Compare commits

..

1 Commits

Author SHA1 Message Date
Brad Fitzpatrick
c084e3f6ec net/netcheck: respect DERPRegion.Avoid on initial probe plan too
As found by @jwhited/@raggi.

Updates #8603
Updates #13969
Updates tailscale/corp#24697

Change-Id: I32bb412a06e46a5fc154d87147e75363cf0d5407
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2024-11-20 10:11:51 -08:00
585 changed files with 7867 additions and 57252 deletions

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: [ ubuntu-latest ]
steps:
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Build checklocks
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks

View File

@@ -45,17 +45,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
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@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
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.
@@ -66,7 +66,7 @@ jobs:
# 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@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
uses: github/codeql-action/autobuild@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -80,4 +80,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
uses: github/codeql-action/analyze@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1

View File

@@ -10,6 +10,6 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: "Build Docker image"
run: docker build .

View File

@@ -17,7 +17,7 @@ jobs:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}"
- uses: "DeterminateSystems/nix-installer-action@main"

View File

@@ -23,17 +23,18 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
- uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version-file: go.mod
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@2226d7cb06a077cd73e56eedd38eecad18e5d837 # v6.5.0
# Note: this is the 'v6.1.0' tag as of 2024-08-21
uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86
with:
version: v1.64
version: v1.60
# Show only new issues if it's a pull request.
only-new-issues: true

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Install govulncheck
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
@@ -24,13 +24,13 @@ jobs:
- name: Post to slack
if: failure() && github.event_name == 'schedule'
uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
env:
SLACK_BOT_TOKEN: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
with:
method: chat.postMessage
token: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
channel-id: 'C05PXRM304B'
payload: |
{
"channel": "C05PXRM304B",
"blocks": [
{
"type": "section",

View File

@@ -6,13 +6,11 @@ on:
- "main"
paths:
- scripts/installer.sh
- .github/workflows/installer.yml
pull_request:
branches:
- "*"
paths:
- scripts/installer.sh
- .github/workflows/installer.yml
jobs:
test:
@@ -31,11 +29,13 @@ jobs:
- "debian:stable-slim"
- "debian:testing-slim"
- "debian:sid-slim"
- "ubuntu:18.04"
- "ubuntu:20.04"
- "ubuntu:22.04"
- "ubuntu:24.04"
- "ubuntu:23.04"
- "elementary/docker:stable"
- "elementary/docker:unstable"
- "parrotsec/core:lts-amd64"
- "parrotsec/core:latest"
- "kalilinux/kali-rolling"
- "kalilinux/kali-dev"
@@ -48,7 +48,7 @@ jobs:
- "opensuse/leap:latest"
- "opensuse/tumbleweed:latest"
- "archlinux:latest"
- "alpine:3.21"
- "alpine:3.14"
- "alpine:latest"
- "alpine:edge"
deps:
@@ -58,6 +58,10 @@ jobs:
# 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 }}
@@ -91,7 +95,10 @@ jobs:
|| contains(matrix.image, 'parrotsec')
|| contains(matrix.image, 'kalilinux')
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# 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

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: [ ubuntu-latest ]
steps:
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Build and lint Helm chart
run: |
eval `./tool/go run ./cmd/mkversion`

View File

@@ -1,27 +0,0 @@
# Run some natlab integration tests.
# See https://github.com/tailscale/tailscale/issues/13038
name: "natlab-integrationtest"
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
pull_request:
paths:
- "tstest/integration/nat/nat_test.go"
jobs:
natlab-integrationtest:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install qemu
run: |
sudo rm /var/lib/man-db/auto-update
sudo apt-get -y update
sudo apt-get -y remove man-db
sudo apt-get install -y qemu-system-x86 qemu-utils
- name: Run natlab integration tests
run: |
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/integration/nat --run-vm-tests

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Run SSH integration tests
run: |
make sshintegrationtest

View File

@@ -50,7 +50,7 @@ jobs:
- shard: '4/4'
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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
@@ -64,6 +64,7 @@ jobs:
matrix:
include:
- goarch: amd64
coverflags: "-coverprofile=/tmp/coverage.out"
- goarch: amd64
buildflags: "-race"
shard: '1/3'
@@ -77,9 +78,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
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
@@ -118,10 +119,15 @@ jobs:
- 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.buildflags}}
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:
@@ -139,25 +145,21 @@ jobs:
echo "Build/test created untracked files in the repo (file names above)."
exit 1
fi
- name: Tidy cache
shell: bash
run: |
find $(go env GOCACHE) -type f -mmin +90 -delete
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
windows:
runs-on: windows-2022
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Install Go
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version-file: go.mod
cache: false
- name: Restore Cache
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
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
@@ -180,11 +182,6 @@ jobs:
# Somewhere in the layers (powershell?)
# the equals signs cause great confusion.
run: go test ./... -bench . -benchtime 1x -run "^$"
- name: Tidy cache
shell: bash
run: |
find $(go env GOCACHE) -type f -mmin +90 -delete
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
privileged:
runs-on: ubuntu-22.04
@@ -193,7 +190,7 @@ jobs:
options: --privileged
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: chown
run: chown -R $(id -u):$(id -g) $PWD
- name: privileged tests
@@ -205,7 +202,7 @@ jobs:
if: github.repository == 'tailscale/tailscale'
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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:
@@ -217,7 +214,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: build all
run: ./tool/go install -race ./cmd/...
- name: build tests
@@ -261,9 +258,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
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
@@ -292,18 +289,13 @@ jobs:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
- name: Tidy cache
shell: bash
run: |
find $(go env GOCACHE) -type f -mmin +90 -delete
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: build some
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
env:
@@ -321,19 +313,13 @@ jobs:
# AIX
- goos: aix
goarch: ppc64
# Solaris
- goos: solaris
goarch: amd64
# illumos
- goos: illumos
goarch: amd64
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
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
@@ -356,11 +342,6 @@ jobs:
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: "0"
- name: Tidy cache
shell: bash
run: |
find $(go env GOCACHE) -type f -mmin +90 -delete
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
android:
# similar to cross above, but android fails to build a few pieces of the
@@ -369,7 +350,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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.
@@ -384,9 +365,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Restore Cache
uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
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
@@ -413,17 +394,12 @@ jobs:
run: |
./tool/go run ./cmd/tsconnect --fast-compression build
./tool/go run ./cmd/tsconnect --fast-compression build-pkg
- name: Tidy cache
shell: bash
run: |
find $(go env GOCACHE) -type f -mmin +90 -delete
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
tailscale_go: # Subset of tests that depend on our custom Go toolchain.
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: test tailscale_go
run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/...
@@ -485,7 +461,7 @@ jobs:
run: |
echo "artifacts_path=$(realpath .)" >> $GITHUB_ENV
- name: upload crash
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
with:
name: artifacts
@@ -495,17 +471,17 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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 --internal
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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')
@@ -518,7 +494,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: check that 'go mod tidy' is clean
run: |
./tool/go mod tidy
@@ -530,7 +506,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: check licenses
run: ./scripts/check_license_headers.sh .
@@ -546,7 +522,7 @@ jobs:
goarch: "386"
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
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
@@ -587,10 +563,8 @@ jobs:
# 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@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"attachments": [{
@@ -602,6 +576,9 @@ jobs:
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
check_mergeability:
if: always()

View File

@@ -21,7 +21,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Run update-flakes
run: ./update-flake.sh
@@ -36,7 +36,7 @@ jobs:
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
- name: Send pull request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5
with:
token: ${{ steps.generate-token.outputs.token }}
author: Flakes Updater <noreply+flakes-updater@tailscale.com>

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Run go get
run: |
@@ -35,7 +35,7 @@ jobs:
- name: Send pull request
id: pull-request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5
with:
token: ${{ steps.generate-token.outputs.token }}
author: OSS Updater <noreply+oss-updater@tailscale.com>

View File

@@ -24,7 +24,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Install deps
run: ./tool/yarn --cwd client/web
- name: Run lint

View File

@@ -26,11 +26,16 @@ issues:
# Per-linter settings are contained in this top-level key
linters-settings:
# Enable all rules by default; we don't use invisible unicode runes.
bidichk:
gofmt:
rewrite-rules:
- pattern: 'interface{}'
replacement: 'any'
goimports:
govet:
# Matches what we use in corp as of 2023-12-07
enable:
@@ -73,6 +78,8 @@ linters-settings:
# analyzer doesn't support type declarations
#- github.com/tailscale/tailscale/types/logger.Logf
misspell:
revive:
enable-all-rules: false
ignore-generated-header: true

View File

@@ -27,7 +27,7 @@
# $ docker exec tailscaled tailscale status
FROM golang:1.24-alpine AS build-env
FROM golang:1.23-alpine AS build-env
WORKDIR /go/src/tailscale

View File

@@ -17,7 +17,7 @@ lint: ## Run golangci-lint
updatedeps: ## Update depaware deps
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
# it finds in its $$PATH is the right one.
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update --internal \
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update \
tailscale.com/cmd/tailscaled \
tailscale.com/cmd/tailscale \
tailscale.com/cmd/derper \
@@ -27,7 +27,7 @@ updatedeps: ## Update depaware deps
depaware: ## Run depaware checks
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
# it finds in its $$PATH is the right one.
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check --internal \
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check \
tailscale.com/cmd/tailscaled \
tailscale.com/cmd/tailscale \
tailscale.com/cmd/derper \
@@ -100,7 +100,7 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-operator ./build_docker.sh
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh
publishdevnameserver: ## Build and publish k8s-nameserver image to location specified by ${REPO}
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
@@ -116,6 +116,7 @@ sshintegrationtest: ## Run the SSH integration tests in various Docker container
GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers

View File

@@ -72,7 +72,7 @@ Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin)
`Signed-off-by` lines in commits.
See `git log` for our commit message style. It's basically the same as
[Go's style](https://go.dev/wiki/CommitMessage).
[Go's style](https://github.com/golang/go/wiki/CommitMessage).
## About Us

View File

@@ -1 +1 @@
1.81.0
1.77.0

View File

@@ -18,6 +18,7 @@ import (
"sync"
"time"
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/types/logger"
"tailscale.com/types/views"
@@ -289,14 +290,12 @@ func (e *AppConnector) updateDomains(domains []string) {
toRemove = append(toRemove, netip.PrefixFrom(a, a.BitLen()))
}
}
e.queue.Add(func() {
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes on domain removal: %v: %v: %v", slicesx.MapKeys(oldDomains), toRemove, err)
}
})
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes on domain removal: %v: %v: %v", xmaps.Keys(oldDomains), toRemove, err)
}
}
e.logf("handling domains: %v and wildcards: %v", slicesx.MapKeys(e.domains), e.wildcards)
e.logf("handling domains: %v and wildcards: %v", xmaps.Keys(e.domains), e.wildcards)
}
// updateRoutes merges the supplied routes into the currently configured routes. The routes supplied
@@ -312,6 +311,11 @@ func (e *AppConnector) updateRoutes(routes []netip.Prefix) {
return
}
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
e.logf("failed to advertise routes: %v: %v", routes, err)
return
}
var toRemove []netip.Prefix
// If we're storing routes and know e.controlRoutes is a good
@@ -335,14 +339,9 @@ nextRoute:
}
}
e.queue.Add(func() {
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
e.logf("failed to advertise routes: %v: %v", routes, err)
}
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes: %v: %v", toRemove, err)
}
})
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes: %v: %v", toRemove, err)
}
e.controlRoutes = routes
if err := e.storeRoutesLocked(); err != nil {
@@ -355,7 +354,7 @@ func (e *AppConnector) Domains() views.Slice[string] {
e.mu.Lock()
defer e.mu.Unlock()
return views.SliceOf(slicesx.MapKeys(e.domains))
return views.SliceOf(xmaps.Keys(e.domains))
}
// DomainRoutes returns a map of domains to resolved IP
@@ -376,13 +375,13 @@ func (e *AppConnector) DomainRoutes() map[string][]netip.Addr {
// response is being returned over the PeerAPI. The response is parsed and
// matched against the configured domains, if matched the routeAdvertiser is
// advised to advertise the discovered route.
func (e *AppConnector) ObserveDNSResponse(res []byte) error {
func (e *AppConnector) ObserveDNSResponse(res []byte) {
var p dnsmessage.Parser
if _, err := p.Start(res); err != nil {
return err
return
}
if err := p.SkipAllQuestions(); err != nil {
return err
return
}
// cnameChain tracks a chain of CNAMEs for a given query in order to reverse
@@ -401,12 +400,12 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) error {
break
}
if err != nil {
return err
return
}
if h.Class != dnsmessage.ClassINET {
if err := p.SkipAnswer(); err != nil {
return err
return
}
continue
}
@@ -415,7 +414,7 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) error {
case dnsmessage.TypeCNAME, dnsmessage.TypeA, dnsmessage.TypeAAAA:
default:
if err := p.SkipAnswer(); err != nil {
return err
return
}
continue
@@ -429,7 +428,7 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) error {
if h.Type == dnsmessage.TypeCNAME {
res, err := p.CNAMEResource()
if err != nil {
return err
return
}
cname := strings.TrimSuffix(strings.ToLower(res.CNAME.String()), ".")
if len(cname) == 0 {
@@ -443,20 +442,20 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) error {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return err
return
}
addr := netip.AddrFrom4(r.A)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return err
return
}
addr := netip.AddrFrom16(r.AAAA)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
default:
if err := p.SkipAnswer(); err != nil {
return err
return
}
continue
}
@@ -487,7 +486,6 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) error {
e.scheduleAdvertisement(domain, toAdvertise...)
}
}
return nil
}
// starting from the given domain that resolved to an address, find it, or any

View File

@@ -8,17 +8,16 @@ import (
"net/netip"
"reflect"
"slices"
"sync/atomic"
"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"
"tailscale.com/util/slicesx"
)
func fakeStoreRoutes(*RouteInfo) error { return nil }
@@ -51,7 +50,7 @@ func TestUpdateDomains(t *testing.T) {
// domains are explicitly downcased on set.
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
a.Wait(ctx)
if got, want := slicesx.MapKeys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
@@ -70,9 +69,7 @@ func TestUpdateRoutes(t *testing.T) {
a.updateDomains([]string{"*.example.com"})
// This route should be collapsed into the range
if err := a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
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")}) {
@@ -80,14 +77,11 @@ func TestUpdateRoutes(t *testing.T) {
}
// This route should not be collapsed or removed
if err := a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
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)
a.Wait(ctx)
slices.SortFunc(rc.Routes(), prefixCompare)
rc.SetRoutes(slices.Compact(rc.Routes()))
@@ -107,7 +101,6 @@ func TestUpdateRoutes(t *testing.T) {
}
func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) {
ctx := context.Background()
for _, shouldStore := range []bool{false, true} {
rc := &appctest.RouteCollector{}
var a *AppConnector
@@ -120,7 +113,6 @@ func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) {
rc.SetRoutes([]netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}
a.updateRoutes(routes)
a.Wait(ctx)
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
t.Fatalf("got %v, want %v", rc.Routes(), routes)
@@ -138,9 +130,7 @@ func TestDomainRoutes(t *testing.T) {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
a.updateDomains([]string{"example.com"})
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
a.Wait(context.Background())
want := map[string][]netip.Addr{
@@ -165,9 +155,7 @@ func TestObserveDNSResponse(t *testing.T) {
}
// a has no domains configured, so it should not advertise any routes
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
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)
}
@@ -175,9 +163,7 @@ func TestObserveDNSResponse(t *testing.T) {
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
a.updateDomains([]string{"example.com"})
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
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)
@@ -186,9 +172,7 @@ func TestObserveDNSResponse(t *testing.T) {
// 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"})
if err := a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com.")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
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) {
@@ -197,9 +181,7 @@ func TestObserveDNSResponse(t *testing.T) {
// a CNAME record chain should result in a route being added if the chain
// even if only found in the middle of the chain
if err := a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org.")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
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) {
@@ -208,18 +190,14 @@ func TestObserveDNSResponse(t *testing.T) {
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
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
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
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)
@@ -229,9 +207,7 @@ func TestObserveDNSResponse(t *testing.T) {
pfx := netip.MustParsePrefix("192.0.2.0/24")
a.updateRoutes([]netip.Prefix{pfx})
wantRoutes = append(wantRoutes, pfx)
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
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)
@@ -254,9 +230,7 @@ func TestWildcardDomains(t *testing.T) {
}
a.updateDomains([]string{"*.example.com"})
if err := a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8")); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
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)
@@ -464,16 +438,10 @@ func TestUpdateDomainRouteRemoval(t *testing.T) {
// adding domains doesn't immediately cause any routes to be advertised
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
for _, res := range [][]byte{
dnsResponse("a.example.com.", "1.2.3.1"),
dnsResponse("a.example.com.", "1.2.3.2"),
dnsResponse("b.example.com.", "1.2.3.3"),
dnsResponse("b.example.com.", "1.2.3.4"),
} {
if err := a.ObserveDNSResponse(res); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
}
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{})
@@ -519,16 +487,10 @@ func TestUpdateWildcardRouteRemoval(t *testing.T) {
// adding domains doesn't immediately cause any routes to be advertised
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
for _, res := range [][]byte{
dnsResponse("a.example.com.", "1.2.3.1"),
dnsResponse("a.example.com.", "1.2.3.2"),
dnsResponse("1.b.example.com.", "1.2.3.3"),
dnsResponse("2.b.example.com.", "1.2.3.4"),
} {
if err := a.ObserveDNSResponse(res); err != nil {
t.Errorf("ObserveDNSResponse: %v", err)
}
}
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{})
@@ -640,57 +602,3 @@ func TestMetricBucketsAreSorted(t *testing.T) {
t.Errorf("metricStoreRoutesNBuckets must be in order")
}
}
// TestUpdateRoutesDeadlock is a regression test for a deadlock in
// LocalBackend<->AppConnector interaction. When using real LocalBackend as the
// routeAdvertiser, calls to Advertise/UnadvertiseRoutes can end up calling
// back into AppConnector via authReconfig. If everything is called
// synchronously, this results in a deadlock on AppConnector.mu.
func TestUpdateRoutesDeadlock(t *testing.T) {
ctx := context.Background()
rc := &appctest.RouteCollector{}
a := NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
advertiseCalled := new(atomic.Bool)
unadvertiseCalled := new(atomic.Bool)
rc.AdvertiseCallback = func() {
// Call something that requires a.mu to be held.
a.DomainRoutes()
advertiseCalled.Store(true)
}
rc.UnadvertiseCallback = func() {
// Call something that requires a.mu to be held.
a.DomainRoutes()
unadvertiseCalled.Store(true)
}
a.updateDomains([]string{"example.com"})
a.Wait(ctx)
// Trigger rc.AdveriseRoute.
a.updateRoutes(
[]netip.Prefix{
netip.MustParsePrefix("127.0.0.1/32"),
netip.MustParsePrefix("127.0.0.2/32"),
},
)
a.Wait(ctx)
// Trigger rc.UnadveriseRoute.
a.updateRoutes(
[]netip.Prefix{
netip.MustParsePrefix("127.0.0.1/32"),
},
)
a.Wait(ctx)
if !advertiseCalled.Load() {
t.Error("AdvertiseRoute was not called")
}
if !unadvertiseCalled.Load() {
t.Error("UnadvertiseRoute was not called")
}
if want := []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}; !slices.Equal(slices.Compact(rc.Routes()), want) {
t.Fatalf("got %v, want %v", rc.Routes(), want)
}
}

View File

@@ -11,22 +11,12 @@ import (
// RouteCollector is a test helper that collects the list of routes advertised
type RouteCollector struct {
// AdvertiseCallback (optional) is called synchronously from
// AdvertiseRoute.
AdvertiseCallback func()
// UnadvertiseCallback (optional) is called synchronously from
// UnadvertiseRoute.
UnadvertiseCallback func()
routes []netip.Prefix
removedRoutes []netip.Prefix
}
func (rc *RouteCollector) AdvertiseRoute(pfx ...netip.Prefix) error {
rc.routes = append(rc.routes, pfx...)
if rc.AdvertiseCallback != nil {
rc.AdvertiseCallback()
}
return nil
}
@@ -40,9 +30,6 @@ func (rc *RouteCollector) UnadvertiseRoute(toRemove ...netip.Prefix) error {
rc.removedRoutes = append(rc.removedRoutes, r)
}
}
if rc.UnadvertiseCallback != nil {
rc.UnadvertiseCallback()
}
return nil
}

View File

@@ -15,9 +15,8 @@ import (
)
// WriteFile writes data to filename+some suffix, then renames it into filename.
// The perm argument is ignored on Windows, but if the target filename already
// exists then the target file's attributes and ACLs are preserved. If the target
// filename already exists but is not a regular file, WriteFile returns an error.
// The perm argument is ignored on Windows. If the target filename already
// exists but is not a regular file, WriteFile returns an error.
func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
fi, err := os.Stat(filename)
if err == nil && !fi.Mode().IsRegular() {
@@ -48,5 +47,5 @@ func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
if err := f.Close(); err != nil {
return err
}
return rename(tmpName, filename)
return os.Rename(tmpName, filename)
}

View File

@@ -1,14 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows
package atomicfile
import (
"os"
)
func rename(srcFile, destFile string) error {
return os.Rename(srcFile, destFile)
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package atomicfile
import (
"os"
"golang.org/x/sys/windows"
)
func rename(srcFile, destFile string) error {
// Use replaceFile when possible to preserve the original file's attributes and ACLs.
if err := replaceFile(destFile, srcFile); err == nil || err != windows.ERROR_FILE_NOT_FOUND {
return err
}
// destFile doesn't exist. Just do a normal rename.
return os.Rename(srcFile, destFile)
}
func replaceFile(destFile, srcFile string) error {
destFile16, err := windows.UTF16PtrFromString(destFile)
if err != nil {
return err
}
srcFile16, err := windows.UTF16PtrFromString(srcFile)
if err != nil {
return err
}
return replaceFileW(destFile16, srcFile16, nil, 0, nil, nil)
}

View File

@@ -1,146 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package atomicfile
import (
"os"
"testing"
"unsafe"
"golang.org/x/sys/windows"
)
var _SECURITY_RESOURCE_MANAGER_AUTHORITY = windows.SidIdentifierAuthority{[6]byte{0, 0, 0, 0, 0, 9}}
// makeRandomSID generates a SID derived from a v4 GUID.
// This is basically the same algorithm used by browser sandboxes for generating
// random SIDs.
func makeRandomSID() (*windows.SID, error) {
guid, err := windows.GenerateGUID()
if err != nil {
return nil, err
}
rids := *((*[4]uint32)(unsafe.Pointer(&guid)))
var pSID *windows.SID
if err := windows.AllocateAndInitializeSid(&_SECURITY_RESOURCE_MANAGER_AUTHORITY, 4, rids[0], rids[1], rids[2], rids[3], 0, 0, 0, 0, &pSID); err != nil {
return nil, err
}
defer windows.FreeSid(pSID)
// Make a copy that lives on the Go heap
return pSID.Copy()
}
func getExistingFileSD(name string) (*windows.SECURITY_DESCRIPTOR, error) {
const infoFlags = windows.DACL_SECURITY_INFORMATION
return windows.GetNamedSecurityInfo(name, windows.SE_FILE_OBJECT, infoFlags)
}
func getExistingFileDACL(name string) (*windows.ACL, error) {
sd, err := getExistingFileSD(name)
if err != nil {
return nil, err
}
dacl, _, err := sd.DACL()
return dacl, err
}
func addDenyACEForRandomSID(dacl *windows.ACL) (*windows.ACL, error) {
randomSID, err := makeRandomSID()
if err != nil {
return nil, err
}
randomSIDTrustee := windows.TRUSTEE{nil, windows.NO_MULTIPLE_TRUSTEE,
windows.TRUSTEE_IS_SID, windows.TRUSTEE_IS_UNKNOWN,
windows.TrusteeValueFromSID(randomSID)}
entries := []windows.EXPLICIT_ACCESS{
{
windows.GENERIC_ALL,
windows.DENY_ACCESS,
windows.NO_INHERITANCE,
randomSIDTrustee,
},
}
return windows.ACLFromEntries(entries, dacl)
}
func setExistingFileDACL(name string, dacl *windows.ACL) error {
return windows.SetNamedSecurityInfo(name, windows.SE_FILE_OBJECT,
windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil)
}
// makeOrigFileWithCustomDACL creates a new, temporary file with a custom
// DACL that we can check for later. It returns the name of the temporary
// file and the security descriptor for the file in SDDL format.
func makeOrigFileWithCustomDACL() (name, sddl string, err error) {
f, err := os.CreateTemp("", "foo*.tmp")
if err != nil {
return "", "", err
}
name = f.Name()
if err := f.Close(); err != nil {
return "", "", err
}
f = nil
defer func() {
if err != nil {
os.Remove(name)
}
}()
dacl, err := getExistingFileDACL(name)
if err != nil {
return "", "", err
}
// Add a harmless, deny-only ACE for a random SID that isn't used for anything
// (but that we can check for later).
dacl, err = addDenyACEForRandomSID(dacl)
if err != nil {
return "", "", err
}
if err := setExistingFileDACL(name, dacl); err != nil {
return "", "", err
}
sd, err := getExistingFileSD(name)
if err != nil {
return "", "", err
}
return name, sd.String(), nil
}
func TestPreserveSecurityInfo(t *testing.T) {
// Make a test file with a custom ACL.
origFileName, want, err := makeOrigFileWithCustomDACL()
if err != nil {
t.Fatalf("makeOrigFileWithCustomDACL returned %v", err)
}
t.Cleanup(func() {
os.Remove(origFileName)
})
if err := WriteFile(origFileName, []byte{}, 0); err != nil {
t.Fatalf("WriteFile returned %v", err)
}
// We expect origFileName's security descriptor to be unchanged despite
// the WriteFile call.
sd, err := getExistingFileSD(origFileName)
if err != nil {
t.Fatalf("getExistingFileSD(%q) returned %v", origFileName, err)
}
if got := sd.String(); got != want {
t.Errorf("security descriptor comparison failed: got %q, want %q", got, want)
}
}

View File

@@ -1,8 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package atomicfile
//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go mksyscall.go
//sys replaceFileW(replaced *uint16, replacement *uint16, backup *uint16, flags uint32, exclude unsafe.Pointer, reserved unsafe.Pointer) (err error) [int32(failretval)==0] = kernel32.ReplaceFileW

View File

@@ -1,52 +0,0 @@
// Code generated by 'go generate'; DO NOT EDIT.
package atomicfile
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 (
modkernel32 = windows.NewLazySystemDLL("kernel32.dll")
procReplaceFileW = modkernel32.NewProc("ReplaceFileW")
)
func replaceFileW(replaced *uint16, replacement *uint16, backup *uint16, flags uint32, exclude unsafe.Pointer, reserved unsafe.Pointer) (err error) {
r1, _, e1 := syscall.Syscall6(procReplaceFileW.Addr(), 6, uintptr(unsafe.Pointer(replaced)), uintptr(unsafe.Pointer(replacement)), uintptr(unsafe.Pointer(backup)), uintptr(flags), uintptr(exclude), uintptr(reserved))
if int32(r1) == 0 {
err = errnoErr(e1)
}
return
}

View File

@@ -37,7 +37,7 @@ while [ "$#" -gt 1 ]; do
--extra-small)
shift
ldflags="$ldflags -w -s"
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion,ts_omit_ssh,ts_omit_wakeonlan,ts_omit_capture"
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion"
;;
--box)
shift

View File

@@ -54,7 +54,7 @@ case "$TARGET" in
--annotations="${ANNOTATIONS}" \
/usr/local/bin/containerboot
;;
k8s-operator)
operator)
DEFAULT_REPOS="tailscale/k8s-operator"
REPOS="${REPOS:-${DEFAULT_REPOS}}"
go run github.com/tailscale/mkctr \

View File

@@ -1,319 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build cgo || !darwin
package systray
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"sync"
"time"
"fyne.io/systray"
"github.com/fogleman/gg"
)
// tsLogo represents the Tailscale logo displayed as the systray icon.
type tsLogo struct {
// dots represents the state of the 3x3 dot grid in the logo.
// A 0 represents a gray dot, any other value is a white dot.
dots [9]byte
// dotMask returns an image mask to be used when rendering the logo dots.
dotMask func(dc *gg.Context, borderUnits int, radius int) *image.Alpha
// overlay is called after the dots are rendered to draw an additional overlay.
overlay func(dc *gg.Context, borderUnits int, radius int)
}
var (
// disconnected is all gray dots
disconnected = tsLogo{dots: [9]byte{
0, 0, 0,
0, 0, 0,
0, 0, 0,
}}
// connected is the normal Tailscale logo
connected = tsLogo{dots: [9]byte{
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{dots: [9]byte{'l', 'o', 'a', 'd', 'i', 'n', 'g'}}
// loadingIcons are shown in sequence as an animated loading icon.
loadingLogos = []tsLogo{
{dots: [9]byte{
0, 1, 1,
1, 0, 1,
0, 0, 1,
}},
{dots: [9]byte{
0, 1, 1,
0, 0, 1,
0, 1, 0,
}},
{dots: [9]byte{
0, 1, 1,
0, 0, 0,
0, 0, 1,
}},
{dots: [9]byte{
0, 0, 1,
0, 1, 0,
0, 0, 0,
}},
{dots: [9]byte{
0, 1, 0,
0, 0, 0,
0, 0, 0,
}},
{dots: [9]byte{
0, 0, 0,
0, 0, 1,
0, 0, 0,
}},
{dots: [9]byte{
0, 0, 0,
0, 0, 0,
0, 0, 0,
}},
{dots: [9]byte{
0, 0, 1,
0, 0, 0,
0, 0, 0,
}},
{dots: [9]byte{
0, 0, 0,
0, 0, 0,
1, 0, 0,
}},
{dots: [9]byte{
0, 0, 0,
0, 0, 0,
1, 1, 0,
}},
{dots: [9]byte{
0, 0, 0,
1, 0, 0,
1, 1, 0,
}},
{dots: [9]byte{
0, 0, 0,
1, 1, 0,
0, 1, 0,
}},
{dots: [9]byte{
0, 0, 0,
1, 1, 0,
0, 1, 1,
}},
{dots: [9]byte{
0, 0, 0,
1, 1, 1,
0, 0, 1,
}},
{dots: [9]byte{
0, 1, 0,
0, 1, 1,
1, 0, 1,
}},
}
// exitNodeOnline is the Tailscale logo with an additional arrow overlay in the corner.
exitNodeOnline = tsLogo{
dots: [9]byte{
0, 0, 0,
1, 1, 1,
0, 1, 0,
},
// draw an arrow mask in the bottom right corner with a reasonably thick line width.
dotMask: func(dc *gg.Context, borderUnits int, radius int) *image.Alpha {
bu, r := float64(borderUnits), float64(radius)
x1 := r * (bu + 3.5)
y := r * (bu + 7)
x2 := x1 + (r * 5)
mc := gg.NewContext(dc.Width(), dc.Height())
mc.DrawLine(x1, y, x2, y) // arrow center line
mc.DrawLine(x2-(1.5*r), y-(1.5*r), x2, y) // top of arrow tip
mc.DrawLine(x2-(1.5*r), y+(1.5*r), x2, y) // bottom of arrow tip
mc.SetLineWidth(r * 3)
mc.Stroke()
return mc.AsMask()
},
// draw an arrow in the bottom right corner over the masked area.
overlay: func(dc *gg.Context, borderUnits int, radius int) {
bu, r := float64(borderUnits), float64(radius)
x1 := r * (bu + 3.5)
y := r * (bu + 7)
x2 := x1 + (r * 5)
dc.DrawLine(x1, y, x2, y) // arrow center line
dc.DrawLine(x2-(1.5*r), y-(1.5*r), x2, y) // top of arrow tip
dc.DrawLine(x2-(1.5*r), y+(1.5*r), x2, y) // bottom of arrow tip
dc.SetColor(fg)
dc.SetLineWidth(r)
dc.Stroke()
},
}
// exitNodeOffline is the Tailscale logo with a red "x" in the corner.
exitNodeOffline = tsLogo{
dots: [9]byte{
0, 0, 0,
1, 1, 1,
0, 1, 0,
},
// Draw a square that hides the four dots in the bottom right corner,
dotMask: func(dc *gg.Context, borderUnits int, radius int) *image.Alpha {
bu, r := float64(borderUnits), float64(radius)
x := r * (bu + 3)
mc := gg.NewContext(dc.Width(), dc.Height())
mc.DrawRectangle(x, x, r*6, r*6)
mc.Fill()
return mc.AsMask()
},
// draw a red "x" over the bottom right corner.
overlay: func(dc *gg.Context, borderUnits int, radius int) {
bu, r := float64(borderUnits), float64(radius)
x1 := r * (bu + 4)
x2 := x1 + (r * 3.5)
dc.DrawLine(x1, x1, x2, x2) // top-left to bottom-right stroke
dc.DrawLine(x1, x2, x2, x1) // bottom-left to top-right stroke
dc.SetColor(red)
dc.SetLineWidth(r)
dc.Stroke()
},
}
)
var (
bg = color.NRGBA{0, 0, 0, 255}
fg = color.NRGBA{255, 255, 255, 255}
gray = color.NRGBA{255, 255, 255, 102}
red = color.NRGBA{229, 111, 74, 255}
)
// render returns a PNG image of the logo.
func (logo tsLogo) render() *bytes.Buffer {
const borderUnits = 1
return logo.renderWithBorder(borderUnits)
}
// renderWithBorder returns a PNG image of the logo with the specified border width.
// One border unit is equal to the radius of a tailscale logo dot.
func (logo tsLogo) renderWithBorder(borderUnits int) *bytes.Buffer {
const radius = 25
dim := radius * (8 + borderUnits*2)
dc := gg.NewContext(dim, dim)
dc.DrawRectangle(0, 0, float64(dim), float64(dim))
dc.SetColor(bg)
dc.Fill()
if logo.dotMask != nil {
mask := logo.dotMask(dc, borderUnits, radius)
dc.SetMask(mask)
dc.InvertMask()
}
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 := fg
if logo.dots[y*3+x] == 0 {
col = gray
}
dc.DrawCircle(float64(px), float64(py), radius)
dc.SetColor(col)
dc.Fill()
}
}
if logo.overlay != nil {
dc.ResetClip()
logo.overlay(dc, borderUnits, radius)
}
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.dots == loading.dots {
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
}
}

View File

@@ -1,740 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build cgo || !darwin
// Package systray provides a minimal Tailscale systray application.
package systray
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"runtime"
"slices"
"strings"
"sync"
"syscall"
"time"
"fyne.io/systray"
"github.com/atotto/clipboard"
dbus "github.com/godbus/dbus/v5"
"github.com/toqueteos/webbrowser"
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/slicesx"
"tailscale.com/util/stringsx"
)
var (
// newMenuDelay is the amount of time to sleep after creating a new menu,
// but before adding items to it. This works around a bug in some dbus implementations.
newMenuDelay time.Duration
// if true, treat all mullvad exit node countries as single-city.
// Instead of rendering a submenu with cities, just select the highest-priority peer.
hideMullvadCities bool
)
// Run starts the systray menu and blocks until the menu exits.
func (menu *Menu) Run() {
menu.updateState()
// exit cleanly on SIGINT and SIGTERM
go func() {
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
select {
case <-interrupt:
menu.onExit()
case <-menu.bgCtx.Done():
}
}()
go menu.lc.IncrementCounter(menu.bgCtx, "systray_start", 1)
systray.Run(menu.onReady, menu.onExit)
}
// Menu represents the systray menu, its items, and the current Tailscale state.
type Menu struct {
mu sync.Mutex // protects the entire Menu
lc local.Client
status *ipnstate.Status
curProfile ipn.LoginProfile
allProfiles []ipn.LoginProfile
// readonly is whether the systray app is running in read-only mode.
// This is set if LocalAPI returns a permission error,
// typically because the user needs to run `tailscale set --operator=$USER`.
readonly bool
bgCtx context.Context // ctx for background tasks not involving menu item clicks
bgCancel context.CancelFunc
// Top-level menu items
connect *systray.MenuItem
disconnect *systray.MenuItem
self *systray.MenuItem
exitNodes *systray.MenuItem
more *systray.MenuItem
quit *systray.MenuItem
rebuildCh chan struct{} // triggers a menu rebuild
accountsCh chan ipn.ProfileID
exitNodeCh chan tailcfg.StableNodeID // ID of selected exit node
eventCancel context.CancelFunc // cancel eventLoop
notificationIcon *os.File // icon used for desktop notifications
}
func (menu *Menu) init() {
if menu.bgCtx != nil {
// already initialized
return
}
menu.rebuildCh = make(chan struct{}, 1)
menu.accountsCh = make(chan ipn.ProfileID)
menu.exitNodeCh = make(chan tailcfg.StableNodeID)
// dbus wants a file path for notification icons, so copy to a temp file.
menu.notificationIcon, _ = os.CreateTemp("", "tailscale-systray.png")
io.Copy(menu.notificationIcon, connected.renderWithBorder(3))
menu.bgCtx, menu.bgCancel = context.WithCancel(context.Background())
go menu.watchIPNBus()
}
func init() {
if runtime.GOOS != "linux" {
// so far, these tweaks are only needed on Linux
return
}
desktop := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP"))
switch desktop {
case "gnome":
// GNOME expands submenus downward in the main menu, rather than flyouts to the side.
// Either as a result of that or another limitation, there seems to be a maximum depth of submenus.
// Mullvad countries that have a city submenu are not being rendered, and so can't be selected.
// Handle this by simply treating all mullvad countries as single-city and select the best peer.
hideMullvadCities = true
case "kde":
// KDE doesn't need a delay, and actually won't render submenus
// if we delay for more than about 400µs.
newMenuDelay = 0
default:
// Add a slight delay to ensure the menu is created before adding items.
//
// Systray implementations that use libdbusmenu sometimes process messages out of order,
// resulting in errors such as:
// (waybar:153009): LIBDBUSMENU-GTK-WARNING **: 18:07:11.551: Children but no menu, someone's been naughty with their 'children-display' property: 'submenu'
//
// See also: https://github.com/fyne-io/systray/issues/12
newMenuDelay = 10 * time.Millisecond
}
}
// onReady is called by the systray package when the menu is ready to be built.
func (menu *Menu) onReady() {
log.Printf("starting")
setAppIcon(disconnected)
menu.rebuild()
}
// updateState updates the Menu state from the Tailscale local client.
func (menu *Menu) updateState() {
menu.mu.Lock()
defer menu.mu.Unlock()
menu.init()
menu.readonly = false
var err error
menu.status, err = menu.lc.Status(menu.bgCtx)
if err != nil {
log.Print(err)
}
menu.curProfile, menu.allProfiles, err = menu.lc.ProfileStatus(menu.bgCtx)
if err != nil {
if local.IsAccessDeniedError(err) {
menu.readonly = true
}
log.Print(err)
}
}
// 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() {
menu.mu.Lock()
defer menu.mu.Unlock()
menu.init()
if menu.eventCancel != nil {
menu.eventCancel()
}
ctx := context.Background()
ctx, menu.eventCancel = context.WithCancel(ctx)
systray.ResetMenu()
if menu.readonly {
const readonlyMsg = "No permission to manage Tailscale.\nSee tailscale.com/s/cli-operator"
m := systray.AddMenuItem(readonlyMsg, "")
onClick(ctx, m, func(_ context.Context) {
webbrowser.Open("https://tailscale.com/s/cli-operator")
})
systray.AddSeparator()
}
menu.connect = systray.AddMenuItem("Connect", "")
menu.disconnect = systray.AddMenuItem("Disconnect", "")
menu.disconnect.Hide()
systray.AddSeparator()
// delay to prevent race setting icon on first start
time.Sleep(newMenuDelay)
// Set systray menu icon and title.
// Also adjust connect/disconnect menu items if needed.
var backendState string
if menu.status != nil {
backendState = menu.status.BackendState
}
switch backendState {
case ipn.Running.String():
if menu.status.ExitNodeStatus != nil && !menu.status.ExitNodeStatus.ID.IsZero() {
if menu.status.ExitNodeStatus.Online {
setTooltip("Using exit node")
setAppIcon(exitNodeOnline)
} else {
setTooltip("Exit node offline")
setAppIcon(exitNodeOffline)
}
} else {
setTooltip(fmt.Sprintf("Connected to %s", menu.status.CurrentTailnet.Name))
setAppIcon(connected)
}
menu.connect.SetTitle("Connected")
menu.connect.Disable()
menu.disconnect.Show()
menu.disconnect.Enable()
case ipn.Starting.String():
setTooltip("Connecting")
setAppIcon(loading)
default:
setTooltip("Disconnected")
setAppIcon(disconnected)
}
if menu.readonly {
menu.connect.Disable()
menu.disconnect.Disable()
}
account := "Account"
if pt := profileTitle(menu.curProfile); pt != "" {
account = pt
}
if !menu.readonly {
accounts := systray.AddMenuItem(account, "")
setRemoteIcon(accounts, menu.curProfile.UserProfile.ProfilePicURL)
time.Sleep(newMenuDelay)
for _, profile := range menu.allProfiles {
title := profileTitle(profile)
var item *systray.MenuItem
if profile.ID == menu.curProfile.ID {
item = accounts.AddSubMenuItemCheckbox(title, "", true)
} else {
item = accounts.AddSubMenuItem(title, "")
}
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
onClick(ctx, item, func(ctx context.Context) {
select {
case <-ctx.Done():
case menu.accountsCh <- profile.ID:
}
})
}
}
if menu.status != nil && menu.status.Self != nil && len(menu.status.Self.TailscaleIPs) > 0 {
title := fmt.Sprintf("This Device: %s (%s)", menu.status.Self.HostName, menu.status.Self.TailscaleIPs[0])
menu.self = systray.AddMenuItem(title, "")
} else {
menu.self = systray.AddMenuItem("This Device: not connected", "")
menu.self.Disable()
}
systray.AddSeparator()
if !menu.readonly {
menu.rebuildExitNodeMenu(ctx)
}
if menu.status != nil {
menu.more = systray.AddMenuItem("More settings", "")
onClick(ctx, menu.more, func(_ context.Context) {
webbrowser.Open("http://100.100.100.100/")
})
}
menu.quit = systray.AddMenuItem("Quit", "Quit the app")
menu.quit.Enable()
go menu.eventLoop(ctx)
}
// profileTitle returns the title string for a profile menu item.
func profileTitle(profile ipn.LoginProfile) string {
title := profile.Name
if profile.NetworkProfile.DomainName != "" {
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
// windows and mac don't support multi-line menu
title += " (" + profile.NetworkProfile.DomainName + ")"
} else {
title += "\n" + profile.NetworkProfile.DomainName
}
}
return title
}
var (
cacheMu sync.Mutex
httpCache = map[string][]byte{} // URL => response body
)
// setRemoteIcon sets the icon for menu to the specified remote image.
// Remote images are fetched as needed and cached.
func setRemoteIcon(menu *systray.MenuItem, urlStr string) {
if menu == nil || urlStr == "" {
return
}
cacheMu.Lock()
b, ok := httpCache[urlStr]
if !ok {
resp, err := http.Get(urlStr)
if err == nil && resp.StatusCode == http.StatusOK {
b, _ = io.ReadAll(resp.Body)
httpCache[urlStr] = b
resp.Body.Close()
}
}
cacheMu.Unlock()
if len(b) > 0 {
menu.SetIcon(b)
}
}
// setTooltip sets the tooltip text for the systray icon.
func setTooltip(text string) {
if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
systray.SetTooltip(text)
} else {
// on Linux, SetTitle actually sets the tooltip
systray.SetTitle(text)
}
}
// 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 <-menu.rebuildCh:
menu.updateState()
menu.rebuild()
case <-menu.connect.ClickedCh:
_, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
WantRunning: true,
},
WantRunningSet: true,
})
if err != nil {
log.Printf("error connecting: %v", err)
}
case <-menu.disconnect.ClickedCh:
_, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
WantRunning: false,
},
WantRunningSet: true,
})
if err != nil {
log.Printf("error disconnecting: %v", err)
}
case <-menu.self.ClickedCh:
menu.copyTailscaleIP(menu.status.Self)
case id := <-menu.accountsCh:
if err := menu.lc.SwitchProfile(ctx, id); err != nil {
log.Printf("error switching to profile ID %v: %v", id, err)
}
case exitNode := <-menu.exitNodeCh:
if exitNode.IsZero() {
log.Print("disable exit node")
if err := menu.lc.SetUseExitNode(ctx, false); err != nil {
log.Printf("error disabling exit node: %v", err)
}
} else {
log.Printf("enable exit node: %v", exitNode)
mp := &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeID: exitNode,
},
ExitNodeIDSet: true,
}
if _, err := menu.lc.EditPrefs(ctx, mp); err != nil {
log.Printf("error setting exit node: %v", err)
}
}
case <-menu.quit.ClickedCh:
systray.Quit()
}
}
}
// onClick registers a click handler for a menu item.
func onClick(ctx context.Context, item *systray.MenuItem, fn func(ctx context.Context)) {
go func() {
for {
select {
case <-ctx.Done():
return
case <-item.ClickedCh:
fn(ctx)
}
}
}()
}
// watchIPNBus subscribes to the tailscale event bus and sends state updates to chState.
// This method does not return.
func (menu *Menu) watchIPNBus() {
for {
if err := menu.watchIPNBusInner(); 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 (menu *Menu) watchIPNBusInner() error {
watcher, err := menu.lc.WatchIPNBus(menu.bgCtx, ipn.NotifyNoPrivateKeys)
if err != nil {
return fmt.Errorf("watching ipn bus: %w", err)
}
defer watcher.Close()
for {
select {
case <-menu.bgCtx.Done():
return nil
default:
n, err := watcher.Next()
if err != nil {
return fmt.Errorf("ipnbus error: %w", err)
}
var rebuild bool
if n.State != nil {
log.Printf("new state: %v", n.State)
rebuild = true
}
if n.Prefs != nil {
rebuild = true
}
if rebuild {
menu.rebuildCh <- struct{}{}
}
}
}
}
// copyTailscaleIP copies the first Tailscale IP of the given device to the clipboard
// and sends a notification with the copied value.
func (menu *Menu) 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)
}
menu.sendNotification(fmt.Sprintf("Copied Address for %v", name), ip)
}
// sendNotification sends a desktop notification with the given title and content.
func (menu *Menu) 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),
menu.notificationIcon.Name(), title, content, []string{}, map[string]dbus.Variant{}, int32(timeout.Milliseconds()))
if call.Err != nil {
log.Printf("dbus: %v", call.Err)
}
}
func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
if menu.status == nil {
return
}
status := menu.status
menu.exitNodes = systray.AddMenuItem("Exit Nodes", "")
time.Sleep(newMenuDelay)
// register a click handler for a menu item to set nodeID as the exit node.
setExitNodeOnClick := func(item *systray.MenuItem, nodeID tailcfg.StableNodeID) {
onClick(ctx, item, func(ctx context.Context) {
select {
case <-ctx.Done():
case menu.exitNodeCh <- nodeID:
}
})
}
noExitNodeMenu := menu.exitNodes.AddSubMenuItemCheckbox("None", "", status.ExitNodeStatus == nil)
setExitNodeOnClick(noExitNodeMenu, "")
// Show recommended exit node if available.
if status.Self.CapMap.Contains(tailcfg.NodeAttrSuggestExitNodeUI) {
sugg, err := menu.lc.SuggestExitNode(ctx)
if err == nil {
title := "Recommended: "
if loc := sugg.Location; loc.Valid() && loc.Country() != "" {
flag := countryFlag(loc.CountryCode())
title += fmt.Sprintf("%s %s: %s", flag, loc.Country(), loc.City())
} else {
title += strings.Split(sugg.Name, ".")[0]
}
menu.exitNodes.AddSeparator()
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
setExitNodeOnClick(rm, sugg.ID)
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
rm.Check()
}
}
}
// Add tailnet exit nodes if present.
var tailnetExitNodes []*ipnstate.PeerStatus
for _, ps := range status.Peer {
if ps.ExitNodeOption && ps.Location == nil {
tailnetExitNodes = append(tailnetExitNodes, ps)
}
}
if len(tailnetExitNodes) > 0 {
menu.exitNodes.AddSeparator()
menu.exitNodes.AddSubMenuItem("Tailnet Exit Nodes", "").Disable()
for _, ps := range status.Peer {
if !ps.ExitNodeOption || ps.Location != nil {
continue
}
name := strings.Split(ps.DNSName, ".")[0]
if !ps.Online {
name += " (offline)"
}
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", false)
if !ps.Online {
sm.Disable()
}
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
sm.Check()
}
setExitNodeOnClick(sm, ps.ID)
}
}
// Add mullvad exit nodes if present.
var mullvadExitNodes mullvadPeers
if status.Self.CapMap.Contains("mullvad") {
mullvadExitNodes = newMullvadPeers(status)
}
if len(mullvadExitNodes.countries) > 0 {
menu.exitNodes.AddSeparator()
menu.exitNodes.AddSubMenuItem("Location-based Exit Nodes", "").Disable()
mullvadMenu := menu.exitNodes.AddSubMenuItemCheckbox("Mullvad VPN", "", false)
for _, country := range mullvadExitNodes.sortedCountries() {
flag := countryFlag(country.code)
countryMenu := mullvadMenu.AddSubMenuItemCheckbox(flag+" "+country.name, "", false)
// single-city country, no submenu
if len(country.cities) == 1 || hideMullvadCities {
setExitNodeOnClick(countryMenu, country.best.ID)
if status.ExitNodeStatus != nil {
for _, city := range country.cities {
for _, ps := range city.peers {
if status.ExitNodeStatus.ID == ps.ID {
mullvadMenu.Check()
countryMenu.Check()
}
}
}
}
continue
}
// multi-city country, build submenu with "best available" option and cities.
time.Sleep(newMenuDelay)
bm := countryMenu.AddSubMenuItemCheckbox("Best Available", "", false)
setExitNodeOnClick(bm, country.best.ID)
countryMenu.AddSeparator()
for _, city := range country.sortedCities() {
cityMenu := countryMenu.AddSubMenuItemCheckbox(city.name, "", false)
setExitNodeOnClick(cityMenu, city.best.ID)
if status.ExitNodeStatus != nil {
for _, ps := range city.peers {
if status.ExitNodeStatus.ID == ps.ID {
mullvadMenu.Check()
countryMenu.Check()
cityMenu.Check()
}
}
}
}
}
}
// TODO: "Allow Local Network Access" and "Run Exit Node" menu items
}
// mullvadPeers contains all mullvad peer nodes, sorted by country and city.
type mullvadPeers struct {
countries map[string]*mvCountry // country code (uppercase) => country
}
// sortedCountries returns countries containing mullvad nodes, sorted by name.
func (mp mullvadPeers) sortedCountries() []*mvCountry {
countries := slicesx.MapValues(mp.countries)
slices.SortFunc(countries, func(a, b *mvCountry) int {
return stringsx.CompareFold(a.name, b.name)
})
return countries
}
type mvCountry struct {
code string
name string
best *ipnstate.PeerStatus // highest priority peer in the country
cities map[string]*mvCity // city code => city
}
// sortedCities returns cities containing mullvad nodes, sorted by name.
func (mc *mvCountry) sortedCities() []*mvCity {
cities := slicesx.MapValues(mc.cities)
slices.SortFunc(cities, func(a, b *mvCity) int {
return stringsx.CompareFold(a.name, b.name)
})
return cities
}
// countryFlag takes a 2-character ASCII string and returns the corresponding emoji flag.
// It returns the empty string on error.
func countryFlag(code string) string {
if len(code) != 2 {
return ""
}
runes := make([]rune, 0, 2)
for i := range 2 {
b := code[i] | 32 // lowercase
if b < 'a' || b > 'z' {
return ""
}
// https://en.wikipedia.org/wiki/Regional_indicator_symbol
runes = append(runes, 0x1F1E6+rune(b-'a'))
}
return string(runes)
}
type mvCity struct {
name string
best *ipnstate.PeerStatus // highest priority peer in the city
peers []*ipnstate.PeerStatus
}
func newMullvadPeers(status *ipnstate.Status) mullvadPeers {
countries := make(map[string]*mvCountry)
for _, ps := range status.Peer {
if !ps.ExitNodeOption || ps.Location == nil {
continue
}
loc := ps.Location
country, ok := countries[loc.CountryCode]
if !ok {
country = &mvCountry{
code: loc.CountryCode,
name: loc.Country,
cities: make(map[string]*mvCity),
}
countries[loc.CountryCode] = country
}
city, ok := countries[loc.CountryCode].cities[loc.CityCode]
if !ok {
city = &mvCity{
name: loc.City,
}
countries[loc.CountryCode].cities[loc.CityCode] = city
}
city.peers = append(city.peers, ps)
if city.best == nil || ps.Location.Priority > city.best.Location.Priority {
city.best = ps
}
if country.best == nil || ps.Location.Priority > country.best.Location.Priority {
country.best = ps
}
}
return mullvadPeers{countries}
}
// onExit is called by the systray package when the menu is exiting.
func (menu *Menu) onExit() {
log.Printf("exiting")
if menu.bgCancel != nil {
menu.bgCancel()
}
if menu.eventCancel != nil {
menu.eventCancel()
}
os.Remove(menu.notificationIcon.Name())
}

View File

@@ -12,7 +12,6 @@ import (
"fmt"
"net/http"
"net/netip"
"net/url"
)
// ACLRow defines a rule that grants access by a set of users or groups to a set
@@ -84,7 +83,7 @@ func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
}
}()
path := c.BuildTailnetURL("acl")
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
@@ -98,7 +97,7 @@ func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, HandleErrorResponse(b, resp)
return nil, handleErrorResponse(b, resp)
}
// Otherwise, try to decode the response.
@@ -127,7 +126,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
}
}()
path := c.BuildTailnetURL("acl", url.Values{"details": {"1"}})
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl?details=1", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
@@ -139,7 +138,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
}
if resp.StatusCode != http.StatusOK {
return nil, HandleErrorResponse(b, resp)
return nil, handleErrorResponse(b, resp)
}
data := struct {
@@ -147,7 +146,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
Warnings []string `json:"warnings"`
}{}
if err := json.Unmarshal(b, &data); err != nil {
return nil, fmt.Errorf("json.Unmarshal %q: %w", b, err)
return nil, err
}
acl = &ACLHuJSON{
@@ -185,7 +184,7 @@ func (e ACLTestError) Error() string {
}
func (c *Client) aclPOSTRequest(ctx context.Context, body []byte, avoidCollisions bool, etag, acceptHeader string) ([]byte, string, error) {
path := c.BuildTailnetURL("acl")
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
if err != nil {
return nil, "", err
@@ -329,7 +328,7 @@ type ACLPreview struct {
}
func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) {
path := c.BuildTailnetURL("acl", "preview")
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/preview", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
if err != nil {
return nil, err
@@ -351,7 +350,7 @@ func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, preview
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, HandleErrorResponse(b, resp)
return nil, handleErrorResponse(b, resp)
}
if err = json.Unmarshal(b, &res); err != nil {
return nil, err
@@ -489,7 +488,7 @@ func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (test
return nil, err
}
path := c.BuildTailnetURL("acl", "validate")
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/validate", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(postData))
if err != nil {
return nil, err

View File

@@ -7,29 +7,11 @@ package apitype
import (
"tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
"tailscale.com/util/ctxkey"
)
// LocalAPIHost is the Host header value used by the LocalAPI.
const LocalAPIHost = "local-tailscaled.sock"
// RequestReasonHeader is the header used to pass justification for a LocalAPI request,
// such as when a user wants to perform an action they don't have permission for,
// and a policy allows it with justification. As of 2025-01-29, it is only used to
// allow a user to disconnect Tailscale when the "always-on" mode is enabled.
//
// The header value is base64-encoded using the standard encoding defined in RFC 4648.
//
// See tailscale/corp#26146.
const RequestReasonHeader = "X-Tailscale-Reason"
// RequestReasonKey is the context key used to pass the request reason
// when making a LocalAPI request via [local.Client].
// It's value is a raw string. An empty string means no reason was provided.
//
// See tailscale/corp#26146.
var RequestReasonKey = ctxkey.New(RequestReasonHeader, "")
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
// In successful whois responses, Node and UserProfile are never nil.
type WhoIsResponse struct {

View File

@@ -131,7 +131,7 @@ func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceL
}
}()
path := c.BuildTailnetURL("devices")
path := fmt.Sprintf("%s/api/v2/tailnet/%s/devices", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
@@ -149,7 +149,7 @@ func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceL
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, HandleErrorResponse(b, resp)
return nil, handleErrorResponse(b, resp)
}
var devices GetDevicesResponse
@@ -188,7 +188,7 @@ func (c *Client) Device(ctx context.Context, deviceID string, fields *DeviceFiel
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, HandleErrorResponse(b, resp)
return nil, handleErrorResponse(b, resp)
}
err = json.Unmarshal(b, &device)
@@ -221,7 +221,7 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return HandleErrorResponse(b, resp)
return handleErrorResponse(b, resp)
}
return nil
}
@@ -253,7 +253,7 @@ func (c *Client) SetAuthorized(ctx context.Context, deviceID string, authorized
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return HandleErrorResponse(b, resp)
return handleErrorResponse(b, resp)
}
return nil
@@ -281,7 +281,7 @@ func (c *Client) SetTags(ctx context.Context, deviceID string, tags []string) er
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return HandleErrorResponse(b, resp)
return handleErrorResponse(b, resp)
}
return nil

View File

@@ -44,7 +44,7 @@ type DNSPreferences struct {
}
func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) {
path := c.BuildTailnetURL("dns", endpoint)
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
@@ -57,14 +57,14 @@ func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, er
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, HandleErrorResponse(b, resp)
return nil, handleErrorResponse(b, resp)
}
return b, nil
}
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) {
path := c.BuildTailnetURL("dns", endpoint)
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
data, err := json.Marshal(&postData)
if err != nil {
return nil, err
@@ -84,7 +84,7 @@ func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData a
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, HandleErrorResponse(b, resp)
return nil, handleErrorResponse(b, resp)
}
return b, nil

View File

@@ -11,14 +11,13 @@ import (
"log"
"net/http"
"tailscale.com/client/local"
"tailscale.com/client/tailscale"
)
func main() {
var lc local.Client
s := &http.Server{
TLSConfig: &tls.Config{
GetCertificate: lc.GetCertificate,
GetCertificate: tailscale.GetCertificate,
},
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "<h1>Hello from Tailscale!</h1> It works.")

View File

@@ -40,7 +40,7 @@ type KeyDeviceCreateCapabilities struct {
// Keys returns the list of keys for the current user.
func (c *Client) Keys(ctx context.Context) ([]string, error) {
path := c.BuildTailnetURL("keys")
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
@@ -51,7 +51,7 @@ func (c *Client) Keys(ctx context.Context) ([]string, error) {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, HandleErrorResponse(b, resp)
return nil, handleErrorResponse(b, resp)
}
var keys struct {
@@ -99,7 +99,7 @@ func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities,
return "", nil, err
}
path := c.BuildTailnetURL("keys")
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(bs))
if err != nil {
return "", nil, err
@@ -110,7 +110,7 @@ func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities,
return "", nil, err
}
if resp.StatusCode != http.StatusOK {
return "", nil, HandleErrorResponse(b, resp)
return "", nil, handleErrorResponse(b, resp)
}
var key struct {
@@ -126,7 +126,7 @@ func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities,
// Key returns the metadata for the given key ID. Currently, capabilities are
// only returned for auth keys, API keys only return general metadata.
func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
path := c.BuildTailnetURL("keys", id)
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
@@ -137,7 +137,7 @@ func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, HandleErrorResponse(b, resp)
return nil, handleErrorResponse(b, resp)
}
var key Key
@@ -149,7 +149,7 @@ func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
// DeleteKey deletes the key with the given ID.
func (c *Client) DeleteKey(ctx context.Context, id string) error {
path := c.BuildTailnetURL("keys", id)
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
if err != nil {
return err
@@ -160,7 +160,7 @@ func (c *Client) DeleteKey(ctx context.Context, id string) error {
return err
}
if resp.StatusCode != http.StatusOK {
return HandleErrorResponse(b, resp)
return handleErrorResponse(b, resp)
}
return nil
}

View File

@@ -1,17 +1,15 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.22
//go:build go1.19
// Package local contains a Go client for the Tailscale LocalAPI.
package local
package tailscale
import (
"bytes"
"cmp"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -45,11 +43,11 @@ import (
"tailscale.com/util/syspolicy/setting"
)
// defaultClient is the default Client when using the legacy
// defaultLocalClient is the default LocalClient when using the legacy
// package-level functions.
var defaultClient Client
var defaultLocalClient LocalClient
// Client is a client to Tailscale's "LocalAPI", communicating with the
// LocalClient is a client to Tailscale's "LocalAPI", communicating with the
// Tailscale daemon on the local machine. Its API is not necessarily stable and
// subject to changes between releases. Some API calls have stricter
// compatibility guarantees, once they've been widely adopted. See method docs
@@ -59,17 +57,11 @@ var defaultClient Client
//
// Any exported fields should be set before using methods on the type
// and not changed thereafter.
type Client struct {
type LocalClient struct {
// Dial optionally specifies an alternate func that connects to the local
// machine's tailscaled or equivalent. If nil, a default is used.
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
// Transport optionally specifies an alternate [http.RoundTripper]
// used to execute HTTP requests. If nil, a default [http.Transport] is used,
// potentially with custom dialing logic from [Dial].
// It is primarily used for testing.
Transport http.RoundTripper
// Socket specifies an alternate path to the local Tailscale socket.
// If empty, a platform-specific default is used.
Socket string
@@ -93,21 +85,21 @@ type Client struct {
tsClientOnce sync.Once
}
func (lc *Client) socket() string {
func (lc *LocalClient) socket() string {
if lc.Socket != "" {
return lc.Socket
}
return paths.DefaultTailscaledSocket()
}
func (lc *Client) dialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
func (lc *LocalClient) dialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
if lc.Dial != nil {
return lc.Dial
}
return lc.defaultDialer
}
func (lc *Client) defaultDialer(ctx context.Context, network, addr string) (net.Conn, error) {
func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string) (net.Conn, error) {
if addr != "local-tailscaled.sock:80" {
return nil, fmt.Errorf("unexpected URL address %q", addr)
}
@@ -133,13 +125,13 @@ func (lc *Client) defaultDialer(ctx context.Context, network, addr string) (net.
// authenticating to the local Tailscale daemon vary by platform.
//
// DoLocalRequest may mutate the request to add Authorization headers.
func (lc *Client) DoLocalRequest(req *http.Request) (*http.Response, error) {
func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error) {
req.Header.Set("Tailscale-Cap", strconv.Itoa(int(tailcfg.CurrentCapabilityVersion)))
lc.tsClientOnce.Do(func() {
lc.tsClient = &http.Client{
Transport: cmp.Or(lc.Transport, http.RoundTripper(
&http.Transport{DialContext: lc.dialer()}),
),
Transport: &http.Transport{
DialContext: lc.dialer(),
},
}
})
if !lc.OmitAuth {
@@ -150,7 +142,7 @@ func (lc *Client) DoLocalRequest(req *http.Request) (*http.Response, error) {
return lc.tsClient.Do(req)
}
func (lc *Client) doLocalRequestNiceError(req *http.Request) (*http.Response, error) {
func (lc *LocalClient) doLocalRequestNiceError(req *http.Request) (*http.Response, error) {
res, err := lc.DoLocalRequest(req)
if err == nil {
if server := res.Header.Get("Tailscale-Version"); server != "" && server != envknob.IPCVersion() && onVersionMismatch != nil {
@@ -239,17 +231,12 @@ func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
onVersionMismatch = f
}
func (lc *Client) send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) {
var headers http.Header
if reason := apitype.RequestReasonKey.Value(ctx); reason != "" {
reasonBase64 := base64.StdEncoding.EncodeToString([]byte(reason))
headers = http.Header{apitype.RequestReasonHeader: {reasonBase64}}
}
slurp, _, err := lc.sendWithHeaders(ctx, method, path, wantStatus, body, headers)
func (lc *LocalClient) send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) {
slurp, _, err := lc.sendWithHeaders(ctx, method, path, wantStatus, body, nil)
return slurp, err
}
func (lc *Client) sendWithHeaders(
func (lc *LocalClient) sendWithHeaders(
ctx context.Context,
method,
path string,
@@ -288,15 +275,15 @@ type httpStatusError struct {
HTTPStatus int
}
func (lc *Client) get200(ctx context.Context, path string) ([]byte, error) {
func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) {
return lc.send(ctx, "GET", path, 200, nil)
}
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
//
// Deprecated: use Client.WhoIs.
// Deprecated: use LocalClient.WhoIs.
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
return defaultClient.WhoIs(ctx, remoteAddr)
return defaultLocalClient.WhoIs(ctx, remoteAddr)
}
func decodeJSON[T any](b []byte) (ret T, err error) {
@@ -314,7 +301,7 @@ func decodeJSON[T any](b []byte) (ret T, err error) {
// For connections proxied by tailscaled, this looks up the owner of the given
// address as TCP first, falling back to UDP; if you want to only check a
// specific address family, use WhoIsProto.
func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
@@ -331,7 +318,7 @@ var ErrPeerNotFound = errors.New("peer not found")
// WhoIsNodeKey returns the owner of the given wireguard public key.
//
// If not found, the error is ErrPeerNotFound.
func (lc *Client) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apitype.WhoIsResponse, error) {
func (lc *LocalClient) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(key.String()))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
@@ -346,7 +333,7 @@ func (lc *Client) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apityp
// IP:port, for the given protocol (tcp or udp).
//
// If not found, the error is ErrPeerNotFound.
func (lc *Client) WhoIsProto(ctx context.Context, proto, remoteAddr string) (*apitype.WhoIsResponse, error) {
func (lc *LocalClient) WhoIsProto(ctx context.Context, proto, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?proto="+url.QueryEscape(proto)+"&addr="+url.QueryEscape(remoteAddr))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
@@ -358,19 +345,19 @@ func (lc *Client) WhoIsProto(ctx context.Context, proto, remoteAddr string) (*ap
}
// Goroutines returns a dump of the Tailscale daemon's current goroutines.
func (lc *Client) Goroutines(ctx context.Context) ([]byte, error) {
func (lc *LocalClient) Goroutines(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/goroutines")
}
// DaemonMetrics returns the Tailscale daemon's metrics in
// the Prometheus text exposition format.
func (lc *Client) DaemonMetrics(ctx context.Context) ([]byte, error) {
func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/metrics")
}
// UserMetrics returns the user metrics in
// the Prometheus text exposition format.
func (lc *Client) UserMetrics(ctx context.Context) ([]byte, error) {
func (lc *LocalClient) UserMetrics(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/usermetrics")
}
@@ -379,7 +366,7 @@ func (lc *Client) UserMetrics(ctx context.Context) ([]byte, error) {
// metric is created and initialized to delta.
//
// IncrementCounter does not support gauge metrics or negative delta values.
func (lc *Client) IncrementCounter(ctx context.Context, name string, delta int) error {
func (lc *LocalClient) IncrementCounter(ctx context.Context, name string, delta int) error {
type metricUpdate struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -398,7 +385,7 @@ func (lc *Client) IncrementCounter(ctx context.Context, name string, delta int)
// TailDaemonLogs returns a stream the Tailscale daemon's logs as they arrive.
// Close the context to stop the stream.
func (lc *Client) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
func (lc *LocalClient) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/logtap", nil)
if err != nil {
return nil, err
@@ -414,7 +401,7 @@ func (lc *Client) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
}
// Pprof returns a pprof profile of the Tailscale daemon.
func (lc *Client) Pprof(ctx context.Context, pprofType string, sec int) ([]byte, error) {
func (lc *LocalClient) Pprof(ctx context.Context, pprofType string, sec int) ([]byte, error) {
var secArg string
if sec < 0 || sec > 300 {
return nil, errors.New("duration out of range")
@@ -447,7 +434,7 @@ type BugReportOpts struct {
//
// The opts type specifies options to pass to the Tailscale daemon when
// generating this bug report.
func (lc *Client) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (string, error) {
func (lc *LocalClient) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (string, error) {
qparams := make(url.Values)
if opts.Note != "" {
qparams.Set("note", opts.Note)
@@ -492,13 +479,13 @@ func (lc *Client) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (st
//
// This is the same as calling BugReportWithOpts and only specifying the Note
// field.
func (lc *Client) BugReport(ctx context.Context, note string) (string, error) {
func (lc *LocalClient) BugReport(ctx context.Context, note string) (string, error) {
return lc.BugReportWithOpts(ctx, BugReportOpts{Note: note})
}
// DebugAction invokes a debug action, such as "rebind" or "restun".
// These are development tools and subject to change or removal over time.
func (lc *Client) DebugAction(ctx context.Context, action string) error {
func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
if err != nil {
return fmt.Errorf("error %w: %s", err, body)
@@ -506,20 +493,9 @@ func (lc *Client) DebugAction(ctx context.Context, action string) error {
return nil
}
// DebugActionBody invokes a debug action with a body parameter, such as
// "debug-force-prefer-derp".
// These are development tools and subject to change or removal over time.
func (lc *Client) DebugActionBody(ctx context.Context, action string, rbody io.Reader) error {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, rbody)
if err != nil {
return fmt.Errorf("error %w: %s", err, body)
}
return nil
}
// DebugResultJSON invokes a debug action and returns its result as something JSON-able.
// These are development tools and subject to change or removal over time.
func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, error) {
func (lc *LocalClient) DebugResultJSON(ctx context.Context, action string) (any, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
@@ -562,7 +538,7 @@ type DebugPortmapOpts struct {
// process.
//
// opts can be nil; if so, default values will be used.
func (lc *Client) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts) (io.ReadCloser, error) {
func (lc *LocalClient) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts) (io.ReadCloser, error) {
vals := make(url.Values)
if opts == nil {
opts = &DebugPortmapOpts{}
@@ -597,7 +573,7 @@ func (lc *Client) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts) (io.
// SetDevStoreKeyValue set a statestore key/value. It's only meant for development.
// The schema (including when keys are re-read) is not a stable interface.
func (lc *Client) SetDevStoreKeyValue(ctx context.Context, key, value string) error {
func (lc *LocalClient) SetDevStoreKeyValue(ctx context.Context, key, value string) error {
body, err := lc.send(ctx, "POST", "/localapi/v0/dev-set-state-store?"+(url.Values{
"key": {key},
"value": {value},
@@ -611,7 +587,7 @@ func (lc *Client) SetDevStoreKeyValue(ctx context.Context, key, value string) er
// SetComponentDebugLogging sets component's debug logging enabled for
// the provided duration. If the duration is in the past, the debug logging
// is disabled.
func (lc *Client) SetComponentDebugLogging(ctx context.Context, component string, d time.Duration) error {
func (lc *LocalClient) SetComponentDebugLogging(ctx context.Context, component string, d time.Duration) error {
body, err := lc.send(ctx, "POST",
fmt.Sprintf("/localapi/v0/component-debug-logging?component=%s&secs=%d",
url.QueryEscape(component), int64(d.Seconds())), 200, nil)
@@ -632,25 +608,25 @@ func (lc *Client) SetComponentDebugLogging(ctx context.Context, component string
// Status returns the Tailscale daemon's status.
func Status(ctx context.Context) (*ipnstate.Status, error) {
return defaultClient.Status(ctx)
return defaultLocalClient.Status(ctx)
}
// Status returns the Tailscale daemon's status.
func (lc *Client) Status(ctx context.Context) (*ipnstate.Status, error) {
func (lc *LocalClient) Status(ctx context.Context) (*ipnstate.Status, error) {
return lc.status(ctx, "")
}
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
return defaultClient.StatusWithoutPeers(ctx)
return defaultLocalClient.StatusWithoutPeers(ctx)
}
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
func (lc *Client) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
func (lc *LocalClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
return lc.status(ctx, "?peers=false")
}
func (lc *Client) status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
func (lc *LocalClient) status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
body, err := lc.get200(ctx, "/localapi/v0/status"+queryString)
if err != nil {
return nil, err
@@ -661,7 +637,7 @@ func (lc *Client) status(ctx context.Context, queryString string) (*ipnstate.Sta
// IDToken is a request to get an OIDC ID token for an audience.
// The token can be presented to any resource provider which offers OIDC
// Federation.
func (lc *Client) IDToken(ctx context.Context, aud string) (*tailcfg.TokenResponse, error) {
func (lc *LocalClient) IDToken(ctx context.Context, aud string) (*tailcfg.TokenResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/id-token?aud="+url.QueryEscape(aud))
if err != nil {
return nil, err
@@ -673,14 +649,14 @@ func (lc *Client) IDToken(ctx context.Context, aud string) (*tailcfg.TokenRespon
// received by the Tailscale daemon in its staging/cache directory but not yet
// transferred by the user's CLI or GUI client and written to a user's home
// directory somewhere.
func (lc *Client) WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
func (lc *LocalClient) WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
return lc.AwaitWaitingFiles(ctx, 0)
}
// AwaitWaitingFiles is like WaitingFiles but takes a duration to await for an answer.
// If the duration is 0, it will return immediately. The duration is respected at second
// granularity only. If no files are available, it returns (nil, nil).
func (lc *Client) AwaitWaitingFiles(ctx context.Context, d time.Duration) ([]apitype.WaitingFile, error) {
func (lc *LocalClient) AwaitWaitingFiles(ctx context.Context, d time.Duration) ([]apitype.WaitingFile, error) {
path := "/localapi/v0/files/?waitsec=" + fmt.Sprint(int(d.Seconds()))
body, err := lc.get200(ctx, path)
if err != nil {
@@ -689,12 +665,12 @@ func (lc *Client) AwaitWaitingFiles(ctx context.Context, d time.Duration) ([]api
return decodeJSON[[]apitype.WaitingFile](body)
}
func (lc *Client) DeleteWaitingFile(ctx context.Context, baseName string) error {
func (lc *LocalClient) DeleteWaitingFile(ctx context.Context, baseName string) error {
_, err := lc.send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil)
return err
}
func (lc *Client) GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) {
func (lc *LocalClient) GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/files/"+url.PathEscape(baseName), nil)
if err != nil {
return nil, 0, err
@@ -715,7 +691,7 @@ func (lc *Client) GetWaitingFile(ctx context.Context, baseName string) (rc io.Re
return res.Body, res.ContentLength, nil
}
func (lc *Client) FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
func (lc *LocalClient) FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
body, err := lc.get200(ctx, "/localapi/v0/file-targets")
if err != nil {
return nil, err
@@ -727,7 +703,7 @@ func (lc *Client) FileTargets(ctx context.Context) ([]apitype.FileTarget, error)
//
// A size of -1 means unknown.
// The name parameter is the original filename, not escaped.
func (lc *Client) PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error {
func (lc *LocalClient) PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error {
req, err := http.NewRequestWithContext(ctx, "PUT", "http://"+apitype.LocalAPIHost+"/localapi/v0/file-put/"+string(target)+"/"+url.PathEscape(name), r)
if err != nil {
return err
@@ -750,7 +726,7 @@ func (lc *Client) PushFile(ctx context.Context, target tailcfg.StableNodeID, siz
// CheckIPForwarding asks the local Tailscale daemon whether it looks like the
// machine is properly configured to forward IP packets as a subnet router
// or exit node.
func (lc *Client) CheckIPForwarding(ctx context.Context) error {
func (lc *LocalClient) CheckIPForwarding(ctx context.Context) error {
body, err := lc.get200(ctx, "/localapi/v0/check-ip-forwarding")
if err != nil {
return err
@@ -770,7 +746,7 @@ func (lc *Client) CheckIPForwarding(ctx context.Context) error {
// CheckUDPGROForwarding asks the local Tailscale daemon whether it looks like
// the machine is optimally configured to forward UDP packets as a subnet router
// or exit node.
func (lc *Client) CheckUDPGROForwarding(ctx context.Context) error {
func (lc *LocalClient) CheckUDPGROForwarding(ctx context.Context) error {
body, err := lc.get200(ctx, "/localapi/v0/check-udp-gro-forwarding")
if err != nil {
return err
@@ -791,7 +767,7 @@ func (lc *Client) CheckUDPGROForwarding(ctx context.Context) error {
// node. This can be done to improve performance of tailnet nodes acting as exit
// nodes or subnet routers.
// See https://tailscale.com/kb/1320/performance-best-practices#linux-optimizations-for-subnet-routers-and-exit-nodes
func (lc *Client) SetUDPGROForwarding(ctx context.Context) error {
func (lc *LocalClient) SetUDPGROForwarding(ctx context.Context) error {
body, err := lc.get200(ctx, "/localapi/v0/set-udp-gro-forwarding")
if err != nil {
return err
@@ -814,12 +790,12 @@ func (lc *Client) SetUDPGROForwarding(ctx context.Context) error {
// work. Currently (2022-04-18) this only checks for SSH server compatibility.
// Note that EditPrefs does the same validation as this, so call CheckPrefs before
// EditPrefs is not necessary.
func (lc *Client) CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
func (lc *LocalClient) CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, jsonBody(p))
return err
}
func (lc *Client) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
func (lc *LocalClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
body, err := lc.get200(ctx, "/localapi/v0/prefs")
if err != nil {
return nil, err
@@ -831,12 +807,7 @@ func (lc *Client) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
return &p, nil
}
// EditPrefs updates the [ipn.Prefs] of the current Tailscale profile, applying the changes in mp.
// It returns an error if the changes cannot be applied, such as due to the caller's access rights
// or a policy restriction. An optional reason or justification for the request can be
// provided as a context value using [apitype.RequestReasonKey]. If permitted by policy,
// access may be granted, and the reason will be logged for auditing purposes.
func (lc *Client) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
body, err := lc.send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, jsonBody(mp))
if err != nil {
return nil, err
@@ -845,7 +816,7 @@ func (lc *Client) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Pref
}
// GetEffectivePolicy returns the effective policy for the specified scope.
func (lc *Client) GetEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
func (lc *LocalClient) GetEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
scopeID, err := scope.MarshalText()
if err != nil {
return nil, err
@@ -859,7 +830,7 @@ func (lc *Client) GetEffectivePolicy(ctx context.Context, scope setting.PolicySc
// ReloadEffectivePolicy reloads the effective policy for the specified scope
// by reading and merging policy settings from all applicable policy sources.
func (lc *Client) ReloadEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
func (lc *LocalClient) ReloadEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
scopeID, err := scope.MarshalText()
if err != nil {
return nil, err
@@ -873,7 +844,7 @@ func (lc *Client) ReloadEffectivePolicy(ctx context.Context, scope setting.Polic
// GetDNSOSConfig returns the system DNS configuration for the current device.
// That is, it returns the DNS configuration that the system would use if Tailscale weren't being used.
func (lc *Client) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {
func (lc *LocalClient) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {
body, err := lc.get200(ctx, "/localapi/v0/dns-osconfig")
if err != nil {
return nil, err
@@ -888,7 +859,7 @@ func (lc *Client) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, err
// QueryDNS executes a DNS query for a name (`google.com.`) and query type (`CNAME`).
// It returns the raw DNS response bytes and the resolvers that were used to answer the query
// (often just one, but can be more if we raced multiple resolvers).
func (lc *Client) QueryDNS(ctx context.Context, name string, queryType string) (bytes []byte, resolvers []*dnstype.Resolver, err error) {
func (lc *LocalClient) QueryDNS(ctx context.Context, name string, queryType string) (bytes []byte, resolvers []*dnstype.Resolver, err error) {
body, err := lc.get200(ctx, fmt.Sprintf("/localapi/v0/dns-query?name=%s&type=%s", url.QueryEscape(name), queryType))
if err != nil {
return nil, nil, err
@@ -901,20 +872,20 @@ func (lc *Client) QueryDNS(ctx context.Context, name string, queryType string) (
}
// StartLoginInteractive starts an interactive login.
func (lc *Client) StartLoginInteractive(ctx context.Context) error {
func (lc *LocalClient) StartLoginInteractive(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil)
return err
}
// Start applies the configuration specified in opts, and starts the
// state machine.
func (lc *Client) Start(ctx context.Context, opts ipn.Options) error {
func (lc *LocalClient) Start(ctx context.Context, opts ipn.Options) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/start", http.StatusNoContent, jsonBody(opts))
return err
}
// Logout logs out the current node.
func (lc *Client) Logout(ctx context.Context) error {
func (lc *LocalClient) Logout(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil)
return err
}
@@ -933,7 +904,7 @@ func (lc *Client) Logout(ctx context.Context) error {
// This is a low-level interface; it's expected that most Tailscale
// users use a higher level interface to getting/using TLS
// certificates.
func (lc *Client) SetDNS(ctx context.Context, name, value string) error {
func (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error {
v := url.Values{}
v.Set("name", name)
v.Set("value", value)
@@ -947,7 +918,7 @@ func (lc *Client) SetDNS(ctx context.Context, name, value string) error {
// tailscaled), a FQDN, or an IP address.
//
// The ctx is only used for the duration of the call, not the lifetime of the net.Conn.
func (lc *Client) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
return lc.UserDial(ctx, "tcp", host, port)
}
@@ -958,7 +929,7 @@ func (lc *Client) DialTCP(ctx context.Context, host string, port uint16) (net.Co
//
// The ctx is only used for the duration of the call, not the lifetime of the
// net.Conn.
func (lc *Client) UserDial(ctx context.Context, network, host string, port uint16) (net.Conn, error) {
func (lc *LocalClient) UserDial(ctx context.Context, network, host string, port uint16) (net.Conn, error) {
connCh := make(chan net.Conn, 1)
trace := httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
@@ -1009,7 +980,7 @@ func (lc *Client) UserDial(ctx context.Context, network, host string, port uint1
// CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled.
// It is intended to be used with netcheck to see availability of DERPs.
func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
func (lc *LocalClient) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
var derpMap tailcfg.DERPMap
res, err := lc.send(ctx, "GET", "/localapi/v0/derpmap", 200, nil)
if err != nil {
@@ -1025,9 +996,9 @@ func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error)
//
// It returns a cached certificate from disk if it's still valid.
//
// Deprecated: use Client.CertPair.
// Deprecated: use LocalClient.CertPair.
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return defaultClient.CertPair(ctx, domain)
return defaultLocalClient.CertPair(ctx, domain)
}
// CertPair returns a cert and private key for the provided DNS domain.
@@ -1035,7 +1006,7 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e
// It returns a cached certificate from disk if it's still valid.
//
// API maturity: this is considered a stable API.
func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
func (lc *LocalClient) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return lc.CertPairWithValidity(ctx, domain, 0)
}
@@ -1048,7 +1019,7 @@ func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM
// valid, but for less than minValidity, it will be synchronously renewed.
//
// API maturity: this is considered a stable API.
func (lc *Client) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
func (lc *LocalClient) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
if err != nil {
return nil, nil, err
@@ -1074,9 +1045,9 @@ func (lc *Client) CertPairWithValidity(ctx context.Context, domain string, minVa
// It's the right signature to use as the value of
// tls.Config.GetCertificate.
//
// Deprecated: use Client.GetCertificate.
// Deprecated: use LocalClient.GetCertificate.
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return defaultClient.GetCertificate(hi)
return defaultLocalClient.GetCertificate(hi)
}
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
@@ -1087,7 +1058,7 @@ func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
// tls.Config.GetCertificate.
//
// API maturity: this is considered a stable API.
func (lc *Client) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
func (lc *LocalClient) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if hi == nil || hi.ServerName == "" {
return nil, errors.New("no SNI ServerName")
}
@@ -1113,13 +1084,13 @@ func (lc *Client) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, err
// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
//
// Deprecated: use Client.ExpandSNIName.
// Deprecated: use LocalClient.ExpandSNIName.
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
return defaultClient.ExpandSNIName(ctx, name)
return defaultLocalClient.ExpandSNIName(ctx, name)
}
// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
func (lc *Client) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
func (lc *LocalClient) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
st, err := lc.StatusWithoutPeers(ctx)
if err != nil {
return "", false
@@ -1147,7 +1118,7 @@ type PingOpts struct {
// Ping sends a ping of the provided type to the provided IP and waits
// for its response. The opts type specifies additional options.
func (lc *Client) PingWithOpts(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType, opts PingOpts) (*ipnstate.PingResult, error) {
func (lc *LocalClient) PingWithOpts(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType, opts PingOpts) (*ipnstate.PingResult, error) {
v := url.Values{}
v.Set("ip", ip.String())
v.Set("size", strconv.Itoa(opts.Size))
@@ -1161,12 +1132,12 @@ func (lc *Client) PingWithOpts(ctx context.Context, ip netip.Addr, pingtype tail
// Ping sends a ping of the provided type to the provided IP and waits
// for its response.
func (lc *Client) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
return lc.PingWithOpts(ctx, ip, pingtype, PingOpts{})
}
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
func (lc *LocalClient) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
if err != nil {
return nil, fmt.Errorf("error: %w", err)
@@ -1177,7 +1148,7 @@ func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockS
// NetworkLockInit initializes the tailnet key authority.
//
// TODO(tom): Plumb through disablement secrets.
func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.NetworkLockStatus, error) {
func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.NetworkLockStatus, error) {
var b bytes.Buffer
type initRequest struct {
Keys []tka.Key
@@ -1198,7 +1169,7 @@ func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disableme
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
// enable unattended bringup in the locked tailnet.
func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
func (lc *LocalClient) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
encodedPrivate, err := tkaKey.MarshalText()
if err != nil {
return "", err
@@ -1221,7 +1192,7 @@ func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey stri
}
// NetworkLockModify adds and/or removes key(s) to the tailnet key authority.
func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
func (lc *LocalClient) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
var b bytes.Buffer
type modifyRequest struct {
AddKeys []tka.Key
@@ -1240,7 +1211,7 @@ func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []t
// NetworkLockSign signs the specified node-key and transmits that signature to the control plane.
// rotationPublic, if specified, must be an ed25519 public key.
func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
func (lc *LocalClient) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
var b bytes.Buffer
type signRequest struct {
NodeKey key.NodePublic
@@ -1258,7 +1229,7 @@ func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, r
}
// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
func (lc *LocalClient) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/affected-sigs", 200, bytes.NewReader(keyID))
if err != nil {
return nil, fmt.Errorf("error: %w", err)
@@ -1267,7 +1238,7 @@ func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.Key
}
// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
func (lc *LocalClient) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
v := url.Values{}
v.Set("limit", fmt.Sprint(maxEntries))
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/log?"+v.Encode(), 200, nil)
@@ -1278,7 +1249,7 @@ func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstat
}
// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
func (lc *LocalClient) NetworkLockForceLocalDisable(ctx context.Context) error {
// This endpoint expects an empty JSON stanza as the payload.
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil {
@@ -1293,7 +1264,7 @@ func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
// in url and returns information extracted from it.
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
func (lc *LocalClient) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
vr := struct {
URL string
}{url}
@@ -1307,7 +1278,7 @@ func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url stri
}
// NetworkLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
func (lc *LocalClient) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
vr := struct {
Keys []tkatype.KeyID
ForkFrom string
@@ -1322,7 +1293,7 @@ func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tk
}
// NetworkLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
func (lc *LocalClient) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
r := bytes.NewReader(aum.Serialize())
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
if err != nil {
@@ -1333,7 +1304,7 @@ func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM)
}
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
func (lc *LocalClient) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
r := bytes.NewReader(aum.Serialize())
_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
if err != nil {
@@ -1344,7 +1315,7 @@ func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM)
// SetServeConfig sets or replaces the serving settings.
// If config is nil, settings are cleared and serving is disabled.
func (lc *Client) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
h := make(http.Header)
if config != nil {
h.Set("If-Match", config.ETag)
@@ -1359,7 +1330,7 @@ func (lc *Client) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) e
// DisconnectControl shuts down all connections to control, thus making control consider this node inactive. This can be
// run on HA subnet router or app connector replicas before shutting them down to ensure peers get told to switch over
// to another replica whilst there is still some grace period for the existing connections to terminate.
func (lc *Client) DisconnectControl(ctx context.Context) error {
func (lc *LocalClient) DisconnectControl(ctx context.Context) error {
_, _, err := lc.sendWithHeaders(ctx, "POST", "/localapi/v0/disconnect-control", 200, nil, nil)
if err != nil {
return fmt.Errorf("error disconnecting control: %w", err)
@@ -1368,7 +1339,7 @@ func (lc *Client) DisconnectControl(ctx context.Context) error {
}
// NetworkLockDisable shuts down network-lock across the tailnet.
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
func (lc *LocalClient) NetworkLockDisable(ctx context.Context, secret []byte) error {
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
return fmt.Errorf("error: %w", err)
}
@@ -1378,7 +1349,7 @@ func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
// GetServeConfig return the current serve config.
//
// If the serve config is empty, it returns (nil, nil).
func (lc *Client) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) {
func (lc *LocalClient) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) {
body, h, err := lc.sendWithHeaders(ctx, "GET", "/localapi/v0/serve-config", 200, nil, nil)
if err != nil {
return nil, fmt.Errorf("getting serve config: %w", err)
@@ -1453,7 +1424,7 @@ func (r jsonReader) Read(p []byte) (n int, err error) {
}
// ProfileStatus returns the current profile and the list of all profiles.
func (lc *Client) ProfileStatus(ctx context.Context) (current ipn.LoginProfile, all []ipn.LoginProfile, err error) {
func (lc *LocalClient) ProfileStatus(ctx context.Context) (current ipn.LoginProfile, all []ipn.LoginProfile, err error) {
body, err := lc.send(ctx, "GET", "/localapi/v0/profiles/current", 200, nil)
if err != nil {
return
@@ -1471,7 +1442,7 @@ func (lc *Client) ProfileStatus(ctx context.Context) (current ipn.LoginProfile,
}
// ReloadConfig reloads the config file, if possible.
func (lc *Client) ReloadConfig(ctx context.Context) (ok bool, err error) {
func (lc *LocalClient) ReloadConfig(ctx context.Context) (ok bool, err error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/reload-config", 200, nil)
if err != nil {
return
@@ -1489,13 +1460,13 @@ func (lc *Client) ReloadConfig(ctx context.Context) (ok bool, err error) {
// SwitchToEmptyProfile creates and switches to a new unnamed profile. The new
// profile is not assigned an ID until it is persisted after a successful login.
// In order to login to the new profile, the user must call LoginInteractive.
func (lc *Client) SwitchToEmptyProfile(ctx context.Context) error {
func (lc *LocalClient) SwitchToEmptyProfile(ctx context.Context) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/profiles/", http.StatusCreated, nil)
return err
}
// SwitchProfile switches to the given profile.
func (lc *Client) SwitchProfile(ctx context.Context, profile ipn.ProfileID) error {
func (lc *LocalClient) SwitchProfile(ctx context.Context, profile ipn.ProfileID) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/profiles/"+url.PathEscape(string(profile)), 204, nil)
return err
}
@@ -1503,7 +1474,7 @@ func (lc *Client) SwitchProfile(ctx context.Context, profile ipn.ProfileID) erro
// DeleteProfile removes the profile with the given ID.
// If the profile is the current profile, an empty profile
// will be selected as if SwitchToEmptyProfile was called.
func (lc *Client) DeleteProfile(ctx context.Context, profile ipn.ProfileID) error {
func (lc *LocalClient) DeleteProfile(ctx context.Context, profile ipn.ProfileID) error {
_, err := lc.send(ctx, "DELETE", "/localapi/v0/profiles"+url.PathEscape(string(profile)), http.StatusNoContent, nil)
return err
}
@@ -1520,7 +1491,7 @@ func (lc *Client) DeleteProfile(ctx context.Context, profile ipn.ProfileID) erro
// to block until the feature has been enabled.
//
// 2023-08-09: Valid feature values are "serve" and "funnel".
func (lc *Client) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) {
func (lc *LocalClient) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) {
v := url.Values{"feature": {feature}}
body, err := lc.send(ctx, "POST", "/localapi/v0/query-feature?"+v.Encode(), 200, nil)
if err != nil {
@@ -1529,7 +1500,7 @@ func (lc *Client) QueryFeature(ctx context.Context, feature string) (*tailcfg.Qu
return decodeJSON[*tailcfg.QueryFeatureResponse](body)
}
func (lc *Client) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*ipnstate.DebugDERPRegionReport, error) {
func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*ipnstate.DebugDERPRegionReport, error) {
v := url.Values{"region": {regionIDOrCode}}
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-derp-region?"+v.Encode(), 200, nil)
if err != nil {
@@ -1539,7 +1510,7 @@ func (lc *Client) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*
}
// DebugPacketFilterRules returns the packet filter rules for the current device.
func (lc *Client) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterRule, error) {
func (lc *LocalClient) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterRule, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-packet-filter-rules", 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
@@ -1550,7 +1521,7 @@ func (lc *Client) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterR
// DebugSetExpireIn marks the current node key to expire in d.
//
// This is meant primarily for debug and testing.
func (lc *Client) DebugSetExpireIn(ctx context.Context, d time.Duration) error {
func (lc *LocalClient) DebugSetExpireIn(ctx context.Context, d time.Duration) error {
v := url.Values{"expiry": {fmt.Sprint(time.Now().Add(d).Unix())}}
_, err := lc.send(ctx, "POST", "/localapi/v0/set-expiry-sooner?"+v.Encode(), 200, nil)
return err
@@ -1560,7 +1531,7 @@ func (lc *Client) DebugSetExpireIn(ctx context.Context, d time.Duration) error {
//
// The provided context does not determine the lifetime of the
// returned io.ReadCloser.
func (lc *Client) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error) {
func (lc *LocalClient) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-capture", nil)
if err != nil {
return nil, err
@@ -1586,7 +1557,7 @@ func (lc *Client) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error)
// resources.
//
// A default set of ipn.Notify messages are returned but the set can be modified by mask.
func (lc *Client) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*IPNBusWatcher, error) {
func (lc *LocalClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*IPNBusWatcher, error) {
req, err := http.NewRequestWithContext(ctx, "GET",
"http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+fmt.Sprint(mask),
nil)
@@ -1612,7 +1583,7 @@ func (lc *Client) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*IP
// CheckUpdate returns a tailcfg.ClientVersion indicating whether or not an update is available
// to be installed via the LocalAPI. In case the LocalAPI can't install updates, it returns a
// ClientVersion that says that we are up to date.
func (lc *Client) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, error) {
func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, error) {
body, err := lc.get200(ctx, "/localapi/v0/update/check")
if err != nil {
return nil, err
@@ -1628,7 +1599,7 @@ func (lc *Client) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, erro
// To turn it on, there must have been a previously used exit node.
// The most previously used one is reused.
// This is a convenience method for GUIs. To select an actual one, update the prefs.
func (lc *Client) SetUseExitNode(ctx context.Context, on bool) error {
func (lc *LocalClient) SetUseExitNode(ctx context.Context, on bool) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/set-use-exit-node-enabled?enabled="+strconv.FormatBool(on), http.StatusOK, nil)
return err
}
@@ -1636,7 +1607,7 @@ func (lc *Client) SetUseExitNode(ctx context.Context, on bool) error {
// DriveSetServerAddr instructs Taildrive to use the server at addr to access
// the filesystem. This is used on platforms like Windows and MacOS to let
// Taildrive know to use the file server running in the GUI app.
func (lc *Client) DriveSetServerAddr(ctx context.Context, addr string) error {
func (lc *LocalClient) DriveSetServerAddr(ctx context.Context, addr string) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/fileserver-address", http.StatusCreated, strings.NewReader(addr))
return err
}
@@ -1644,14 +1615,14 @@ func (lc *Client) DriveSetServerAddr(ctx context.Context, addr string) error {
// DriveShareSet adds or updates the given share in the list of shares that
// Taildrive will serve to remote nodes. If a share with the same name already
// exists, the existing share is replaced/updated.
func (lc *Client) DriveShareSet(ctx context.Context, share *drive.Share) error {
func (lc *LocalClient) DriveShareSet(ctx context.Context, share *drive.Share) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/shares", http.StatusCreated, jsonBody(share))
return err
}
// DriveShareRemove removes the share with the given name from the list of
// shares that Taildrive will serve to remote nodes.
func (lc *Client) DriveShareRemove(ctx context.Context, name string) error {
func (lc *LocalClient) DriveShareRemove(ctx context.Context, name string) error {
_, err := lc.send(
ctx,
"DELETE",
@@ -1662,7 +1633,7 @@ func (lc *Client) DriveShareRemove(ctx context.Context, name string) error {
}
// DriveShareRename renames the share from old to new name.
func (lc *Client) DriveShareRename(ctx context.Context, oldName, newName string) error {
func (lc *LocalClient) DriveShareRename(ctx context.Context, oldName, newName string) error {
_, err := lc.send(
ctx,
"POST",
@@ -1674,7 +1645,7 @@ func (lc *Client) DriveShareRename(ctx context.Context, oldName, newName string)
// DriveShareList returns the list of shares that drive is currently serving
// to remote nodes.
func (lc *Client) DriveShareList(ctx context.Context) ([]*drive.Share, error) {
func (lc *LocalClient) DriveShareList(ctx context.Context) ([]*drive.Share, error) {
result, err := lc.get200(ctx, "/localapi/v0/drive/shares")
if err != nil {
return nil, err
@@ -1685,7 +1656,7 @@ func (lc *Client) DriveShareList(ctx context.Context) ([]*drive.Share, error) {
}
// IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus.
// It's returned by Client.WatchIPNBus.
// It's returned by LocalClient.WatchIPNBus.
//
// It must be closed when done.
type IPNBusWatcher struct {
@@ -1709,7 +1680,7 @@ func (w *IPNBusWatcher) Close() error {
}
// Next returns the next ipn.Notify from the stream.
// If the context from Client.WatchIPNBus is done, that error is returned.
// If the context from LocalClient.WatchIPNBus is done, that error is returned.
func (w *IPNBusWatcher) Next() (ipn.Notify, error) {
var n ipn.Notify
if err := w.dec.Decode(&n); err != nil {
@@ -1722,7 +1693,7 @@ func (w *IPNBusWatcher) Next() (ipn.Notify, error) {
}
// SuggestExitNode requests an exit node suggestion and returns the exit node's details.
func (lc *Client) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
func (lc *LocalClient) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/suggest-exit-node")
if err != nil {
return apitype.ExitNodeSuggestionResponse{}, err

View File

@@ -1,106 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailscale
import (
"context"
"crypto/tls"
"tailscale.com/client/local"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn/ipnstate"
)
// ErrPeerNotFound is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
var ErrPeerNotFound = local.ErrPeerNotFound
// LocalClient is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
type LocalClient = local.Client
// IPNBusWatcher is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
type IPNBusWatcher = local.IPNBusWatcher
// BugReportOpts is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
type BugReportOpts = local.BugReportOpts
// DebugPortMapOpts is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
type DebugPortmapOpts = local.DebugPortmapOpts
// PingOpts is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
type PingOpts = local.PingOpts
// GetCertificate is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return local.GetCertificate(hi)
}
// SetVersionMismatchHandler is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
local.SetVersionMismatchHandler(f)
}
// IsAccessDeniedError is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
func IsAccessDeniedError(err error) bool {
return local.IsAccessDeniedError(err)
}
// IsPreconditionsFailedError is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
func IsPreconditionsFailedError(err error) bool {
return local.IsPreconditionsFailedError(err)
}
// WhoIs is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
return local.WhoIs(ctx, remoteAddr)
}
// Status is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
func Status(ctx context.Context) (*ipnstate.Status, error) {
return local.Status(ctx)
}
// StatusWithoutPeers is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
return local.StatusWithoutPeers(ctx)
}
// CertPair is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return local.CertPair(ctx, domain)
}
// ExpandSNIName is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
return local.ExpandSNIName(ctx, name)
}

View File

@@ -3,7 +3,7 @@
//go:build go1.19
package local
package tailscale
import (
"context"
@@ -41,7 +41,7 @@ func TestWhoIsPeerNotFound(t *testing.T) {
}))
defer ts.Close()
lc := &Client{
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())

View File

@@ -44,7 +44,7 @@ func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, e
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, HandleErrorResponse(b, resp)
return nil, handleErrorResponse(b, resp)
}
var sr Routes
@@ -84,7 +84,7 @@ func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netip
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, HandleErrorResponse(b, resp)
return nil, handleErrorResponse(b, resp)
}
var srr *Routes

View File

@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"tailscale.com/util/httpm"
)
@@ -21,7 +22,7 @@ func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (er
}
}()
path := c.BuildTailnetURL("tailnet")
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
if err != nil {
return err
@@ -34,7 +35,7 @@ func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (er
}
if resp.StatusCode != http.StatusOK {
return HandleErrorResponse(b, resp)
return handleErrorResponse(b, resp)
}
return nil

View File

@@ -3,12 +3,11 @@
//go:build go1.19
// Package tailscale contains a Go client for the Tailscale control plane API.
// Package tailscale contains Go clients for the Tailscale LocalAPI and
// Tailscale control plane API.
//
// This package is only intended for internal and transitional use.
//
// Deprecated: the official control plane client is available at
// tailscale.com/client/tailscale/v2.
// Warning: this package is in development and makes no API compatibility
// promises as of 2022-04-29. It is subject to change at any time.
package tailscale
import (
@@ -17,12 +16,13 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"path"
)
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
// for now. This package is being replaced by tailscale.com/client/tailscale/v2.
// for now. It was added 2022-04-29 when it was moved to this git repo
// and will be removed when the public API has settled.
//
// TODO(bradfitz): remove this after the we're happy with the public API.
var I_Acknowledge_This_API_Is_Unstable = false
// TODO: use url.PathEscape() for deviceID and tailnets when constructing requests.
@@ -36,8 +36,6 @@ const maxReadSize = 10 << 20
//
// Use NewClient to instantiate one. Exported fields should be set before
// the client is used and not changed thereafter.
//
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
type Client struct {
// tailnet is the globally unique identifier for a Tailscale network, such
// as "example.com" or "user@gmail.com".
@@ -65,46 +63,6 @@ func (c *Client) httpClient() *http.Client {
return http.DefaultClient
}
// BuildURL builds a url to http(s)://<apiserver>/api/v2/<slash-separated-pathElements>
// using the given pathElements. It url escapes each path element, so the
// caller doesn't need to worry about that. The last item of pathElements can
// be of type url.Values to add a query string to the URL.
//
// For example, BuildURL(devices, 5) with the default server URL would result in
// https://api.tailscale.com/api/v2/devices/5.
func (c *Client) BuildURL(pathElements ...any) string {
elem := make([]string, 1, len(pathElements)+1)
elem[0] = "/api/v2"
var query string
for i, pathElement := range pathElements {
if uv, ok := pathElement.(url.Values); ok && i == len(pathElements)-1 {
query = uv.Encode()
} else {
elem = append(elem, url.PathEscape(fmt.Sprint(pathElement)))
}
}
url := c.baseURL() + path.Join(elem...)
if query != "" {
url += "?" + query
}
return url
}
// BuildTailnetURL builds a url to http(s)://<apiserver>/api/v2/tailnet/<tailnet>/<slash-separated-pathElements>
// using the given pathElements. It url escapes each path element, so the
// caller doesn't need to worry about that. The last item of pathElements can
// be of type url.Values to add a query string to the URL.
//
// For example, BuildTailnetURL(policy, validate) with the default server URL and a tailnet of "example.com"
// would result in https://api.tailscale.com/api/v2/tailnet/example.com/policy/validate.
func (c *Client) BuildTailnetURL(pathElements ...any) string {
allElements := make([]any, 2, len(pathElements)+2)
allElements[0] = "tailnet"
allElements[1] = c.tailnet
allElements = append(allElements, pathElements...)
return c.BuildURL(allElements...)
}
func (c *Client) baseURL() string {
if c.BaseURL != "" {
return c.BaseURL
@@ -140,8 +98,6 @@ func (c *Client) setAuth(r *http.Request) {
// If httpClient is nil, then http.DefaultClient is used.
// "api.tailscale.com" is set as the BaseURL for the returned client
// and can be changed manually by the user.
//
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
func NewClient(tailnet string, auth AuthMethod) *Client {
return &Client{
tailnet: tailnet,
@@ -192,14 +148,12 @@ func (e ErrResponse) Error() string {
return fmt.Sprintf("Status: %d, Message: %q", e.Status, e.Message)
}
// HandleErrorResponse decodes the error message from the server and returns
// handleErrorResponse decodes the error message from the server and returns
// an ErrResponse from it.
//
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
func HandleErrorResponse(b []byte, resp *http.Response) error {
func handleErrorResponse(b []byte, resp *http.Response) error {
var errResp ErrResponse
if err := json.Unmarshal(b, &errResp); err != nil {
return fmt.Errorf("json.Unmarshal %q: %w", b, err)
return err
}
errResp.Status = resp.StatusCode
return errResp

View File

@@ -1,86 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailscale
import (
"net/url"
"testing"
)
func TestClientBuildURL(t *testing.T) {
c := Client{BaseURL: "http://127.0.0.1:1234"}
for _, tt := range []struct {
desc string
elements []any
want string
}{
{
desc: "single-element",
elements: []any{"devices"},
want: "http://127.0.0.1:1234/api/v2/devices",
},
{
desc: "multiple-elements",
elements: []any{"tailnet", "example.com"},
want: "http://127.0.0.1:1234/api/v2/tailnet/example.com",
},
{
desc: "escape-element",
elements: []any{"tailnet", "example dot com?foo=bar"},
want: `http://127.0.0.1:1234/api/v2/tailnet/example%20dot%20com%3Ffoo=bar`,
},
{
desc: "url.Values",
elements: []any{"tailnet", "example.com", "acl", url.Values{"details": {"1"}}},
want: `http://127.0.0.1:1234/api/v2/tailnet/example.com/acl?details=1`,
},
} {
t.Run(tt.desc, func(t *testing.T) {
got := c.BuildURL(tt.elements...)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestClientBuildTailnetURL(t *testing.T) {
c := Client{
BaseURL: "http://127.0.0.1:1234",
tailnet: "example.com",
}
for _, tt := range []struct {
desc string
elements []any
want string
}{
{
desc: "single-element",
elements: []any{"devices"},
want: "http://127.0.0.1:1234/api/v2/tailnet/example.com/devices",
},
{
desc: "multiple-elements",
elements: []any{"devices", 123},
want: "http://127.0.0.1:1234/api/v2/tailnet/example.com/devices/123",
},
{
desc: "escape-element",
elements: []any{"foo bar?baz=qux"},
want: `http://127.0.0.1:1234/api/v2/tailnet/example.com/foo%20bar%3Fbaz=qux`,
},
{
desc: "url.Values",
elements: []any{"acl", url.Values{"details": {"1"}}},
want: `http://127.0.0.1:1234/api/v2/tailnet/example.com/acl?details=1`,
},
} {
t.Run(tt.desc, func(t *testing.T) {
got := c.BuildTailnetURL(tt.elements...)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -1,11 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import React, { useState } from "react"
import { useAPI } from "src/api"
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Collapsible from "src/ui/collapsible"
import Input from "src/ui/input"
/**
* LoginView is rendered when the client is not authenticated
@@ -13,6 +15,8 @@ import Button from "src/ui/button"
*/
export default function LoginView({ data }: { data: NodeData }) {
const api = useAPI()
const [controlURL, setControlURL] = useState<string>("")
const [authKey, setAuthKey] = useState<string>("")
return (
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
@@ -84,6 +88,8 @@ export default function LoginView({ data }: { data: NodeData }) {
action: "up",
data: {
Reauthenticate: true,
ControlURL: controlURL,
AuthKey: authKey,
},
})
}
@@ -92,6 +98,34 @@ export default function LoginView({ data }: { data: NodeData }) {
>
Log In
</Button>
<Collapsible trigger="Advanced options">
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
<p className="text-sm text-gray-500">
Connect with a pre-authenticated key.{" "}
<a
href="https://tailscale.com/kb/1085/auth-keys/"
className="link"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
<Input
className="mt-2"
value={authKey}
onChange={(e) => setAuthKey(e.target.value)}
placeholder="tskey-auth-XXX"
/>
<h4 className="font-medium mt-3 mb-1">Server URL</h4>
<p className="text-sm text-gray-500">Base URL of control server.</p>
<Input
className="mt-2"
value={controlURL}
onChange={(e) => setControlURL(e.target.value)}
placeholder="https://login.tailscale.com/"
/>
</Collapsible>
</>
)}
</div>

View File

@@ -22,7 +22,7 @@ import (
"time"
"github.com/gorilla/csrf"
"tailscale.com/client/local"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/clientupdate"
"tailscale.com/envknob"
@@ -50,7 +50,7 @@ type Server struct {
mode ServerMode
logf logger.Logf
lc *local.Client
lc *tailscale.LocalClient
timeNow func() time.Time
// devMode indicates that the server run with frontend assets
@@ -89,8 +89,8 @@ type Server struct {
type ServerMode string
const (
// LoginServerMode serves a read-only login client for logging a
// node into a tailnet, and viewing a read-only interface of the
// LoginServerMode serves a readonly login client for logging a
// node into a tailnet, and viewing a readonly interface of the
// node's current Tailscale settings.
//
// In this mode, API calls are authenticated via platform auth.
@@ -110,7 +110,7 @@ const (
// This mode restricts the app to only being assessible over Tailscale,
// and API calls are authenticated via browser sessions associated with
// the source's Tailscale identity. If the source browser does not have
// a valid session, a read-only version of the app is displayed.
// a valid session, a readonly version of the app is displayed.
ManageServerMode ServerMode = "manage"
)
@@ -125,9 +125,9 @@ type ServerOpts struct {
// PathPrefix is the URL prefix added to requests by CGI or reverse proxy.
PathPrefix string
// LocalClient is the local.Client to use for this web server.
// LocalClient is the tailscale.LocalClient to use for this web server.
// If nil, a new one will be created.
LocalClient *local.Client
LocalClient *tailscale.LocalClient
// TimeNow optionally provides a time function.
// time.Now is used as default.
@@ -166,7 +166,7 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
return nil, fmt.Errorf("invalid Mode provided")
}
if opts.LocalClient == nil {
opts.LocalClient = &local.Client{}
opts.LocalClient = &tailscale.LocalClient{}
}
s = &Server{
mode: opts.Mode,
@@ -203,9 +203,25 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
}
s.assetsHandler, s.assetsCleanup = assetsHandler(s.devMode)
var metric string
s.apiHandler, metric = s.modeAPIHandler(s.mode)
s.apiHandler = s.withCSRF(s.apiHandler)
var metric string // clientmetric to report on startup
// Create handler for "/api" requests with CSRF protection.
// We don't require secure cookies, since the web client is regularly used
// on network appliances that are served on local non-https URLs.
// The client is secured by limiting the interface it listens on,
// or by authenticating requests before they reach the web client.
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
switch s.mode {
case LoginServerMode:
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
metric = "web_login_client_initialization"
case ReadOnlyServerMode:
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
metric = "web_readonly_client_initialization"
case ManageServerMode:
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
metric = "web_client_initialization"
}
// Don't block startup on reporting metric.
// Report in separate go routine with 5 second timeout.
@@ -218,39 +234,6 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
return s, nil
}
func (s *Server) withCSRF(h http.Handler) http.Handler {
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
// ref https://github.com/tailscale/tailscale/pull/14822
// signal to the CSRF middleware that the request is being served over
// plaintext HTTP to skip TLS-only header checks.
withSetPlaintext := func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = csrf.PlaintextHTTPRequest(r)
h.ServeHTTP(w, r)
})
}
// NB: the order of the withSetPlaintext and csrfProtect calls is important
// to ensure that we signal to the CSRF middleware that the request is being
// served over plaintext HTTP and not over TLS as it presumes by default.
return withSetPlaintext(csrfProtect(h))
}
func (s *Server) modeAPIHandler(mode ServerMode) (http.Handler, string) {
switch mode {
case LoginServerMode:
return http.HandlerFunc(s.serveLoginAPI), "web_login_client_initialization"
case ReadOnlyServerMode:
return http.HandlerFunc(s.serveLoginAPI), "web_readonly_client_initialization"
case ManageServerMode:
return http.HandlerFunc(s.serveAPI), "web_client_initialization"
default: // invalid mode
log.Fatalf("invalid mode: %v", mode)
}
return nil, ""
}
func (s *Server) Shutdown() {
s.logf("web.Server: shutting down")
if s.assetsCleanup != nil {
@@ -712,16 +695,16 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
switch {
case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
resp.Authorized = false // restricted to the read-only view
resp.Authorized = false // restricted to the readonly view
case sErr != nil && errors.Is(sErr, errNotOwner):
s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1)
resp.Authorized = false // restricted to the read-only view
resp.Authorized = false // restricted to the readonly view
case sErr != nil && errors.Is(sErr, errTaggedLocalSource):
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1)
resp.Authorized = false // restricted to the read-only view
resp.Authorized = false // restricted to the readonly view
case sErr != nil && errors.Is(sErr, errTaggedRemoteSource):
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1)
resp.Authorized = false // restricted to the read-only view
resp.Authorized = false // restricted to the readonly view
case sErr != nil && !errors.Is(sErr, errNoSession):
// Any other error.
http.Error(w, sErr.Error(), http.StatusInternalServerError)
@@ -821,8 +804,8 @@ type nodeData struct {
DeviceName string
TailnetName string // TLS cert name
DomainName string
IPv4 netip.Addr
IPv6 netip.Addr
IPv4 string
IPv6 string
OS string
IPNVersion string
@@ -881,14 +864,10 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
return
}
filterRules, _ := s.lc.DebugPacketFilterRules(r.Context())
ipv4, ipv6 := s.selfNodeAddresses(r, st)
data := &nodeData{
ID: st.Self.ID,
Status: st.BackendState,
DeviceName: strings.Split(st.Self.DNSName, ".")[0],
IPv4: ipv4,
IPv6: ipv6,
OS: st.Self.OS,
IPNVersion: strings.Split(st.Version, "-")[0],
Profile: st.User[st.Self.UserID],
@@ -908,6 +887,10 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
ACLAllowsAnyIncomingTraffic: s.aclsAllowAccess(filterRules),
}
ipv4, ipv6 := s.selfNodeAddresses(r, st)
data.IPv4 = ipv4.String()
data.IPv6 = ipv6.String()
if hostinfo.GetEnvType() == hostinfo.HomeAssistantAddOn && data.URLPrefix == "" {
// X-Ingress-Path is the path prefix in use for Home Assistant
// https://developers.home-assistant.io/docs/add-ons/presentation#ingress

View File

@@ -11,7 +11,6 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"net/url"
@@ -21,8 +20,7 @@ import (
"time"
"github.com/google/go-cmp/cmp"
"github.com/gorilla/csrf"
"tailscale.com/client/local"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
@@ -122,7 +120,7 @@ func TestServeAPI(t *testing.T) {
s := &Server{
mode: ManageServerMode,
lc: &local.Client{Dial: lal.Dial},
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: time.Now,
}
@@ -290,7 +288,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
s := &Server{
timeNow: time.Now,
lc: &local.Client{Dial: lal.Dial},
lc: &tailscale.LocalClient{Dial: lal.Dial},
}
// Add some browser sessions to cache state.
@@ -459,7 +457,7 @@ func TestAuthorizeRequest(t *testing.T) {
s := &Server{
mode: ManageServerMode,
lc: &local.Client{Dial: lal.Dial},
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: time.Now,
}
validCookie := "ts-cookie"
@@ -574,7 +572,7 @@ func TestServeAuth(t *testing.T) {
s := &Server{
mode: ManageServerMode,
lc: &local.Client{Dial: lal.Dial},
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: func() time.Time { return timeNow },
newAuthURL: mockNewAuthURL,
waitAuthURL: mockWaitAuthURL,
@@ -916,7 +914,7 @@ func TestServeAPIAuthMetricLogging(t *testing.T) {
s := &Server{
mode: ManageServerMode,
lc: &local.Client{Dial: lal.Dial},
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: func() time.Time { return timeNow },
newAuthURL: mockNewAuthURL,
waitAuthURL: mockWaitAuthURL,
@@ -1128,7 +1126,7 @@ func TestRequireTailscaleIP(t *testing.T) {
s := &Server{
mode: ManageServerMode,
lc: &local.Client{Dial: lal.Dial},
lc: &tailscale.LocalClient{Dial: lal.Dial},
timeNow: time.Now,
logf: t.Logf,
}
@@ -1479,83 +1477,3 @@ func mockWaitAuthURL(_ context.Context, id string, src tailcfg.NodeID) (*tailcfg
return nil, errors.New("unknown id")
}
}
func TestCSRFProtect(t *testing.T) {
s := &Server{}
mux := http.NewServeMux()
mux.HandleFunc("GET /test/csrf-token", func(w http.ResponseWriter, r *http.Request) {
token := csrf.Token(r)
_, err := io.WriteString(w, token)
if err != nil {
t.Fatal(err)
}
})
mux.HandleFunc("POST /test/csrf-protected", func(w http.ResponseWriter, r *http.Request) {
_, err := io.WriteString(w, "ok")
if err != nil {
t.Fatal(err)
}
})
h := s.withCSRF(mux)
ser := httptest.NewServer(h)
defer ser.Close()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("unable to construct cookie jar: %v", err)
}
client := ser.Client()
client.Jar = jar
// make GET request to populate cookie jar
resp, err := client.Get(ser.URL + "/test/csrf-token")
if err != nil {
t.Fatalf("unable to make request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: %v", resp.Status)
}
tokenBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("unable to read body: %v", err)
}
csrfToken := strings.TrimSpace(string(tokenBytes))
if csrfToken == "" {
t.Fatal("empty csrf token")
}
// make a POST request without the CSRF header; ensure it fails
resp, err = client.Post(ser.URL+"/test/csrf-protected", "text/plain", nil)
if err != nil {
t.Fatalf("unable to make request: %v", err)
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("unexpected status: %v", resp.Status)
}
// make a POST request with the CSRF header; ensure it succeeds
req, err := http.NewRequest("POST", ser.URL+"/test/csrf-protected", nil)
if err != nil {
t.Fatalf("error building request: %v", err)
}
req.Header.Set("X-CSRF-Token", csrfToken)
resp, err = client.Do(req)
if err != nil {
t.Fatalf("unable to make request: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: %v", resp.Status)
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("unable to read body: %v", err)
}
if string(out) != "ok" {
t.Fatalf("unexpected body: %q", out)
}
}

View File

@@ -27,7 +27,6 @@ import (
"strconv"
"strings"
"tailscale.com/hostinfo"
"tailscale.com/types/logger"
"tailscale.com/util/cmpver"
"tailscale.com/version"
@@ -170,12 +169,6 @@ func NewUpdater(args Arguments) (*Updater, error) {
type updateFunction func() error
func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
hi := hostinfo.New()
// We don't know how to update custom tsnet binaries, it's up to the user.
if hi.Package == "tsnet" {
return nil, false
}
switch runtime.GOOS {
case "windows":
return up.updateWindows, true

View File

@@ -18,12 +18,12 @@ var (
)
func usage() {
fmt.Fprint(os.Stderr, `
fmt.Fprintf(os.Stderr, `
usage: addlicense -file FILE <subcommand args...>
`[1:])
flag.PrintDefaults()
fmt.Fprint(os.Stderr, `
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,

View File

@@ -1,131 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// checkmetrics validates that all metrics in the tailscale client-metrics
// are documented in a given path or URL.
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"time"
"tailscale.com/ipn/store/mem"
"tailscale.com/tsnet"
"tailscale.com/tstest/integration/testcontrol"
"tailscale.com/util/httpm"
)
var (
kbPath = flag.String("kb-path", "", "filepath to the client-metrics knowledge base")
kbUrl = flag.String("kb-url", "", "URL to the client-metrics knowledge base page")
)
func main() {
flag.Parse()
if *kbPath == "" && *kbUrl == "" {
log.Fatalf("either -kb-path or -kb-url must be set")
}
var control testcontrol.Server
ts := httptest.NewServer(&control)
defer ts.Close()
td, err := os.MkdirTemp("", "testcontrol")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(td)
// tsnet is used not used as a Tailscale client, but as a way to
// boot up Tailscale, have all the metrics registered, and then
// verifiy that all the metrics are documented.
tsn := &tsnet.Server{
Dir: td,
Store: new(mem.Store),
UserLogf: log.Printf,
Ephemeral: true,
ControlURL: ts.URL,
}
if err := tsn.Start(); err != nil {
log.Fatal(err)
}
defer tsn.Close()
log.Printf("checking that all metrics are documented, looking for: %s", tsn.Sys().UserMetricsRegistry().MetricNames())
if *kbPath != "" {
kb, err := readKB(*kbPath)
if err != nil {
log.Fatalf("reading kb: %v", err)
}
missing := undocumentedMetrics(kb, tsn.Sys().UserMetricsRegistry().MetricNames())
if len(missing) > 0 {
log.Fatalf("found undocumented metrics in %q: %v", *kbPath, missing)
}
}
if *kbUrl != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
kb, err := getKB(ctx, *kbUrl)
if err != nil {
log.Fatalf("getting kb: %v", err)
}
missing := undocumentedMetrics(kb, tsn.Sys().UserMetricsRegistry().MetricNames())
if len(missing) > 0 {
log.Fatalf("found undocumented metrics in %q: %v", *kbUrl, missing)
}
}
}
func readKB(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("reading file: %w", err)
}
return string(b), nil
}
func getKB(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, httpm.GET, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("getting kb page: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading body: %w", err)
}
return string(b), nil
}
func undocumentedMetrics(b string, metrics []string) []string {
var missing []string
for _, metric := range metrics {
if !strings.Contains(b, metric) {
missing = append(missing, metric)
}
}
return missing
}

View File

@@ -6,12 +6,10 @@
package main
import (
"fmt"
"log"
"net"
"net/http"
"sync"
"tailscale.com/kube/kubetypes"
)
// healthz is a simple health check server, if enabled it returns 200 OK if
@@ -20,38 +18,34 @@ import (
type healthz struct {
sync.Mutex
hasAddrs bool
podIPv4 string
}
func (h *healthz) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.Lock()
defer h.Unlock()
if h.hasAddrs {
w.Header().Add(kubetypes.PodIPv4Header, h.podIPv4)
if _, err := w.Write([]byte("ok")); err != nil {
http.Error(w, fmt.Sprintf("error writing status: %v", err), http.StatusInternalServerError)
}
w.Write([]byte("ok"))
} else {
http.Error(w, "node currently has no tailscale IPs", http.StatusServiceUnavailable)
http.Error(w, "node currently has no tailscale IPs", http.StatusInternalServerError)
}
}
func (h *healthz) update(healthy bool) {
h.Lock()
defer h.Unlock()
if h.hasAddrs != healthy {
log.Println("Setting healthy", healthy)
}
h.hasAddrs = healthy
}
// healthHandlers registers a simple health handler at /healthz.
// A containerized tailscale instance is considered healthy if
// 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 healthHandlers(mux *http.ServeMux, podIPv4 string) *healthz {
h := &healthz{podIPv4: podIPv4}
mux.Handle("GET /healthz", h)
return h
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)
}
}()
}

View File

@@ -8,64 +8,31 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/netip"
"os"
"strings"
"time"
"tailscale.com/ipn"
"tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/logtail/backoff"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
)
// kubeClient is a wrapper around Tailscale's internal kube client that knows how to talk to the kube API server. We use
// this rather than any of the upstream Kubernetes client libaries to avoid extra imports.
type kubeClient struct {
kubeclient.Client
stateSecret string
canPatch bool // whether the client has permissions to patch Kubernetes Secrets
}
func newKubeClient(root string, stateSecret string) (*kubeClient, error) {
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 {
return nil, fmt.Errorf("Error creating kube client: %w", 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")))
}
return &kubeClient{Client: kc, stateSecret: stateSecret}, nil
}
// storeDeviceID writes deviceID to 'device_id' data field of the client's state Secret.
func (kc *kubeClient) storeDeviceID(ctx context.Context, deviceID tailcfg.StableNodeID) error {
// 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{
kubetypes.KeyDeviceID: []byte(deviceID),
"device_id": []byte(deviceID),
},
}
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container")
}
// storeDeviceEndpoints writes device's tailnet IPs and MagicDNS name to fields 'device_ips', 'device_fqdn' of client's
// state Secret.
func (kc *kubeClient) storeDeviceEndpoints(ctx context.Context, fqdn string, addresses []netip.Prefix) error {
// 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())
@@ -77,28 +44,16 @@ func (kc *kubeClient) storeDeviceEndpoints(ctx context.Context, fqdn string, add
s := &kubeapi.Secret{
Data: map[string][]byte{
kubetypes.KeyDeviceFQDN: []byte(fqdn),
kubetypes.KeyDeviceIPs: deviceIPs,
"device_fqdn": []byte(fqdn),
"device_ips": deviceIPs,
},
}
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
}
// storeHTTPSEndpoint writes an HTTPS endpoint exposed by this device via 'tailscale serve' to the client's state
// Secret. In practice this will be the same value that gets written to 'device_fqdn', but this should only be called
// when the serve config has been successfully set up.
func (kc *kubeClient) storeHTTPSEndpoint(ctx context.Context, ep string) error {
s := &kubeapi.Secret{
Data: map[string][]byte{
kubetypes.KeyHTTPSEndpoint: []byte(ep),
},
}
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
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 (kc *kubeClient) deleteAuthKey(ctx context.Context) error {
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{
{
@@ -106,7 +61,7 @@ func (kc *kubeClient) deleteAuthKey(ctx context.Context) error {
Path: "/data/authkey",
},
}
if err := kc.JSONPatchResource(ctx, kc.stateSecret, kubeclient.TypeSecrets, m); err != nil {
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.
@@ -117,78 +72,22 @@ func (kc *kubeClient) deleteAuthKey(ctx context.Context) error {
return nil
}
// storeCapVerUID stores the current capability version of tailscale and, if provided, UID of the Pod in the tailscale
// state Secret.
// These two fields are used by the Kubernetes Operator to observe the current capability version of tailscaled running in this container.
func (kc *kubeClient) storeCapVerUID(ctx context.Context, podUID string) error {
capVerS := fmt.Sprintf("%d", tailcfg.CurrentCapabilityVersion)
d := map[string][]byte{
kubetypes.KeyCapVer: []byte(capVerS),
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)
}
if podUID != "" {
d[kubetypes.KeyPodUID] = []byte(podUID)
var err error
kc, err = kubeclient.New("tailscale-container")
if err != nil {
log.Fatalf("Error creating kube client: %v", err)
}
s := &kubeapi.Secret{
Data: d,
}
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
}
// waitForConsistentState waits for tailscaled to finish writing state if it
// looks like it's started. It is designed to reduce the likelihood that
// tailscaled gets shut down in the window between authenticating to control
// and finishing writing state. However, it's not bullet proof because we can't
// atomically authenticate and write state.
func (kc *kubeClient) waitForConsistentState(ctx context.Context) error {
var logged bool
bo := backoff.NewBackoff("", logger.Discard, 2*time.Second)
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
secret, err := kc.GetSecret(ctx, kc.stateSecret)
if ctx.Err() != nil || kubeclient.IsNotFoundErr(err) {
return nil
}
if err != nil {
return fmt.Errorf("getting Secret %q: %v", kc.stateSecret, err)
}
if hasConsistentState(secret.Data) {
return nil
}
if !logged {
log.Printf("Waiting for tailscaled to finish writing state to Secret %q", kc.stateSecret)
logged = true
}
bo.BackOff(ctx, errors.New("")) // Fake error to trigger actual sleep.
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")))
}
}
// hasConsistentState returns true is there is either no state or the full set
// of expected keys are present.
func hasConsistentState(d map[string][]byte) bool {
var (
_, hasCurrent = d[string(ipn.CurrentProfileStateKey)]
_, hasKnown = d[string(ipn.KnownProfilesStateKey)]
_, hasMachine = d[string(ipn.MachineKeyStateKey)]
hasProfile bool
)
for k := range d {
if strings.HasPrefix(k, "profile-") {
if hasProfile {
return false // We only expect one profile.
}
hasProfile = true
}
}
// Approximate check, we don't want to reimplement all of profileManager.
return (hasCurrent && hasKnown && hasMachine && hasProfile) ||
(!hasCurrent && !hasKnown && !hasMachine && !hasProfile)
}

View File

@@ -9,10 +9,8 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"tailscale.com/ipn"
"tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient"
)
@@ -23,7 +21,7 @@ func TestSetupKube(t *testing.T) {
cfg *settings
wantErr bool
wantCfg *settings
kc *kubeClient
kc kubeclient.Client
}{
{
name: "TS_AUTHKEY set, state Secret exists",
@@ -31,14 +29,14 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
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",
@@ -50,14 +48,14 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
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",
@@ -69,14 +67,14 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
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",
@@ -89,14 +87,14 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
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",
@@ -113,11 +111,11 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
kc: &kubeclient.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, errors.New("broken")
},
}},
},
wantErr: true,
},
{
@@ -129,14 +127,14 @@ func TestSetupKube(t *testing.T) {
wantCfg: &settings{
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
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
@@ -147,28 +145,28 @@ func TestSetupKube(t *testing.T) {
wantCfg: &settings{
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
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{stateSecret: "foo", Client: &kubeclient.FakeClient{
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",
},
@@ -179,14 +177,14 @@ func TestSetupKube(t *testing.T) {
cfg: &settings{
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
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",
@@ -196,9 +194,9 @@ func TestSetupKube(t *testing.T) {
}
for _, tt := range tests {
kc := tt.kc
kc = tt.kc
t.Run(tt.name, func(t *testing.T) {
if err := tt.cfg.setupKube(context.Background(), kc); (err != nil) != tt.wantErr {
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 != "" {
@@ -207,34 +205,3 @@ func TestSetupKube(t *testing.T) {
})
}
}
func TestWaitForConsistentState(t *testing.T) {
data := map[string][]byte{
// Missing _current-profile.
string(ipn.KnownProfilesStateKey): []byte(""),
string(ipn.MachineKeyStateKey): []byte(""),
"profile-foo": []byte(""),
}
kc := &kubeClient{
Client: &kubeclient.FakeClient{
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return &kubeapi.Secret{
Data: data,
}, nil
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := kc.waitForConsistentState(ctx); err != context.DeadlineExceeded {
t.Fatalf("expected DeadlineExceeded, got %v", err)
}
ctx, cancel = context.WithTimeout(context.Background(), time.Second)
defer cancel()
data[string(ipn.CurrentProfileStateKey)] = []byte("")
if err := kc.waitForConsistentState(ctx); err != nil {
t.Fatalf("expected nil, got %v", err)
}
}

View File

@@ -52,17 +52,11 @@
// ${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: deprecated, use TS_ENABLE_HEALTH_CHECK instead and optionally
// set TS_LOCAL_ADDR_PORT. Will be removed in 1.82.0.
// - TS_LOCAL_ADDR_PORT: the address and port to serve local metrics and health
// check endpoints if enabled via TS_ENABLE_METRICS and/or TS_ENABLE_HEALTH_CHECK.
// Defaults to [::]:9002, serving on all available interfaces.
// - TS_ENABLE_METRICS: if true, a metrics endpoint will be served at /metrics on
// the address specified by TS_LOCAL_ADDR_PORT. See https://tailscale.com/kb/1482/client-metrics
// for more information on the metrics exposed.
// - TS_ENABLE_HEALTH_CHECK: if true, a health check endpoint will be served at /healthz on
// the address specified by TS_LOCAL_ADDR_PORT. The health endpoint will return 200
// OK if this node has at least one tailnet IP address, otherwise returns 503.
// - TS_HEALTHCHECK_ADDR_PORT: if specified, an HTTP health endpoint will be
// served at /healthz at the provided address, which should be in form [<address>]:<port>.
// If not set, no health check will be run. If set to :<port>, 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
@@ -105,7 +99,6 @@ import (
"log"
"math"
"net"
"net/http"
"net/netip"
"os"
"os/signal"
@@ -121,7 +114,6 @@ import (
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
kubeutils "tailscale.com/k8s-operator"
"tailscale.com/kube/kubetypes"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/ptr"
@@ -137,122 +129,55 @@ func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) {
}
func main() {
if err := run(); err != nil && !errors.Is(err, context.Canceled) {
log.Fatal(err)
}
}
func run() error {
log.SetPrefix("boot: ")
tailscale.I_Acknowledge_This_API_Is_Unstable = true
cfg, err := configFromEnv()
if err != nil {
return fmt.Errorf("invalid configuration: %w", err)
log.Fatalf("invalid configuration: %v", err)
}
if !cfg.UserspaceMode {
if err := ensureTunFile(cfg.Root); err != nil {
return fmt.Errorf("unable to create tuntap device file: %w", err)
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 {
return fmt.Errorf("you can either set the sysctls as a privileged initContainer, or run the tailscale container with privileged=true.")
log.Fatalf("You can either set the sysctls as a privileged initContainer, or run the tailscale container with privileged=true.")
} else {
return fmt.Errorf("you can fix this by running the container with privileged=true, or the equivalent in your container runtime that permits access to sysctls.")
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.")
}
}
}
}
// Root context for the whole containerboot process, used to make sure
// shutdown signals are promptly and cleanly handled.
ctx, cancel := contextWithExitSignalWatch()
defer cancel()
// bootCtx is used for all setup stuff until we're in steady
// 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(ctx, 60*time.Second)
bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var kc *kubeClient
if cfg.InKubernetes {
kc, err = newKubeClient(cfg.Root, cfg.KubeSecret)
if err != nil {
return fmt.Errorf("error initializing kube client: %w", err)
}
if err := cfg.setupKube(bootCtx, kc); err != nil {
return fmt.Errorf("error setting up for running on Kubernetes: %w", err)
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 {
return fmt.Errorf("failed to bring up tailscale: %w", err)
log.Fatalf("failed to bring up tailscale: %v", err)
}
killTailscaled := func() {
if hasKubeStateStore(cfg) {
// Check we're not shutting tailscaled down while it's still writing
// state. If we authenticate and fail to write all the state, we'll
// never recover automatically.
//
// The default termination grace period for a Pod is 30s. We wait 25s at
// most so that we still reserve some of that budget for tailscaled
// to receive and react to a SIGTERM before the SIGKILL that k8s
// will send at the end of the grace period.
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
log.Printf("Checking for consistent state")
err := kc.waitForConsistentState(ctx)
if err != nil {
log.Printf("Error waiting for consistent state on shutdown: %v", err)
}
}
log.Printf("Sending SIGTERM to tailscaled")
if err := daemonProcess.Signal(unix.SIGTERM); err != nil {
log.Fatalf("error shutting tailscaled down: %v", err)
}
}
defer killTailscaled()
var healthCheck *healthz
ep := &egressProxy{}
if cfg.HealthCheckAddrPort != "" {
mux := http.NewServeMux()
log.Printf("Running healthcheck endpoint at %s/healthz", cfg.HealthCheckAddrPort)
healthCheck = healthHandlers(mux, cfg.PodIPv4)
close := runHTTPServer(mux, cfg.HealthCheckAddrPort)
defer close()
}
if cfg.localMetricsEnabled() || cfg.localHealthEnabled() || cfg.egressSvcsTerminateEPEnabled() {
mux := http.NewServeMux()
if cfg.localMetricsEnabled() {
log.Printf("Running metrics endpoint at %s/metrics", cfg.LocalAddrPort)
metricsHandlers(mux, client, cfg.DebugAddrPort)
}
if cfg.localHealthEnabled() {
log.Printf("Running healthcheck endpoint at %s/healthz", cfg.LocalAddrPort)
healthCheck = healthHandlers(mux, cfg.PodIPv4)
}
if cfg.EgressProxiesCfgPath != "" {
log.Printf("Running preshutdown hook at %s%s", cfg.LocalAddrPort, kubetypes.EgessServicesPreshutdownEP)
ep.registerHandlers(mux)
}
close := runHTTPServer(mux, cfg.LocalAddrPort)
defer close()
}
if cfg.EnableForwardingOptimizations {
if err := client.SetUDPGROForwarding(bootCtx); err != nil {
log.Printf("[unexpected] error enabling UDP GRO forwarding: %v", err)
@@ -261,7 +186,7 @@ func run() error {
w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState)
if err != nil {
return fmt.Errorf("failed to watch tailscaled for updates: %w", err)
log.Fatalf("failed to watch tailscaled for updates: %v", err)
}
// Now that we've started tailscaled, we can symlink the socket to the
@@ -297,18 +222,18 @@ func run() error {
didLogin = true
w.Close()
if err := tailscaleUp(bootCtx, cfg); err != nil {
return fmt.Errorf("failed to auth tailscale: %w", err)
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: %w", err)
return fmt.Errorf("rewatching tailscaled for updates after auth: %v", err)
}
return nil
}
if isTwoStepConfigAlwaysAuth(cfg) {
if err := authTailscale(); err != nil {
return fmt.Errorf("failed to auth tailscale: %w", err)
log.Fatalf("failed to auth tailscale: %v", err)
}
}
@@ -316,7 +241,7 @@ authLoop:
for {
n, err := w.Next()
if err != nil {
return fmt.Errorf("failed to read from tailscaled: %w", err)
log.Fatalf("failed to read from tailscaled: %v", err)
}
if n.State != nil {
@@ -325,10 +250,10 @@ authLoop:
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.
return fmt.Errorf("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.")
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 {
return fmt.Errorf("failed to auth tailscale: %w", err)
log.Fatalf("failed to auth tailscale: %v", err)
}
case ipn.NeedsMachineAuth:
log.Printf("machine authorization required, please visit the admin panel")
@@ -348,25 +273,22 @@ authLoop:
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 {
return fmt.Errorf("failed to auth tailscale: %w", err)
log.Fatalf("failed to auth tailscale: %v", err)
}
}
// Remove any serve config and advertised HTTPS endpoint that may have been set by a previous run of
// containerboot, but only if we're providing a new one.
if cfg.ServeConfigPath != "" {
log.Printf("serve proxy: unsetting previous config")
// 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 {
return fmt.Errorf("failed to unset serve config: %w", err)
}
if hasKubeStateStore(cfg) {
if err := kc.storeHTTPSEndpoint(ctx, ""); err != nil {
return fmt.Errorf("failed to update HTTPS endpoint in tailscale state: %w", err)
}
log.Fatalf("failed to unset serve config: %v", err)
}
}
@@ -375,26 +297,14 @@ authLoop:
// 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 := kc.deleteAuthKey(ctx); err != nil {
return fmt.Errorf("deleting authkey from kube secret: %w", err)
}
}
if hasKubeStateStore(cfg) {
if err := kc.storeCapVerUID(ctx, cfg.PodUID); err != nil {
return fmt.Errorf("storing capability version and UID: %w", err)
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 {
return fmt.Errorf("rewatching tailscaled for updates after auth: %w", err)
}
// If tailscaled config was read from a mounted file, watch the file for updates and reload.
cfgWatchErrChan := make(chan error)
if cfg.TailscaledConfigFilePath != "" {
go watchTailscaledConfigChanges(ctx, cfg.TailscaledConfigFilePath, client, cfgWatchErrChan)
log.Fatalf("rewatching tailscaled for updates after auth: %v", err)
}
var (
@@ -411,14 +321,17 @@ authLoop:
certDomain = new(atomic.Pointer[string])
certDomainChanged = make(chan bool, 1)
triggerWatchServeConfigChanges sync.Once
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 {
return fmt.Errorf("error creating new netfilter runner: %w", err)
log.Fatalf("error creating new netfilter runner: %v", err)
}
}
@@ -489,9 +402,7 @@ runLoop:
killTailscaled()
break runLoop
case err := <-errChan:
return fmt.Errorf("failed to read from tailscaled: %w", err)
case err := <-cfgWatchErrChan:
return fmt.Errorf("failed to watch tailscaled config: %w", err)
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.
@@ -499,7 +410,7 @@ runLoop:
// 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.
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", *n.State)
log.Fatalf("tailscaled left running state (now in state %q), exiting", *n.State)
}
if n.NetMap != nil {
addrs = n.NetMap.SelfNode.Addresses().AsSlice()
@@ -516,8 +427,8 @@ runLoop:
// fails.
deviceID := n.NetMap.SelfNode.StableID()
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceID, &deviceID) {
if err := kc.storeDeviceID(ctx, n.NetMap.SelfNode.StableID()); err != nil {
return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err)
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 != "" {
@@ -554,12 +465,12 @@ runLoop:
rulesInstalled = true
log.Printf("Installing forwarding rules for destination %v", ea.String())
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
return fmt.Errorf("installing egress proxy rules for destination %s: %v", ea.String(), err)
log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err)
}
}
}
if !rulesInstalled {
return fmt.Errorf("no forwarding rules for egress addresses %v, host supports IPv6: %v", egressAddrs, nfr.HasIPV6NAT())
log.Fatalf("no forwarding rules for egress addresses %v, host supports IPv6: %v", egressAddrs, nfr.HasIPV6NAT())
}
}
currentEgressIPs = newCurentEgressIPs
@@ -567,7 +478,7 @@ runLoop:
if cfg.ProxyTargetIP != "" && len(addrs) != 0 && ipsHaveChanged {
log.Printf("Installing proxy rules")
if err := installIngressForwardingRule(ctx, cfg.ProxyTargetIP, addrs, nfr); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
log.Fatalf("installing ingress proxy rules: %v", err)
}
}
if cfg.ProxyTargetDNSName != "" && len(addrs) != 0 && ipsHaveChanged {
@@ -583,17 +494,14 @@ runLoop:
if backendsHaveChanged {
log.Printf("installing ingress proxy rules for backends %v", newBackendAddrs)
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
return fmt.Errorf("error installing ingress proxy rules: %w", err)
log.Fatalf("error installing ingress proxy rules: %v", err)
}
}
resetTimer(false)
backendAddrs = newBackendAddrs
}
if cfg.ServeConfigPath != "" {
cd := certDomainFromNetmap(n.NetMap)
if cd == "" {
cd = kubetypes.ValueNoHTTPS
}
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 {
@@ -605,7 +513,7 @@ runLoop:
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 {
return fmt.Errorf("installing egress proxy rules: %w", err)
log.Fatalf("installing egress proxy rules: %v", err)
}
}
// If this is a L7 cluster ingress proxy (set up
@@ -617,7 +525,7 @@ runLoop:
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 {
return fmt.Errorf("installing rules to forward traffic to node's tailnet IP: %w", err)
log.Fatalf("installing rules to forward traffic to node's tailnet IP: %v", err)
}
}
currentIPs = newCurrentIPs
@@ -635,21 +543,17 @@ runLoop:
// 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(&currentDeviceEndpoints, &deviceEndpoints) {
if err := kc.storeDeviceEndpoints(ctx, n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
return fmt.Errorf("storing device IPs and FQDN in Kubernetes Secret: %w", err)
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 healthCheck != nil {
healthCheck.update(len(addrs) != 0)
if cfg.HealthCheckAddrPort != "" {
h.Lock()
h.hasAddrs = len(addrs) != 0
h.Unlock()
healthzRunner()
}
if cfg.ServeConfigPath != "" {
triggerWatchServeConfigChanges.Do(func() {
go watchServeConfigChanges(ctx, cfg.ServeConfigPath, certDomainChanged, certDomain, client, kc)
})
}
if egressSvcsNotify != nil {
egressSvcsNotify <- n
}
@@ -671,21 +575,20 @@ runLoop:
// 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.EgressProxiesCfgPath != "" {
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath)
if cfg.EgressSvcsCfgPath != "" {
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressSvcsCfgPath)
egressSvcsNotify = make(chan ipn.Notify)
opts := egressProxyRunOpts{
cfgPath: cfg.EgressProxiesCfgPath,
ep := egressProxy{
cfgPath: cfg.EgressSvcsCfgPath,
nfr: nfr,
kc: kc,
tsClient: client,
stateSecret: cfg.KubeSecret,
netmapChan: egressSvcsNotify,
podIPv4: cfg.PodIPv4,
tailnetAddrs: addrs,
}
go func() {
if err := ep.run(ctx, n, opts); err != nil {
if err := ep.run(ctx, n); err != nil {
egressSvcsErrorChan <- err
}
}()
@@ -727,18 +630,16 @@ runLoop:
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 {
return fmt.Errorf("installing ingress proxy rules for DNS target %s: %v", cfg.ProxyTargetDNSName, err)
log.Fatalf("installing ingress proxy rules for DNS target %s: %v", cfg.ProxyTargetDNSName, err)
}
}
backendAddrs = newBackendAddrs
resetTimer(false)
case e := <-egressSvcsErrorChan:
return fmt.Errorf("egress proxy failed: %v", e)
log.Fatalf("egress proxy failed: %v", e)
}
}
wg.Wait()
return nil
}
// ensureTunFile checks that /dev/net/tun exists, creating it if
@@ -767,13 +668,13 @@ func resolveDNS(ctx context.Context, name string) ([]net.IP, error) {
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: %w", err)
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: %w", err)
return nil, fmt.Errorf("error looking up IPv6 addresses: %v", err)
}
}
if len(ip4s) == 0 && len(ip6s) == 0 {
@@ -786,7 +687,7 @@ func resolveDNS(ctx context.Context, name string) ([]net.IP, error) {
// 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 struct{})
closeChan := make(chan string)
ctx, cancel := context.WithCancel(context.Background())
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
@@ -798,11 +699,8 @@ func contextWithExitSignalWatch() (context.Context, func()) {
return
}
}()
closeOnce := sync.Once{}
f := func() {
closeOnce.Do(func() {
close(closeChan)
})
closeChan <- "goodbye"
}
return ctx, f
}
@@ -845,26 +743,3 @@ func tailscaledConfigFilePath() string {
log.Printf("Using tailscaled config file %q to match current capability version %d", filePath, tailcfg.CurrentCapabilityVersion)
return filePath
}
func runHTTPServer(mux *http.ServeMux, addr string) (close func() error) {
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("failed to listen on addr %q: %v", addr, err)
}
srv := &http.Server{Handler: mux}
go func() {
if err := srv.Serve(ln); err != nil {
if err != http.ErrServerClosed {
log.Fatalf("failed running server: %v", err)
} else {
log.Printf("HTTP server at %s closed", addr)
}
}
}()
return func() error {
err := srv.Shutdown(context.Background())
return errors.Join(err, ln.Close())
}
}

View File

@@ -25,16 +25,12 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"golang.org/x/sys/unix"
"tailscale.com/ipn"
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/netmap"
@@ -51,13 +47,16 @@ func TestContainerBoot(t *testing.T) {
defer lapi.Close()
kube := kubeServer{FSRoot: d}
kube.Start(t)
if err := kube.Start(); err != nil {
t.Fatal(err)
}
defer kube.Close()
tailscaledConf := &ipn.ConfigVAlpha{AuthKey: ptr.To("foo"), Version: "alpha0"}
serveConf := ipn.ServeConfig{TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}}
egressCfg := egressSvcConfig("foo", "foo.tailnetxyz.ts.net")
egressStatus := egressSvcStatus("foo", "foo.tailnetxyz.ts.net")
tailscaledConfBytes, err := json.Marshal(tailscaledConf)
if err != nil {
t.Fatalf("error unmarshaling tailscaled config: %v", err)
}
dirs := []string{
"var/lib",
@@ -81,10 +80,7 @@ func TestContainerBoot(t *testing.T) {
"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": mustJSON(t, tailscaledConf),
"etc/tailscaled/serve-config.json": mustJSON(t, serveConf),
filepath.Join("etc/tailscaled/", egressservices.KeyEgressServices): mustJSON(t, egressCfg),
filepath.Join("etc/tailscaled/", egressservices.KeyHEPPings): []byte("4"),
"etc/tailscaled/cap-95.hujson": tailscaledConfBytes,
}
resetFiles := func() {
for path, content := range files {
@@ -105,29 +101,6 @@ func TestContainerBoot(t *testing.T) {
argFile := filepath.Join(d, "args")
runningSockPath := filepath.Join(d, "tmp/tailscaled.sock")
var localAddrPort, healthAddrPort int
for _, p := range []*int{&localAddrPort, &healthAddrPort} {
ln, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatalf("Failed to open listener: %v", err)
}
if err := ln.Close(); err != nil {
t.Fatalf("Failed to close listener: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
*p = port
}
metricsURL := func(port int) string {
return fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
}
healthURL := func(port int) string {
return fmt.Sprintf("http://127.0.0.1:%d/healthz", port)
}
egressSvcTerminateURL := func(port int) string {
return fmt.Sprintf("http://127.0.0.1:%d%s", port, kubetypes.EgessServicesPreshutdownEP)
}
capver := fmt.Sprintf("%d", tailcfg.CurrentCapabilityVersion)
type phase struct {
// If non-nil, send this IPN bus notification (and remember it as the
@@ -137,31 +110,15 @@ func TestContainerBoot(t *testing.T) {
// 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
// Update the kube secret with these keys/values at the beginning of the
// phase (simulates our fake tailscaled doing it).
UpdateKubeSecret map[string]string
// WantFiles files that should exist in the container and their
// contents.
WantFiles map[string]string
// WantLog is a log message we expect from containerboot.
WantLog string
// If set for a phase, the test will expect containerboot to exit with
// this error code, and the test will finish on that phase without
// waiting for the successful startup log message.
WantExitCode *int
// The signal to send to containerboot at the start of the phase.
Signal *syscall.Signal
EndpointStatuses map[string]int
// 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),
@@ -190,11 +147,6 @@ func TestContainerBoot(t *testing.T) {
"/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",
},
// No metrics or health by default.
EndpointStatuses: map[string]int{
metricsURL(9002): -1,
healthURL(9002): -1,
},
},
{
Notify: runningNotify,
@@ -447,8 +399,7 @@ func TestContainerBoot(t *testing.T) {
},
},
},
WantLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
WantExitCode: ptr.To(1),
WantFatalLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
},
},
},
@@ -502,11 +453,10 @@ func TestContainerBoot(t *testing.T) {
{
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"]`,
"tailscale_capver": capver,
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
},
},
},
@@ -596,10 +546,9 @@ func TestContainerBoot(t *testing.T) {
"/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"]`,
"tailscale_capver": capver,
"device_fqdn": "test-node.test.ts.net",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
},
},
},
@@ -626,11 +575,10 @@ func TestContainerBoot(t *testing.T) {
{
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"]`,
"tailscale_capver": capver,
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
},
},
{
@@ -645,11 +593,10 @@ func TestContainerBoot(t *testing.T) {
},
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "new-name.test.ts.net",
"device_id": "newID",
"device_ips": `["100.64.0.1"]`,
"tailscale_capver": capver,
"authkey": "tskey-key",
"device_fqdn": "new-name.test.ts.net",
"device_id": "newID",
"device_ips": `["100.64.0.1"]`,
},
},
},
@@ -753,264 +700,6 @@ func TestContainerBoot(t *testing.T) {
},
},
},
{
Name: "metrics_enabled",
Env: map[string]string{
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
"TS_ENABLE_METRICS": "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=false",
},
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): 200,
healthURL(localAddrPort): -1,
},
}, {
Notify: runningNotify,
},
},
},
{
Name: "health_enabled",
Env: map[string]string{
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
"TS_ENABLE_HEALTH_CHECK": "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=false",
},
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): -1,
healthURL(localAddrPort): 503, // Doesn't start passing until the next phase.
},
}, {
Notify: runningNotify,
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): -1,
healthURL(localAddrPort): 200,
},
},
},
},
{
Name: "metrics_and_health_on_same_port",
Env: map[string]string{
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
"TS_ENABLE_METRICS": "true",
"TS_ENABLE_HEALTH_CHECK": "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=false",
},
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): 200,
healthURL(localAddrPort): 503, // Doesn't start passing until the next phase.
},
}, {
Notify: runningNotify,
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): 200,
healthURL(localAddrPort): 200,
},
},
},
},
{
Name: "local_metrics_and_deprecated_health",
Env: map[string]string{
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
"TS_ENABLE_METRICS": "true",
"TS_HEALTHCHECK_ADDR_PORT": fmt.Sprintf("[::]:%d", healthAddrPort),
},
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",
},
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): 200,
healthURL(healthAddrPort): 503, // Doesn't start passing until the next phase.
},
}, {
Notify: runningNotify,
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): 200,
healthURL(healthAddrPort): 200,
},
},
},
},
{
Name: "serve_config_no_kube",
Env: map[string]string{
"TS_SERVE_CONFIG": filepath.Join(d, "etc/tailscaled/serve-config.json"),
"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,
},
},
},
{
Name: "serve_config_kube",
Env: map[string]string{
"KUBERNETES_SERVICE_HOST": kube.Host,
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
"TS_SERVE_CONFIG": filepath.Join(d, "etc/tailscaled/serve-config.json"),
},
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"]`,
"https_endpoint": "no-https",
"tailscale_capver": capver,
},
},
},
},
{
Name: "egress_svcs_config_kube",
Env: map[string]string{
"KUBERNETES_SERVICE_HOST": kube.Host,
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
"TS_EGRESS_PROXIES_CONFIG_PATH": filepath.Join(d, "etc/tailscaled"),
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
},
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",
},
EndpointStatuses: map[string]int{
egressSvcTerminateURL(localAddrPort): 200,
},
},
{
Notify: runningNotify,
WantKubeSecret: map[string]string{
"egress-services": mustBase64(t, egressStatus),
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
"tailscale_capver": capver,
},
EndpointStatuses: map[string]int{
egressSvcTerminateURL(localAddrPort): 200,
},
},
},
},
{
Name: "egress_svcs_config_no_kube",
Env: map[string]string{
"TS_EGRESS_PROXIES_CONFIG_PATH": filepath.Join(d, "etc/tailscaled"),
"TS_AUTHKEY": "tskey-key",
},
Phases: []phase{
{
WantLog: "TS_EGRESS_PROXIES_CONFIG_PATH is only supported for Tailscale running on Kubernetes",
WantExitCode: ptr.To(1),
},
},
},
{
Name: "kube_shutdown_during_state_write",
Env: map[string]string{
"KUBERNETES_SERVICE_HOST": kube.Host,
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
"TS_ENABLE_HEALTH_CHECK": "true",
},
KubeSecret: map[string]string{
"authkey": "tskey-key",
},
Phases: []phase{
{
// Normal startup.
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",
},
},
{
// SIGTERM before state is finished writing, should wait for
// consistent state before propagating SIGTERM to tailscaled.
Signal: ptr.To(unix.SIGTERM),
UpdateKubeSecret: map[string]string{
"_machinekey": "foo",
"_profiles": "foo",
"profile-baff": "foo",
// Missing "_current-profile" key.
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"_machinekey": "foo",
"_profiles": "foo",
"profile-baff": "foo",
},
WantLog: "Waiting for tailscaled to finish writing state to Secret \"tailscale\"",
},
{
// tailscaled has finished writing state, should propagate SIGTERM.
UpdateKubeSecret: map[string]string{
"_current-profile": "foo",
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"_machinekey": "foo",
"_profiles": "foo",
"profile-baff": "foo",
"_current-profile": "foo",
},
WantLog: "HTTP server at [::]:9002 closed",
WantExitCode: ptr.To(0),
},
},
},
}
for _, test := range tests {
@@ -1055,36 +744,26 @@ func TestContainerBoot(t *testing.T) {
var wantCmds []string
for i, p := range test.Phases {
for k, v := range p.UpdateKubeSecret {
kube.SetSecret(k, v)
}
lapi.Notify(p.Notify)
if p.Signal != nil {
cmd.Process.Signal(*p.Signal)
}
if p.WantLog != "" {
if p.WantFatalLog != "" {
err := tstest.WaitFor(2*time.Second, func() error {
waitLogLine(t, time.Second, cbOut, p.WantLog)
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)
}
}
if p.WantExitCode != nil {
state, err := cmd.Process.Wait()
if err != nil {
t.Fatal(err)
}
if state.ExitCode() != *p.WantExitCode {
t.Fatalf("phase %d: want exit code %d, got %d", i, *p.WantExitCode, state.ExitCode())
}
// 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 {
@@ -1117,32 +796,10 @@ func TestContainerBoot(t *testing.T) {
return nil
})
if err != nil {
t.Fatalf("phase %d: %v", i, err)
}
for url, want := range p.EndpointStatuses {
err := tstest.WaitFor(2*time.Second, func() error {
resp, err := http.Get(url)
if err != nil && want != -1 {
return fmt.Errorf("GET %s: %v", url, err)
}
if want > 0 && resp.StatusCode != want {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GET %s, want %d, got %d\n%s", url, want, resp.StatusCode, string(body))
}
return nil
})
if err != nil {
t.Fatalf("phase %d: %v", i, err)
}
t.Fatal(err)
}
}
waitLogLine(t, 2*time.Second, cbOut, "Startup complete, waiting for shutdown signal")
if cmd.ProcessState != nil {
t.Fatalf("containerboot should be running but exited with exit code %d", cmd.ProcessState.ExitCode())
}
})
}
}
@@ -1298,12 +955,6 @@ func (l *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
panic(fmt.Sprintf("unsupported method %q", r.Method))
}
case "/localapi/v0/usermetrics":
if r.Method != "GET" {
panic(fmt.Sprintf("unsupported method %q", r.Method))
}
w.Write([]byte("fake metrics"))
return
default:
panic(fmt.Sprintf("unsupported path %q", r.URL.Path))
}
@@ -1374,18 +1025,18 @@ func (k *kubeServer) Reset() {
k.secret = map[string]string{}
}
func (k *kubeServer) Start(t *testing.T) {
func (k *kubeServer) Start() error {
root := filepath.Join(k.FSRoot, "var/run/secrets/kubernetes.io/serviceaccount")
if err := os.MkdirAll(root, 0700); err != nil {
t.Fatal(err)
return err
}
if err := os.WriteFile(filepath.Join(root, "namespace"), []byte("default"), 0600); err != nil {
t.Fatal(err)
return err
}
if err := os.WriteFile(filepath.Join(root, "token"), []byte("bearer_token"), 0600); err != nil {
t.Fatal(err)
return err
}
k.srv = httptest.NewTLSServer(k)
@@ -1394,11 +1045,13 @@ func (k *kubeServer) Start(t *testing.T) {
var cert bytes.Buffer
if err := pem.Encode(&cert, &pem.Block{Type: "CERTIFICATE", Bytes: k.srv.Certificate().Raw}); err != nil {
t.Fatal(err)
return err
}
if err := os.WriteFile(filepath.Join(root, "ca.crt"), cert.Bytes(), 0600); err != nil {
t.Fatal(err)
return err
}
return nil
}
func (k *kubeServer) Close() {
@@ -1447,7 +1100,6 @@ func (k *kubeServer) serveSecret(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("reading request body: %v", err), http.StatusInternalServerError)
return
}
defer r.Body.Close()
switch r.Method {
case "GET":
@@ -1480,32 +1132,13 @@ func (k *kubeServer) serveSecret(w http.ResponseWriter, r *http.Request) {
panic(fmt.Sprintf("json decode failed: %v. Body:\n\n%s", err, string(bs)))
}
for _, op := range req {
switch op.Op {
case "remove":
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 "replace":
path, ok := strings.CutPrefix(op.Path, "/data/")
if !ok {
panic(fmt.Sprintf("unsupported json-patch path %q", op.Path))
}
req := make([]kubeclient.JSONPatch, 0)
if err := json.Unmarshal(bs, &req); err != nil {
panic(fmt.Sprintf("json decode failed: %v. Body:\n\n%s", err, string(bs)))
}
for _, patch := range req {
val, ok := patch.Value.(string)
if !ok {
panic(fmt.Sprintf("unsupported json patch value %v: cannot be converted to string", patch.Value))
}
k.secret[path] = val
}
default:
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 {
@@ -1521,44 +1154,6 @@ func (k *kubeServer) serveSecret(w http.ResponseWriter, r *http.Request) {
panic(fmt.Sprintf("unknown content type %q", r.Header.Get("Content-Type")))
}
default:
panic(fmt.Sprintf("unhandled HTTP request %s %s", r.Method, r.URL))
}
}
func mustBase64(t *testing.T, v any) string {
b := mustJSON(t, v)
s := base64.StdEncoding.WithPadding('=').EncodeToString(b)
return s
}
func mustJSON(t *testing.T, v any) []byte {
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("error converting %v to json: %v", v, err)
}
return b
}
// egress services status given one named tailnet target specified by FQDN. As written by the proxy to its state Secret.
func egressSvcStatus(name, fqdn string) egressservices.Status {
return egressservices.Status{
Services: map[string]*egressservices.ServiceStatus{
name: {
TailnetTarget: egressservices.TailnetTarget{
FQDN: fqdn,
},
},
},
}
}
// egress config given one named tailnet target specified by FQDN.
func egressSvcConfig(name, fqdn string) egressservices.Configs {
return egressservices.Configs{
name: egressservices.Config{
TailnetTarget: egressservices.TailnetTarget{
FQDN: fqdn,
},
},
panic(fmt.Sprintf("unhandled HTTP method %q", r.Method))
}
}

View File

@@ -1,79 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"fmt"
"io"
"net/http"
"tailscale.com/client/local"
"tailscale.com/client/tailscale/apitype"
)
// metrics is a simple metrics HTTP server, if enabled it forwards requests to
// the tailscaled's LocalAPI usermetrics endpoint at /localapi/v0/usermetrics.
type metrics struct {
debugEndpoint string
lc *local.Client
}
func proxy(w http.ResponseWriter, r *http.Request, url string, do func(*http.Request) (*http.Response, error)) {
req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to construct request: %s", err), http.StatusInternalServerError)
return
}
req.Header = r.Header.Clone()
resp, err := do(req)
if err != nil {
http.Error(w, fmt.Sprintf("failed to proxy request: %s", err), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
for key, val := range resp.Header {
for _, v := range val {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (m *metrics) handleMetrics(w http.ResponseWriter, r *http.Request) {
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi/v0/usermetrics"
proxy(w, r, localAPIURL, m.lc.DoLocalRequest)
}
func (m *metrics) handleDebug(w http.ResponseWriter, r *http.Request) {
if m.debugEndpoint == "" {
http.Error(w, "debug endpoint not configured", http.StatusNotFound)
return
}
debugURL := "http://" + m.debugEndpoint + r.URL.Path
proxy(w, r, debugURL, http.DefaultClient.Do)
}
// metricsHandlers registers a simple HTTP metrics handler at /metrics, forwarding
// requests to tailscaled's /localapi/v0/usermetrics API.
//
// In 1.78.x and 1.80.x, it also proxies debug paths to tailscaled's debug
// endpoint if configured to ease migration for a breaking change serving user
// metrics instead of debug metrics on the "metrics" port.
func metricsHandlers(mux *http.ServeMux, lc *local.Client, debugAddrPort string) {
m := &metrics{
lc: lc,
debugEndpoint: debugAddrPort,
}
mux.HandleFunc("GET /metrics", m.handleMetrics)
mux.HandleFunc("/debug/", m.handleDebug) // TODO(tomhjp): Remove for 1.82.0 release.
}

View File

@@ -17,10 +17,8 @@ import (
"time"
"github.com/fsnotify/fsnotify"
"tailscale.com/client/local"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/kube/kubetypes"
"tailscale.com/types/netmap"
)
// watchServeConfigChanges watches path for changes, and when it sees one, reads
@@ -28,23 +26,21 @@ import (
// 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 *local.Client, kc *kubeClient) {
func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan bool, certDomainAtomic *atomic.Pointer[string], lc *tailscale.LocalClient) {
if certDomainAtomic == nil {
panic("certDomainAtomic must not be nil")
panic("cd must not be nil")
}
var tickChan <-chan time.Time
var eventChan <-chan fsnotify.Event
if w, err := fsnotify.NewWatcher(); err != nil {
// Creating a new fsnotify watcher would fail for example if inotify was not able to create a new file descriptor.
// See https://github.com/tailscale/tailscale/issues/15081
log.Printf("serve proxy: failed to create fsnotify watcher, timer-only mode: %v", err)
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("serve proxy: failed to add fsnotify watch: %v", err)
log.Fatalf("failed to add fsnotify watch: %v", err)
}
eventChan = w.Events
}
@@ -63,72 +59,24 @@ func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan
// 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("serve proxy: failed to read serve config: %v", err)
}
if sc == nil {
log.Printf("serve proxy: no serve config at %q, skipping", path)
continue
log.Fatalf("failed to read serve config: %v", err)
}
if prevServeConfig != nil && reflect.DeepEqual(sc, prevServeConfig) {
continue
}
if err := updateServeConfig(ctx, sc, certDomain, lc); err != nil {
log.Fatalf("serve proxy: error updating serve config: %v", err)
}
if kc != nil && kc.canPatch {
if err := kc.storeHTTPSEndpoint(ctx, certDomain); err != nil {
log.Fatalf("serve proxy: error storing HTTPS endpoint: %v", err)
}
log.Printf("Applying serve config")
if err := lc.SetServeConfig(ctx, sc); err != nil {
log.Fatalf("failed to set serve config: %v", err)
}
prevServeConfig = sc
}
}
func certDomainFromNetmap(nm *netmap.NetworkMap) string {
if len(nm.DNS.CertDomains) == 0 {
return ""
}
return nm.DNS.CertDomains[0]
}
// localClient is a subset of [local.Client] that can be mocked for testing.
type localClient interface {
SetServeConfig(context.Context, *ipn.ServeConfig) error
}
func updateServeConfig(ctx context.Context, sc *ipn.ServeConfig, certDomain string, lc localClient) error {
if !isValidHTTPSConfig(certDomain, sc) {
return nil
}
log.Printf("serve proxy: applying serve config")
return lc.SetServeConfig(ctx, sc)
}
func isValidHTTPSConfig(certDomain string, sc *ipn.ServeConfig) bool {
if certDomain == kubetypes.ValueNoHTTPS && hasHTTPSEndpoint(sc) {
log.Printf(
`serve proxy: this node is configured as a proxy that exposes an HTTPS endpoint to tailnet,
(perhaps a Kubernetes operator Ingress proxy) but it is not able to issue TLS certs, so this will likely not work.
To make it work, ensure that HTTPS is enabled for your tailnet, see https://tailscale.com/kb/1153/enabling-https for more details.`)
return false
}
return true
}
func hasHTTPSEndpoint(cfg *ipn.ServeConfig) bool {
if cfg == nil {
return false
}
for _, tcpCfg := range cfg.TCP {
if tcpCfg.HTTPS {
return true
}
}
return false
}
// readServeConfig reads the ipn.ServeConfig from path, replacing
// ${TS_CERT_DOMAIN} with certDomain.
func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
@@ -137,17 +85,8 @@ func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
}
j, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
// Serve config can be provided by users as well as the Kubernetes Operator (for its proxies). User-provided
// config could be empty for reasons.
if len(j) == 0 {
log.Printf("serve proxy: serve config file is empty, skipping")
return nil, nil
}
j = bytes.ReplaceAll(j, []byte("${TS_CERT_DOMAIN}"), []byte(certDomain))
var sc ipn.ServeConfig
if err := json.Unmarshal(j, &sc); err != nil {

View File

@@ -1,267 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/kube/kubetypes"
)
func TestUpdateServeConfig(t *testing.T) {
tests := []struct {
name string
sc *ipn.ServeConfig
certDomain string
wantCall bool
}{
{
name: "no_https_no_cert_domain",
sc: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTP: true},
},
},
certDomain: kubetypes.ValueNoHTTPS, // tailnet has HTTPS disabled
wantCall: true, // should set serve config as it doesn't have HTTPS endpoints
},
{
name: "https_with_cert_domain",
sc: &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://10.0.1.100:8080"},
},
},
},
},
certDomain: "test-node.tailnet.ts.net",
wantCall: true,
},
{
name: "https_without_cert_domain",
sc: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
},
},
certDomain: kubetypes.ValueNoHTTPS,
wantCall: false, // incorrect configuration- should not set serve config
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeLC := &fakeLocalClient{}
err := updateServeConfig(context.Background(), tt.sc, tt.certDomain, fakeLC)
if err != nil {
t.Errorf("updateServeConfig() error = %v", err)
}
if fakeLC.setServeCalled != tt.wantCall {
t.Errorf("SetServeConfig() called = %v, want %v", fakeLC.setServeCalled, tt.wantCall)
}
})
}
}
func TestReadServeConfig(t *testing.T) {
tests := []struct {
name string
gotSC string
certDomain string
wantSC *ipn.ServeConfig
wantErr bool
}{
{
name: "empty_file",
},
{
name: "valid_config_with_cert_domain_placeholder",
gotSC: `{
"TCP": {
"443": {
"HTTPS": true
}
},
"Web": {
"${TS_CERT_DOMAIN}:443": {
"Handlers": {
"/api": {
"Proxy": "https://10.2.3.4/api"
}}}}}`,
certDomain: "example.com",
wantSC: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
HTTPS: true,
},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("example.com:443"): {
Handlers: map[string]*ipn.HTTPHandler{
"/api": {
Proxy: "https://10.2.3.4/api",
},
},
},
},
},
},
{
name: "valid_config_for_http_proxy",
gotSC: `{
"TCP": {
"80": {
"HTTP": true
}
}}`,
wantSC: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
80: {
HTTP: true,
},
},
},
},
{
name: "config_without_cert_domain",
gotSC: `{
"TCP": {
"443": {
"HTTPS": true
}
},
"Web": {
"localhost:443": {
"Handlers": {
"/api": {
"Proxy": "https://10.2.3.4/api"
}}}}}`,
certDomain: "",
wantErr: false,
wantSC: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
HTTPS: true,
},
},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
ipn.HostPort("localhost:443"): {
Handlers: map[string]*ipn.HTTPHandler{
"/api": {
Proxy: "https://10.2.3.4/api",
},
},
},
},
},
},
{
name: "invalid_json",
gotSC: "invalid json",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "serve-config.json")
if err := os.WriteFile(path, []byte(tt.gotSC), 0644); err != nil {
t.Fatal(err)
}
got, err := readServeConfig(path, tt.certDomain)
if (err != nil) != tt.wantErr {
t.Errorf("readServeConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !cmp.Equal(got, tt.wantSC) {
t.Errorf("readServeConfig() diff (-got +want):\n%s", cmp.Diff(got, tt.wantSC))
}
})
}
}
type fakeLocalClient struct {
*local.Client
setServeCalled bool
}
func (m *fakeLocalClient) SetServeConfig(ctx context.Context, cfg *ipn.ServeConfig) error {
m.setServeCalled = true
return nil
}
func TestHasHTTPSEndpoint(t *testing.T) {
tests := []struct {
name string
cfg *ipn.ServeConfig
want bool
}{
{
name: "nil_config",
cfg: nil,
want: false,
},
{
name: "empty_config",
cfg: &ipn.ServeConfig{},
want: false,
},
{
name: "no_https_endpoints",
cfg: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
80: {
HTTPS: false,
},
},
},
want: false,
},
{
name: "has_https_endpoint",
cfg: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
HTTPS: true,
},
},
},
want: true,
},
{
name: "mixed_endpoints",
cfg: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
80: {HTTPS: false},
443: {HTTPS: true},
},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hasHTTPSEndpoint(tt.cfg)
if got != tt.want {
t.Errorf("hasHTTPSEndpoint() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -11,24 +11,18 @@ import (
"errors"
"fmt"
"log"
"net/http"
"net/netip"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"time"
"github.com/fsnotify/fsnotify"
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/util/httpm"
"tailscale.com/util/linuxfw"
"tailscale.com/util/mak"
)
@@ -43,15 +37,13 @@ const tailscaleTunInterface = "tailscale0"
// egressProxy knows how to configure firewall rules to route cluster traffic to
// one or more tailnet services.
type egressProxy struct {
cfgPath string // path to a directory with egress services config files
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
tsClient *local.Client // never nil
netmapChan chan ipn.Notify // chan to receive netmap updates on
podIPv4 string // never empty string, currently only IPv4 is supported
@@ -63,29 +55,15 @@ type egressProxy struct {
// memory at all.
targetFQDNs map[string][]netip.Prefix
tailnetAddrs []netip.Prefix // tailnet IPs of this tailnet device
// shortSleep is the backoff sleep between healthcheck endpoint calls - can be overridden in tests.
shortSleep time.Duration
// longSleep is the time to sleep after the routing rules are updated to increase the chance that kube
// proxies on all nodes have updated their routing configuration. It can be configured to 0 in
// tests.
longSleep time.Duration
// client is a client that can send HTTP requests.
client httpClient
}
// httpClient is a client that can send HTTP requests and can be mocked in tests.
type httpClient interface {
Do(*http.Request) (*http.Response, error)
// 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, opts egressProxyRunOpts) error {
ep.configure(opts)
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
@@ -97,7 +75,7 @@ func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRu
tickChan = ticker.C
} else {
defer w.Close()
if err := w.Add(ep.cfgPath); err != nil {
if err := w.Add(filepath.Dir(ep.cfgPath)); err != nil {
return fmt.Errorf("failed to add fsnotify watch: %w", err)
}
eventChan = w.Events
@@ -107,52 +85,28 @@ func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRu
return err
}
for {
var err error
select {
case <-ctx.Done():
return nil
case <-tickChan:
log.Printf("periodic sync, ensuring firewall config is up to date...")
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 {
continue
if shouldResync {
log.Printf("netmap change detected, ensuring firewall config is up to date...")
err = ep.sync(ctx, n)
}
log.Printf("netmap change detected, ensuring firewall config is up to date...")
}
if err := ep.sync(ctx, n); err != nil {
if err != nil {
return fmt.Errorf("error syncing egress service config: %w", err)
}
}
}
type egressProxyRunOpts struct {
cfgPath string
nfr linuxfw.NetfilterRunner
kc kubeclient.Client
tsClient *local.Client
stateSecret string
netmapChan chan ipn.Notify
podIPv4 string
tailnetAddrs []netip.Prefix
}
// applyOpts configures egress proxy using the provided options.
func (ep *egressProxy) configure(opts egressProxyRunOpts) {
ep.cfgPath = opts.cfgPath
ep.nfr = opts.nfr
ep.kc = opts.kc
ep.tsClient = opts.tsClient
ep.stateSecret = opts.stateSecret
ep.netmapChan = opts.netmapChan
ep.podIPv4 = opts.podIPv4
ep.tailnetAddrs = opts.tailnetAddrs
ep.client = &http.Client{} // default HTTP client
ep.shortSleep = time.Second
ep.longSleep = time.Second * 10
}
// 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
@@ -373,8 +327,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, s
// getConfigs gets the mounted egress service configuration.
func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) {
svcsCfg := filepath.Join(ep.cfgPath, egressservices.KeyEgressServices)
j, err := os.ReadFile(svcsCfg)
j, err := os.ReadFile(ep.cfgPath)
if os.IsNotExist(err) {
return nil, nil
}
@@ -616,142 +569,3 @@ func servicesStatusIsEqual(st, st1 *egressservices.Status) bool {
st1.PodIPv4 = ""
return reflect.DeepEqual(*st, *st1)
}
// registerHandlers adds a new handler to the provided ServeMux that can be called as a Kubernetes prestop hook to
// delay shutdown till it's safe to do so.
func (ep *egressProxy) registerHandlers(mux *http.ServeMux) {
mux.Handle(fmt.Sprintf("GET %s", kubetypes.EgessServicesPreshutdownEP), ep)
}
// ServeHTTP serves /internal-egress-services-preshutdown endpoint, when it receives a request, it periodically polls
// the configured health check endpoint for each egress service till it the health check endpoint no longer hits this
// proxy Pod. It uses the Pod-IPv4 header to verify if health check response is received from this Pod.
func (ep *egressProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
cfgs, err := ep.getConfigs()
if err != nil {
http.Error(w, fmt.Sprintf("error retrieving egress services configs: %v", err), http.StatusInternalServerError)
return
}
if cfgs == nil {
if _, err := w.Write([]byte("safe to terminate")); err != nil {
http.Error(w, fmt.Sprintf("error writing termination status: %v", err), http.StatusInternalServerError)
return
}
}
hp, err := ep.getHEPPings()
if err != nil {
http.Error(w, fmt.Sprintf("error determining the number of times health check endpoint should be pinged: %v", err), http.StatusInternalServerError)
return
}
ep.waitTillSafeToShutdown(r.Context(), cfgs, hp)
}
// waitTillSafeToShutdown looks up all egress targets configured to be proxied via this instance and, for each target
// whose configuration includes a healthcheck endpoint, pings the endpoint till none of the responses
// are returned by this instance or till the HTTP request times out. In practice, the endpoint will be a Kubernetes Service for whom one of the backends
// would normally be this Pod. When this Pod is being deleted, the operator should have removed it from the Service
// backends and eventually kube proxy routing rules should be updated to no longer route traffic for the Service to this
// Pod.
func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs *egressservices.Configs, hp int) {
if cfgs == nil || len(*cfgs) == 0 { // avoid sleeping if no services are configured
return
}
log.Printf("Ensuring that cluster traffic for egress targets is no longer routed via this Pod...")
wg := syncs.WaitGroup{}
for s, cfg := range *cfgs {
hep := cfg.HealthCheckEndpoint
if hep == "" {
log.Printf("Tailnet target %q does not have a cluster healthcheck specified, unable to verify if cluster traffic for the target is still routed via this Pod", s)
continue
}
svc := s
wg.Go(func() {
log.Printf("Ensuring that cluster traffic is no longer routed to %q via this Pod...", svc)
for {
if ctx.Err() != nil { // kubelet's HTTP request timeout
log.Printf("Cluster traffic for %s did not stop being routed to this Pod.", svc)
return
}
found, err := lookupPodRoute(ctx, hep, ep.podIPv4, hp, ep.client)
if err != nil {
log.Printf("unable to reach endpoint %q, assuming the routing rules for this Pod have been deleted: %v", hep, err)
break
}
if !found {
log.Printf("service %q is no longer routed through this Pod", svc)
break
}
log.Printf("service %q is still routed through this Pod, waiting...", svc)
time.Sleep(ep.shortSleep)
}
})
}
wg.Wait()
// The check above really only checked that the routing rules are updated on this node. Sleep for a bit to
// ensure that the routing rules are updated on other nodes. TODO(irbekrm): this may or may not be good enough.
// If it's not good enough, we'd probably want to do something more complex, where the proxies check each other.
log.Printf("Sleeping for %s before shutdown to ensure that kube proxies on all nodes have updated routing configuration", ep.longSleep)
time.Sleep(ep.longSleep)
}
// lookupPodRoute calls the healthcheck endpoint repeat times and returns true if the endpoint returns with the podIP
// header at least once.
func lookupPodRoute(ctx context.Context, hep, podIP string, repeat int, client httpClient) (bool, error) {
for range repeat {
f, err := lookup(ctx, hep, podIP, client)
if err != nil {
return false, err
}
if f {
return true, nil
}
}
return false, nil
}
// lookup calls the healthcheck endpoint and returns true if the response contains the podIP header.
func lookup(ctx context.Context, hep, podIP string, client httpClient) (bool, error) {
req, err := http.NewRequestWithContext(ctx, httpm.GET, hep, nil)
if err != nil {
return false, fmt.Errorf("error creating new HTTP request: %v", err)
}
// Close the TCP connection to ensure that the next request is routed to a different backend.
req.Close = true
resp, err := client.Do(req)
if err != nil {
log.Printf("Endpoint %q can not be reached: %v, likely because there are no (more) healthy backends", hep, err)
return true, nil
}
defer resp.Body.Close()
gotIP := resp.Header.Get(kubetypes.PodIPv4Header)
return strings.EqualFold(podIP, gotIP), nil
}
// getHEPPings gets the number of pings that should be sent to a health check endpoint to ensure that each configured
// backend is hit. This assumes that a health check endpoint is a Kubernetes Service and traffic to backend Pods is
// round robin load balanced.
func (ep *egressProxy) getHEPPings() (int, error) {
hepPingsPath := filepath.Join(ep.cfgPath, egressservices.KeyHEPPings)
j, err := os.ReadFile(hepPingsPath)
if os.IsNotExist(err) {
return 0, nil
}
if err != nil {
return -1, err
}
if len(j) == 0 || string(j) == "" {
return 0, nil
}
hp, err := strconv.Atoi(string(j))
if err != nil {
return -1, fmt.Errorf("error parsing hep pings as int: %v", err)
}
if hp < 0 {
log.Printf("[unexpected] hep pings is negative: %d", hp)
return 0, nil
}
return hp, nil
}

View File

@@ -6,18 +6,11 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/netip"
"reflect"
"strings"
"sync"
"testing"
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubetypes"
)
func Test_updatesForSvc(t *testing.T) {
@@ -180,145 +173,3 @@ func Test_updatesForSvc(t *testing.T) {
})
}
}
// A failure of this test will most likely look like a timeout.
func TestWaitTillSafeToShutdown(t *testing.T) {
podIP := "10.0.0.1"
anotherIP := "10.0.0.2"
tests := []struct {
name string
// services is a map of service name to the number of calls to make to the healthcheck endpoint before
// returning a response that does NOT contain this Pod's IP in headers.
services map[string]int
replicas int
healthCheckSet bool
}{
{
name: "no_configs",
},
{
name: "one_service_immediately_safe_to_shutdown",
services: map[string]int{
"svc1": 0,
},
replicas: 2,
healthCheckSet: true,
},
{
name: "multiple_services_immediately_safe_to_shutdown",
services: map[string]int{
"svc1": 0,
"svc2": 0,
"svc3": 0,
},
replicas: 2,
healthCheckSet: true,
},
{
name: "multiple_services_no_healthcheck_endpoints",
services: map[string]int{
"svc1": 0,
"svc2": 0,
"svc3": 0,
},
replicas: 2,
},
{
name: "one_service_eventually_safe_to_shutdown",
services: map[string]int{
"svc1": 3, // After 3 calls to health check endpoint, no longer returns this Pod's IP
},
replicas: 2,
healthCheckSet: true,
},
{
name: "multiple_services_eventually_safe_to_shutdown",
services: map[string]int{
"svc1": 1, // After 1 call to health check endpoint, no longer returns this Pod's IP
"svc2": 3, // After 3 calls to health check endpoint, no longer returns this Pod's IP
"svc3": 5, // After 5 calls to the health check endpoint, no longer returns this Pod's IP
},
replicas: 2,
healthCheckSet: true,
},
{
name: "multiple_services_eventually_safe_to_shutdown_with_higher_replica_count",
services: map[string]int{
"svc1": 7,
"svc2": 10,
},
replicas: 5,
healthCheckSet: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfgs := &egressservices.Configs{}
switches := make(map[string]int)
for svc, callsToSwitch := range tt.services {
endpoint := fmt.Sprintf("http://%s.local", svc)
if tt.healthCheckSet {
(*cfgs)[svc] = egressservices.Config{
HealthCheckEndpoint: endpoint,
}
}
switches[endpoint] = callsToSwitch
}
ep := &egressProxy{
podIPv4: podIP,
client: &mockHTTPClient{
podIP: podIP,
anotherIP: anotherIP,
switches: switches,
},
}
ep.waitTillSafeToShutdown(context.Background(), cfgs, tt.replicas)
})
}
}
// mockHTTPClient is a client that receives an HTTP call for an egress service endpoint and returns a response with an
// IP address in a 'Pod-IPv4' header. It can be configured to return one IP address for N calls, then switch to another
// IP address to simulate a scenario where an IP is eventually no longer a backend for an endpoint.
// TODO(irbekrm): to test this more thoroughly, we should have the client take into account the number of replicas and
// return as if traffic was round robin load balanced across different Pods.
type mockHTTPClient struct {
// podIP - initial IP address to return, that matches the current proxy's IP address.
podIP string
anotherIP string
// after how many calls to an endpoint, the client should start returning 'anotherIP' instead of 'podIP.
switches map[string]int
mu sync.Mutex // protects the following
// calls tracks the number of calls received.
calls map[string]int
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
m.mu.Lock()
if m.calls == nil {
m.calls = make(map[string]int)
}
endpoint := req.URL.String()
m.calls[endpoint]++
calls := m.calls[endpoint]
m.mu.Unlock()
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
if calls <= m.switches[endpoint] {
resp.Header.Set(kubetypes.PodIPv4Header, m.podIP) // Pod is still routable
} else {
resp.Header.Set(kubetypes.PodIPv4Header, m.anotherIP) // Pod is no longer routable
}
return resp, nil
}

View File

@@ -64,16 +64,11 @@ type settings struct {
// 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
PodUID string
HealthCheckAddrPort string
LocalAddrPort string
MetricsEnabled bool
HealthCheckEnabled bool
DebugAddrPort string
EgressProxiesCfgPath string
PodIP string
PodIPv4 string
PodIPv6 string
HealthCheckAddrPort string
EgressSvcsCfgPath string
}
func configFromEnv() (*settings, error) {
@@ -103,12 +98,7 @@ func configFromEnv() (*settings, error) {
PodIP: defaultEnv("POD_IP", ""),
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""),
LocalAddrPort: defaultEnv("TS_LOCAL_ADDR_PORT", "[::]:9002"),
MetricsEnabled: defaultBool("TS_ENABLE_METRICS", false),
HealthCheckEnabled: defaultBool("TS_ENABLE_HEALTH_CHECK", false),
DebugAddrPort: defaultEnv("TS_DEBUG_ADDR_PORT", ""),
EgressProxiesCfgPath: defaultEnv("TS_EGRESS_PROXIES_CONFIG_PATH", ""),
PodUID: defaultEnv("POD_UID", ""),
EgressSvcsCfgPath: defaultEnv("TS_EGRESS_SERVICES_CONFIG_PATH", ""),
}
podIPs, ok := os.LookupEnv("POD_IPS")
if ok {
@@ -181,34 +171,17 @@ func (s *settings) validate() error {
return errors.New("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS is not supported in userspace mode")
}
if s.HealthCheckAddrPort != "" {
log.Printf("[warning] TS_HEALTHCHECK_ADDR_PORT is deprecated and will be removed in 1.82.0. Please use TS_ENABLE_HEALTH_CHECK and optionally TS_LOCAL_ADDR_PORT instead.")
if _, err := netip.ParseAddrPort(s.HealthCheckAddrPort); err != nil {
return fmt.Errorf("error parsing TS_HEALTHCHECK_ADDR_PORT value %q: %w", s.HealthCheckAddrPort, err)
return fmt.Errorf("error parsing TS_HEALTH_CHECK_ADDR_PORT value %q: %w", s.HealthCheckAddrPort, err)
}
}
if s.localMetricsEnabled() || s.localHealthEnabled() || s.EgressProxiesCfgPath != "" {
if _, err := netip.ParseAddrPort(s.LocalAddrPort); err != nil {
return fmt.Errorf("error parsing TS_LOCAL_ADDR_PORT value %q: %w", s.LocalAddrPort, err)
}
}
if s.DebugAddrPort != "" {
if _, err := netip.ParseAddrPort(s.DebugAddrPort); err != nil {
return fmt.Errorf("error parsing TS_DEBUG_ADDR_PORT value %q: %w", s.DebugAddrPort, err)
}
}
if s.HealthCheckEnabled && s.HealthCheckAddrPort != "" {
return errors.New("TS_HEALTHCHECK_ADDR_PORT is deprecated and will be removed in 1.82.0, use TS_ENABLE_HEALTH_CHECK and optionally TS_LOCAL_ADDR_PORT")
}
if s.EgressProxiesCfgPath != "" && !(s.InKubernetes && s.KubeSecret != "") {
return errors.New("TS_EGRESS_PROXIES_CONFIG_PATH is only supported for Tailscale running on Kubernetes")
}
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, kc *kubeClient) error {
func (cfg *settings) setupKube(ctx context.Context) error {
if cfg.KubeSecret == "" {
return nil
}
@@ -217,7 +190,6 @@ func (cfg *settings) setupKube(ctx context.Context, kc *kubeClient) error {
return fmt.Errorf("some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
}
cfg.KubernetesCanPatch = canPatch
kc.canPatch = canPatch
s, err := kc.GetSecret(ctx, cfg.KubeSecret)
if err != nil {
@@ -291,7 +263,7 @@ func isOneStepConfig(cfg *settings) bool {
// 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.EgressProxiesCfgPath != ""
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
@@ -300,18 +272,6 @@ func hasKubeStateStore(cfg *settings) bool {
return cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != ""
}
func (cfg *settings) localMetricsEnabled() bool {
return cfg.LocalAddrPort != "" && cfg.MetricsEnabled
}
func (cfg *settings) localHealthEnabled() bool {
return cfg.LocalAddrPort != "" && cfg.HealthCheckEnabled
}
func (cfg *settings) egressSvcsTerminateEPEnabled() bool {
return cfg.LocalAddrPort != "" && cfg.EgressProxiesCfgPath != ""
}
// defaultEnv returns the value of the given envvar name, or defVal if
// unset.
func defaultEnv(name, defVal string) string {

View File

@@ -13,17 +13,14 @@ import (
"log"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"syscall"
"time"
"github.com/fsnotify/fsnotify"
"tailscale.com/client/local"
"tailscale.com/client/tailscale"
)
func startTailscaled(ctx context.Context, cfg *settings) (*local.Client, *os.Process, error) {
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.
@@ -42,19 +39,19 @@ func startTailscaled(ctx context.Context, cfg *settings) (*local.Client, *os.Pro
log.Printf("Waiting for tailscaled socket")
for {
if ctx.Err() != nil {
return nil, nil, errors.New("timed out waiting for tailscaled socket")
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 {
return nil, nil, fmt.Errorf("error waiting for tailscaled socket: %w", err)
log.Fatalf("Waiting for tailscaled socket: %v", err)
}
break
}
tsClient := &local.Client{
tsClient := &tailscale.LocalClient{
Socket: cfg.Socket,
UseSocketOnly: true,
}
@@ -93,12 +90,6 @@ func tailscaledArgs(cfg *settings) []string {
if cfg.TailscaledConfigFilePath != "" {
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
}
// Once enough proxy versions have been released for all the supported
// versions to understand this cfg setting, the operator can stop
// setting TS_TAILSCALED_EXTRA_ARGS for the debug flag.
if cfg.DebugAddrPort != "" && !strings.Contains(cfg.DaemonExtraArgs, cfg.DebugAddrPort) {
args = append(args, "--debug="+cfg.DebugAddrPort)
}
if cfg.DaemonExtraArgs != "" {
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
}
@@ -169,75 +160,3 @@ func tailscaleSet(ctx context.Context, cfg *settings) error {
}
return nil
}
func watchTailscaledConfigChanges(ctx context.Context, path string, lc *local.Client, errCh chan<- error) {
var (
tickChan <-chan time.Time
eventChan <-chan fsnotify.Event
errChan <-chan error
tailscaledCfgDir = filepath.Dir(path)
prevTailscaledCfg []byte
)
if w, err := fsnotify.NewWatcher(); err != nil {
// Creating a new fsnotify watcher would fail for example if inotify was not able to create a new file descriptor.
// See https://github.com/tailscale/tailscale/issues/15081
log.Printf("tailscaled config watch: 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(tailscaledCfgDir); err != nil {
errCh <- fmt.Errorf("failed to add fsnotify watch: %w", err)
return
}
eventChan = w.Events
errChan = w.Errors
}
b, err := os.ReadFile(path)
if err != nil {
errCh <- fmt.Errorf("error reading configfile: %w", err)
return
}
prevTailscaledCfg = b
// kubelet mounts Secrets to Pods using a series of symlinks, one of
// which is <mount-dir>/..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
const kubeletMountedCfg = "..data"
toWatch := filepath.Join(tailscaledCfgDir, kubeletMountedCfg)
for {
select {
case <-ctx.Done():
return
case err := <-errChan:
errCh <- fmt.Errorf("watcher error: %w", err)
return
case <-tickChan:
case event := <-eventChan:
if event.Name != toWatch {
continue
}
}
b, err := os.ReadFile(path)
if err != nil {
errCh <- fmt.Errorf("error reading configfile: %w", err)
return
}
// For some proxy types the mounted volume also contains tailscaled state and other files. We
// don't want to reload config unnecessarily on unrelated changes to these files.
if reflect.DeepEqual(b, prevTailscaledCfg) {
continue
}
prevTailscaledCfg = b
log.Printf("tailscaled config watch: ensuring that config is up to date")
ok, err := lc.ReloadConfig(ctx)
if err != nil {
errCh <- fmt.Errorf("error reloading tailscaled config: %w", err)
return
}
if ok {
log.Printf("tailscaled config watch: config was reloaded")
}
}
}

View File

@@ -20,10 +20,10 @@ import (
)
func BenchmarkHandleBootstrapDNS(b *testing.B) {
tstest.Replace(b, bootstrapDNS, "log.tailscale.com,login.tailscale.com,controlplane.tailscale.com,login.us.tailscale.com")
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.com"), nil)
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) {
@@ -63,7 +63,7 @@ func TestUnpublishedDNS(t *testing.T) {
nettest.SkipIfNoNetwork(t)
const published = "login.tailscale.com"
const unpublished = "log.tailscale.com"
const unpublished = "log.tailscale.io"
prev1, prev2 := *bootstrapDNS, *unpublishedDNS
*bootstrapDNS = published
@@ -119,18 +119,18 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
unpublishedDNSCache.Store(&dnsEntryMap{
IPs: map[string][]net.IP{
"log.tailscale.com": {},
"log.tailscale.io": {},
"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)},
},
Percent: map[string]float64{
"log.tailscale.com": 1.0,
"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.com", "login.tailscale.com"} {
for _, q := range []string{"log.tailscale.io", "login.tailscale.com"} {
resetMetrics()
ips := getBootstrapDNS(t, q)

View File

@@ -4,28 +4,15 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"time"
"golang.org/x/crypto/acme/autocert"
"tailscale.com/tailcfg"
)
var unsafeHostnameCharacters = regexp.MustCompile(`[^a-zA-Z0-9-\.]`)
@@ -66,9 +53,8 @@ func certProviderByCertMode(mode, dir, hostname string) (certProvider, error) {
}
type manualCertManager struct {
cert *tls.Certificate
hostname string // hostname or IP address of server
noHostname bool // whether hostname is an IP address
cert *tls.Certificate
hostname string
}
// NewManualCertManager returns a cert provider which read certificate by given hostname on create.
@@ -77,18 +63,8 @@ func NewManualCertManager(certdir, hostname string) (certProvider, error) {
crtPath := filepath.Join(certdir, keyname+".crt")
keyPath := filepath.Join(certdir, keyname+".key")
cert, err := tls.LoadX509KeyPair(crtPath, keyPath)
hostnameIP := net.ParseIP(hostname) // or nil if hostname isn't an IP address
if err != nil {
// If the hostname is an IP address, automatically create a
// self-signed certificate for it.
var certp *tls.Certificate
if os.IsNotExist(err) && hostnameIP != nil {
certp, err = createSelfSignedIPCert(crtPath, keyPath, hostname)
}
if err != nil {
return nil, fmt.Errorf("can not load x509 key pair for hostname %q: %w", keyname, err)
}
cert = *certp
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])
@@ -98,23 +74,7 @@ func NewManualCertManager(certdir, hostname string) (certProvider, error) {
if err := x509Cert.VerifyHostname(hostname); err != nil {
return nil, fmt.Errorf("cert invalid for hostname %q: %w", hostname, err)
}
if hostnameIP != nil {
// If the hostname is an IP address, print out information on how to
// confgure this in the derpmap.
dn := &tailcfg.DERPNode{
Name: "custom",
RegionID: 900,
HostName: hostname,
CertName: fmt.Sprintf("sha256-raw:%-02x", sha256.Sum256(x509Cert.Raw)),
}
dnJSON, _ := json.Marshal(dn)
log.Printf("Using self-signed certificate for IP address %q. Configure it in DERPMap using: (https://tailscale.com/s/custom-derp)\n %s", hostname, dnJSON)
}
return &manualCertManager{
cert: &cert,
hostname: hostname,
noHostname: net.ParseIP(hostname) != nil,
}, nil
return &manualCertManager{cert: &cert, hostname: hostname}, nil
}
func (m *manualCertManager) TLSConfig() *tls.Config {
@@ -128,7 +88,7 @@ func (m *manualCertManager) TLSConfig() *tls.Config {
}
func (m *manualCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if hi.ServerName != m.hostname && !m.noHostname {
if hi.ServerName != m.hostname {
return nil, fmt.Errorf("cert mismatch with hostname: %q", hi.ServerName)
}
@@ -143,69 +103,3 @@ func (m *manualCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certif
func (m *manualCertManager) HTTPHandler(fallback http.Handler) http.Handler {
return fallback
}
func createSelfSignedIPCert(crtPath, keyPath, ipStr string) (*tls.Certificate, error) {
ip := net.ParseIP(ipStr)
if ip == nil {
return nil, fmt.Errorf("invalid IP address: %s", ipStr)
}
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate EC private key: %v", err)
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %v", err)
}
now := time.Now()
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: ipStr,
},
NotBefore: now,
NotAfter: now.AddDate(1, 0, 0), // expires in 1 year; a bit over that is rejected by macOS etc
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Set the IP as a SAN.
template.IPAddresses = []net.IP{ip}
// Create the self-signed certificate.
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyBytes, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return nil, fmt.Errorf("unable to marshal EC private key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
if err := os.MkdirAll(filepath.Dir(crtPath), 0700); err != nil {
return nil, fmt.Errorf("failed to create directory for certificate: %v", err)
}
if err := os.WriteFile(crtPath, certPEM, 0644); err != nil {
return nil, fmt.Errorf("failed to write certificate to %s: %v", crtPath, err)
}
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
return nil, fmt.Errorf("failed to write key to %s: %v", keyPath, err)
}
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("failed to create tls.Certificate: %v", err)
}
return &tlsCert, nil
}

View File

@@ -1,170 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/net/netmon"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
// Verify that in --certmode=manual mode, we can use a bare IP address
// as the --hostname and that GetCertificate will return it.
func TestCertIP(t *testing.T) {
dir := t.TempDir()
const hostname = "1.2.3.4"
priv, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader)
if err != nil {
t.Fatal(err)
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
t.Fatal(err)
}
ip := net.ParseIP(hostname)
if ip == nil {
t.Fatalf("invalid IP address %q", hostname)
}
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"Tailscale Test Corp"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(30 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{ip},
}
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
t.Fatal(err)
}
certOut, err := os.Create(filepath.Join(dir, hostname+".crt"))
if err != nil {
t.Fatal(err)
}
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
t.Fatalf("Failed to write data to cert.pem: %v", err)
}
if err := certOut.Close(); err != nil {
t.Fatalf("Error closing cert.pem: %v", err)
}
keyOut, err := os.OpenFile(filepath.Join(dir, hostname+".key"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
t.Fatal(err)
}
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("Unable to marshal private key: %v", err)
}
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
t.Fatalf("Failed to write data to key.pem: %v", err)
}
if err := keyOut.Close(); err != nil {
t.Fatalf("Error closing key.pem: %v", err)
}
cp, err := certProviderByCertMode("manual", dir, hostname)
if err != nil {
t.Fatal(err)
}
back, err := cp.TLSConfig().GetCertificate(&tls.ClientHelloInfo{
ServerName: "", // no SNI
})
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
if back == nil {
t.Fatalf("GetCertificate returned nil")
}
}
// Test that we can dial a raw IP without using a hostname and without a WebPKI
// cert, validating the cert against the signature of the cert in the DERP map's
// DERPNode.
//
// See https://github.com/tailscale/tailscale/issues/11776.
func TestPinnedCertRawIP(t *testing.T) {
td := t.TempDir()
cp, err := NewManualCertManager(td, "127.0.0.1")
if err != nil {
t.Fatalf("NewManualCertManager: %v", err)
}
cert, err := cp.TLSConfig().GetCertificate(&tls.ClientHelloInfo{
ServerName: "127.0.0.1",
})
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer ln.Close()
ds := derp.NewServer(key.NewNode(), t.Logf)
derpHandler := derphttp.Handler(ds)
mux := http.NewServeMux()
mux.Handle("/derp", derpHandler)
var hs http.Server
hs.Handler = mux
hs.TLSConfig = cp.TLSConfig()
go hs.ServeTLS(ln, "", "")
lnPort := ln.Addr().(*net.TCPAddr).Port
reg := &tailcfg.DERPRegion{
RegionID: 900,
Nodes: []*tailcfg.DERPNode{
{
RegionID: 900,
HostName: "127.0.0.1",
CertName: fmt.Sprintf("sha256-raw:%-02x", sha256.Sum256(cert.Leaf.Raw)),
DERPPort: lnPort,
},
},
}
netMon := netmon.NewStatic()
dc := derphttp.NewRegionClient(key.NewNode(), t.Logf, netMon, func() *tailcfg.DERPRegion {
return reg
})
defer dc.Close()
_, connClose, _, err := dc.DialRegionTLS(context.Background(), reg)
if err != nil {
t.Fatalf("DialRegionTLS: %v", err)
}
defer connClose.Close()
}

View File

@@ -27,7 +27,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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 tailscale.com/util/fastuuid
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+
@@ -35,11 +37,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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/munnerz/goautoneg from github.com/prometheus/common/expfmt
💣 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
@@ -51,11 +53,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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
github.com/tailscale/setec/client/setec from tailscale.com/cmd/derper
github.com/tailscale/setec/types/api from github.com/tailscale/setec/client/setec
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/local+
💣 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
@@ -87,29 +87,27 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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/local from tailscale.com/client/tailscale+
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/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/local+
tailscale.com/envknob from tailscale.com/client/local+
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/local
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
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/bakedroots from tailscale.com/net/tlsdial
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/local
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
@@ -119,11 +117,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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/local
💣 tailscale.com/safesocket from tailscale.com/client/local
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/local+
tailscale.com/tka from tailscale.com/client/local+
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
@@ -134,7 +132,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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/local+
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
@@ -144,7 +142,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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/local+
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+
@@ -154,6 +152,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 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/fastuuid from tailscale.com/tsweb
💣 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+
@@ -161,7 +160,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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+
@@ -191,11 +189,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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/internal/alias from golang.org/x/crypto/chacha20+
golang.org/x/crypto/internal/poly1305 from golang.org/x/crypto/chacha20poly1305+
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+
@@ -204,12 +202,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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/internal/socks from golang.org/x/net/proxy
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/tailscale/setec/client/setec
golang.org/x/sys/cpu from golang.org/x/crypto/argon2+
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+
@@ -228,7 +224,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
container/list from crypto/tls+
context from crypto/tls+
crypto from crypto/ecdh+
crypto/aes from crypto/internal/hpke+
crypto/aes from crypto/ecdsa+
crypto/cipher from crypto/aes+
crypto/des from crypto/tls+
crypto/dsa from crypto/x509
@@ -237,58 +233,19 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
crypto/ed25519 from crypto/tls+
crypto/elliptic from crypto/ecdsa+
crypto/hmac from crypto/tls+
crypto/internal/boring from crypto/aes+
crypto/internal/boring/bbig from crypto/ecdsa+
crypto/internal/boring/sig from crypto/internal/boring
crypto/internal/entropy from crypto/internal/fips140/drbg
crypto/internal/fips140 from crypto/internal/fips140/aes+
crypto/internal/fips140/aes from crypto/aes+
crypto/internal/fips140/aes/gcm from crypto/cipher+
crypto/internal/fips140/alias from crypto/cipher+
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
crypto/internal/fips140/check from crypto/internal/fips140/aes+
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
crypto/internal/fips140/ecdh from crypto/ecdh
crypto/internal/fips140/ecdsa from crypto/ecdsa
crypto/internal/fips140/ed25519 from crypto/ed25519
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
crypto/internal/fips140/hmac from crypto/hmac+
crypto/internal/fips140/mlkem from crypto/tls
crypto/internal/fips140/nistec from crypto/elliptic+
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
crypto/internal/fips140/rsa from crypto/rsa
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
crypto/internal/fips140/sha3 from crypto/internal/fips140/hmac+
crypto/internal/fips140/sha512 from crypto/internal/fips140/ecdsa+
crypto/internal/fips140/subtle from crypto/internal/fips140/aes+
crypto/internal/fips140/tls12 from crypto/tls
crypto/internal/fips140/tls13 from crypto/tls
crypto/internal/fips140deps/byteorder from crypto/internal/fips140/aes+
crypto/internal/fips140deps/cpu from crypto/internal/fips140/aes+
crypto/internal/fips140deps/godebug from crypto/internal/fips140+
crypto/internal/fips140hash from crypto/ecdsa+
crypto/internal/fips140only from crypto/cipher+
crypto/internal/hpke from crypto/tls
crypto/internal/impl from crypto/internal/fips140/aes+
crypto/internal/randutil from crypto/dsa+
crypto/internal/sysrand from crypto/internal/entropy+
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/sha3 from crypto/internal/fips140hash
crypto/sha512 from crypto/ecdsa+
crypto/subtle from crypto/cipher+
crypto/subtle from crypto/aes+
crypto/tls from golang.org/x/crypto/acme+
crypto/tls/internal/fips140tls from crypto/tls
crypto/x509 from crypto/tls+
D crypto/x509/internal/macos from crypto/x509
crypto/x509/pkix from crypto/x509+
embed from google.golang.org/protobuf/internal/editiondefaults+
database/sql/driver from github.com/google/uuid
embed from crypto/internal/nistec+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from github.com/fxamacker/cbor/v2+
@@ -308,49 +265,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
hash/maphash from go4.org/mem
html from net/http/pprof+
html/template from tailscale.com/cmd/derper
internal/abi from crypto/x509/internal/macos+
internal/asan from syscall+
internal/bisect from internal/godebug
internal/bytealg from bytes+
internal/byteorder from crypto/cipher+
internal/chacha8rand from math/rand/v2+
internal/coverage/rtcov from runtime
internal/cpu from crypto/internal/fips140deps/cpu+
internal/filepathlite from os+
internal/fmtsort from fmt+
internal/goarch from crypto/internal/fips140deps/cpu+
internal/godebug from crypto/tls+
internal/godebugs from internal/godebug+
internal/goexperiment from runtime+
internal/goos from crypto/x509+
internal/itoa from internal/poll+
internal/msan from syscall+
internal/nettrace from net+
internal/oserror from io/fs+
internal/poll from net+
internal/profile from net/http/pprof
internal/profilerecord from runtime+
internal/race from internal/poll+
internal/reflectlite from context+
internal/runtime/atomic from internal/runtime/exithook+
internal/runtime/exithook from runtime
internal/runtime/maps from reflect+
internal/runtime/math from internal/runtime/maps+
internal/runtime/sys from crypto/subtle+
L internal/runtime/syscall from runtime+
internal/singleflight from net
internal/stringslite from embed+
internal/sync from sync+
internal/syscall/execenv from os+
LD internal/syscall/unix from crypto/internal/sysrand+
W internal/syscall/windows from crypto/internal/sysrand+
W internal/syscall/windows/registry from mime+
W internal/syscall/windows/sysdll from internal/syscall/windows+
internal/testlog from os
internal/unsafeheader from internal/reflectlite+
io from bufio+
io/fs from crypto/x509+
L io/ioutil from github.com/mitchellh/go-ps+
io/ioutil from github.com/mitchellh/go-ps+
iter from maps+
log from expvar+
log/internal from log
@@ -359,7 +276,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
math/big from crypto/dsa+
math/bits from compress/flate+
math/rand from github.com/mdlayher/netlink+
math/rand/v2 from crypto/ecdsa+
math/rand/v2 from tailscale.com/util/fastuuid+
mime from github.com/prometheus/common/expfmt+
mime/multipart from net/http
mime/quotedprintable from mime/multipart
@@ -367,12 +284,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
net/http from expvar+
net/http/httptrace from net/http+
net/http/internal from net/http
net/http/internal/ascii 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/internal/sysrand+
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+
@@ -381,7 +297,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
reflect from crypto/x509+
regexp from github.com/coreos/go-iptables/iptables+
regexp/syntax from regexp
runtime from crypto/internal/fips140+
runtime/debug from github.com/prometheus/client_golang/prometheus+
runtime/metrics from github.com/prometheus/client_golang/prometheus+
runtime/pprof from net/http/pprof
@@ -392,7 +307,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
strings from bufio+
sync from compress/flate+
sync/atomic from context+
syscall from crypto/internal/sysrand+
syscall from crypto/rand+
text/tabwriter from runtime/pprof
text/template from html/template
text/template/parse from html/template+
@@ -401,5 +316,3 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
unicode/utf16 from crypto/x509+
unicode/utf8 from bufio+
unique from net/netip
unsafe from bytes+
weak from unique

View File

@@ -27,7 +27,6 @@ import (
"net/http"
"os"
"os/signal"
"path"
"path/filepath"
"regexp"
"runtime"
@@ -37,7 +36,6 @@ import (
"syscall"
"time"
"github.com/tailscale/setec/client/setec"
"golang.org/x/time/rate"
"tailscale.com/atomicfile"
"tailscale.com/derp"
@@ -60,25 +58,18 @@ var (
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. When --certmode=manual, this can be an IP address to avoid SNI checks")
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.")
flagHome = flag.String("home", "", "what to serve at the root path. It may be left empty (the default, for a default homepage), \"blank\" for a blank page, or a URL to redirect to")
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. If an entry contains a slash, the second part names a hostname to be used when dialing the target.")
secretsURL = flag.String("secrets-url", "", "SETEC server URL for secrets retrieval of mesh key")
secretPrefix = flag.String("secrets-path-prefix", "prod/derp", "setec path prefix for \""+setecMeshKeyName+"\" secret for DERP mesh key")
secretsCacheDir = flag.String("secrets-cache-dir", defaultSetecCacheDir(), "directory to cache setec secrets in (required if --secrets-url is set)")
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")
socket = flag.String("socket", "", "optional alternate path to tailscaled socket (only relevant when using --verify-clients)")
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")
@@ -86,21 +77,13 @@ var (
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")
// tcpWriteTimeout is the timeout for writing to client TCP connections. It does not apply to mesh connections.
tcpWriteTimeout = flag.Duration("tcp-write-timeout", derp.DefaultTCPWiteTimeout, "TCP write timeout; 0 results in no timeout being set on writes")
)
var (
tlsRequestVersion = &metrics.LabelMap{Label: "version"}
tlsActiveVersion = &metrics.LabelMap{Label: "version"}
// Exactly 64 hexadecimal lowercase digits.
validMeshKey = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
const setecMeshKeyName = "meshkey"
const meshKeyEnvVar = "TAILSCALE_DERPER_MESH_KEY"
func init() {
expvar.Publish("derper_tls_request_version", tlsRequestVersion)
expvar.Publish("gauge_derper_tls_active_version", tlsActiveVersion)
@@ -156,14 +139,6 @@ func writeNewConfig() config {
return cfg
}
func checkMeshKey(key string) (string, error) {
key = strings.TrimSpace(key)
if !validMeshKey.MatchString(key) {
return "", errors.New("key must contain exactly 64 hex digits")
}
return key, nil
}
func main() {
flag.Parse()
if *versionFlag {
@@ -196,70 +171,26 @@ func main() {
s := derp.NewServer(cfg.PrivateKey, log.Printf)
s.SetVerifyClient(*verifyClients)
s.SetTailscaledSocketPath(*socket)
s.SetVerifyClientURL(*verifyClientURL)
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
s.SetTCPWriteTimeout(*tcpWriteTimeout)
var meshKey string
if *dev {
meshKey = os.Getenv(meshKeyEnvVar)
if meshKey == "" {
log.Printf("No mesh key specified for dev via %s\n", meshKeyEnvVar)
} else {
log.Printf("Set mesh key from %s\n", meshKeyEnvVar)
}
} else if *secretsURL != "" {
meshKeySecret := path.Join(*secretPrefix, setecMeshKeyName)
fc, err := setec.NewFileCache(*secretsCacheDir)
if *meshPSKFile != "" {
b, err := os.ReadFile(*meshPSKFile)
if err != nil {
log.Fatalf("NewFileCache: %v", err)
log.Fatal(err)
}
log.Printf("Setting up setec store from %q", *secretsURL)
st, err := setec.NewStore(ctx,
setec.StoreConfig{
Client: setec.Client{Server: *secretsURL},
Secrets: []string{
meshKeySecret,
},
Cache: fc,
})
if err != nil {
log.Fatalf("NewStore: %v", 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)
}
meshKey = st.Secret(meshKeySecret).GetString()
log.Println("Got mesh key from setec store")
st.Close()
} else if *meshPSKFile != "" {
b, err := setec.StaticFile(*meshPSKFile)
if err != nil {
log.Fatalf("StaticFile failed to get key: %v", err)
}
log.Println("Got mesh key from static file")
meshKey = b.GetString()
}
if meshKey == "" && *dev {
log.Printf("No mesh key configured for --dev mode")
} else if meshKey == "" {
log.Printf("No mesh key configured")
} else if key, err := checkMeshKey(meshKey); err != nil {
log.Fatalf("invalid mesh key: %v", err)
} else {
s.SetMeshKey(key)
log.Println("DERP mesh key configured")
log.Printf("DERP mesh key configured")
}
if err := startMesh(s); err != nil {
log.Fatalf("startMesh: %v", err)
}
expvar.Publish("derp", s.ExpVar())
handleHome, ok := getHomeHandler(*flagHome)
if !ok {
log.Fatalf("unknown --home value %q", *flagHome)
}
mux := http.NewServeMux()
if *runDERP {
derpHandler := derphttp.Handler(s)
@@ -280,7 +211,19 @@ func main() {
mux.HandleFunc("/bootstrap-dns", tsweb.BrowserHeaderHandlerFunc(handleBootstrapDNS))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tsweb.AddBrowserHeaders(w)
handleHome.ServeHTTP(w, r)
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)
@@ -322,9 +265,6 @@ func main() {
Control: ktimeout.UserTimeout(*tcpUserTimeout),
KeepAlive: *tcpKeepAlive,
}
// As of 2025-02-19, MPTCP does not support TCP_USER_TIMEOUT socket option
// set in ktimeout.UserTimeout above.
lc.SetMultipathTCP(false)
quietLogger := log.New(logger.HTTPServerLogFilter{Inner: log.Printf}, "", 0)
httpsrv := &http.Server{
@@ -439,10 +379,6 @@ func prodAutocertHostPolicy(_ context.Context, host string) error {
return errors.New("invalid hostname")
}
func defaultSetecCacheDir() string {
return filepath.Join(os.Getenv("HOME"), ".cache", "derper-secrets")
}
func defaultMeshPSKFile() string {
try := []string{
"/home/derp/keys/derp-mesh.key",
@@ -573,35 +509,3 @@ var homePageTemplate = template.Must(template.New("home").Parse(`<html><body>
</body>
</html>
`))
// getHomeHandler returns a handler for the home page based on a flag string
// as documented on the --home flag.
func getHomeHandler(val string) (_ http.Handler, ok bool) {
if val == "" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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
}
}), true
}
if val == "blank" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
}), true
}
if strings.HasPrefix(val, "http://") || strings.HasPrefix(val, "https://") {
return http.RedirectHandler(val, http.StatusFound), true
}
return nil, false
}

View File

@@ -6,6 +6,7 @@ package main
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -108,7 +109,6 @@ func TestDeps(t *testing.T) {
"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)
}
@@ -137,47 +137,5 @@ func TestTemplate(t *testing.T) {
if !strings.Contains(str, "Debug info") {
t.Error("Output is missing debug info")
}
}
func TestCheckMeshKey(t *testing.T) {
testCases := []struct {
name string
input string
want string
wantErr bool
}{
{
name: "KeyOkay",
input: "f1ffafffffffffffffffffffffffffffffffffffffffffffffffff2ffffcfff6",
want: "f1ffafffffffffffffffffffffffffffffffffffffffffffffffff2ffffcfff6",
wantErr: false,
},
{
name: "TrimKeyOkay",
input: " f1ffafffffffffffffffffffffffffffffffffffffffffffffffff2ffffcfff6 ",
want: "f1ffafffffffffffffffffffffffffffffffffffffffffffffffff2ffffcfff6",
wantErr: false,
},
{
name: "NotAKey",
input: "zzthisisnotakey",
want: "",
wantErr: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
k, err := checkMeshKey(tt.input)
if err != nil && !tt.wantErr {
t.Errorf("unexpected error: %v", err)
}
if k != tt.want && err == nil {
t.Errorf("want: %s doesn't match expected: %s", tt.want, k)
}
})
}
fmt.Println(buf.String())
}

View File

@@ -10,6 +10,7 @@ import (
"log"
"net"
"strings"
"time"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
@@ -24,28 +25,15 @@ func startMesh(s *derp.Server) error {
if !s.HasMeshKey() {
return errors.New("--mesh-with requires --mesh-psk-file")
}
for _, hostTuple := range strings.Split(*meshWith, ",") {
if err := startMeshWithHost(s, hostTuple); err != nil {
for _, host := range strings.Split(*meshWith, ",") {
if err := startMeshWithHost(s, host); err != nil {
return err
}
}
return nil
}
func startMeshWithHost(s *derp.Server, hostTuple string) error {
var host string
var dialHost string
hostParts := strings.Split(hostTuple, "/")
if len(hostParts) > 2 {
return fmt.Errorf("too many components in host tuple %q", hostTuple)
}
host = hostParts[0]
if len(hostParts) == 2 {
dialHost = hostParts[1]
} else {
dialHost = hostParts[0]
}
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)
@@ -55,20 +43,31 @@ func startMeshWithHost(s *derp.Server, hostTuple string) error {
c.MeshKey = s.MeshKey()
c.WatchConnectionChanges = true
logf("will dial %q for %q", dialHost, host)
if dialHost != host {
// 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
c.SetURLDialer(func(ctx context.Context, network, addr string) (net.Conn, error) {
_, port, err := net.SplitHostPort(addr)
if err != nil {
logf("failed to split %q: %v", addr, err)
return nil, err
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)
}
dialAddr := net.JoinHostPort(dialHost, port)
logf("dialing %q instead of %q", dialAddr, addr)
return d.DialContext(ctx, network, dialAddr)
})
}
}
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) }

View File

@@ -18,21 +18,18 @@ import (
)
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")
bwTUNIPv4Address = flag.String("bw-tun-ipv4-addr", "", "if specified, bandwidth probes will be performed over a TUN device at this address in order to exercise TCP-in-TCP in similar fashion to TCP over Tailscale via DERP; we will use a /30 subnet including this IP address")
qdPacketsPerSecond = flag.Int("qd-packets-per-second", 0, "if greater than 0, queuing delay will be measured continuously using 260 byte packets (approximate size of a CallMeMaybe packet) sent at this rate per second")
qdPacketTimeout = flag.Duration("qd-packet-timeout", 5*time.Second, "queuing delay packets arriving after this period of time from being sent are treated like dropped packets and don't count toward queuing delay timings")
regionCodeOrID = flag.String("region-code", "", "probe only this region (e.g. 'lax' or '17'); if left blank, all regions will be probed")
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() {
@@ -47,13 +44,12 @@ func main() {
prober.WithMeshProbing(*meshInterval),
prober.WithSTUNProbing(*stunInterval),
prober.WithTLSProbing(*tlsInterval),
prober.WithQueuingDelayProbing(*qdPacketsPerSecond, *qdPacketTimeout),
}
if *bwInterval > 0 {
opts = append(opts, prober.WithBandwidthProbing(*bwInterval, *bwSize, *bwTUNIPv4Address))
opts = append(opts, prober.WithBandwidthProbing(*bwInterval, *bwSize))
}
if *regionCodeOrID != "" {
opts = append(opts, prober.WithRegionCodeOrID(*regionCodeOrID))
if *regionCode != "" {
opts = append(opts, prober.WithRegion(*regionCode))
}
dp, err := prober.DERP(p, *derpMapURL, opts...)
if err != nil {
@@ -110,7 +106,7 @@ func getOverallStatus(p *prober.Prober) (o overallStatus) {
// Do not show probes that have not finished yet.
continue
}
if i.Status == prober.ProbeStatusSucceeded {
if i.Result {
o.addGoodf("%s: %s", p, i.Latency)
} else {
o.addBadf("%s: %s", p, i.Error)

View File

@@ -16,10 +16,14 @@ import (
"strings"
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/internal/client/tailscale"
"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")
@@ -42,6 +46,7 @@ func main() {
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: baseURL + "/api/v2/oauth/token",
Scopes: []string{"device"},
}
ctx := context.Background()

View File

@@ -13,7 +13,6 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
@@ -59,8 +58,8 @@ func apply(cache *Cache, client *http.Client, tailnet, apiKey string) func(conte
}
if cache.PrevETag == "" {
log.Println("no previous etag found, assuming the latest control etag")
cache.PrevETag = controlEtag
log.Println("no previous etag found, assuming local file is correct and recording that")
cache.PrevETag = localEtag
}
log.Printf("control: %s", controlEtag)
@@ -106,8 +105,8 @@ func test(cache *Cache, client *http.Client, tailnet, apiKey string) func(contex
}
if cache.PrevETag == "" {
log.Println("no previous etag found, assuming the latest control etag")
cache.PrevETag = controlEtag
log.Println("no previous etag found, assuming local file is correct and recording that")
cache.PrevETag = localEtag
}
log.Printf("control: %s", controlEtag)
@@ -149,8 +148,8 @@ func getChecksums(cache *Cache, client *http.Client, tailnet, apiKey string) fun
}
if cache.PrevETag == "" {
log.Println("no previous etag found, assuming control etag")
cache.PrevETag = Shuck(controlEtag)
log.Println("no previous etag found, assuming local file is correct and recording that")
cache.PrevETag = Shuck(localEtag)
}
log.Printf("control: %s", controlEtag)
@@ -406,8 +405,7 @@ func getACLETag(ctx context.Context, client *http.Client, tailnet, apiKey string
got := resp.StatusCode
want := http.StatusOK
if got != want {
errorDetails, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("wanted HTTP status code %d but got %d: %#q", want, got, string(errorDetails))
return "", fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
}
return Shuck(resp.Header.Get("ETag")), nil

View File

@@ -18,9 +18,8 @@ import (
"strings"
"time"
"tailscale.com/client/local"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tailcfg"
)
var (
@@ -32,7 +31,7 @@ var (
//go:embed hello.tmpl.html
var embeddedTemplate string
var localClient local.Client
var localClient tailscale.LocalClient
func main() {
flag.Parse()
@@ -135,10 +134,6 @@ func tailscaleIP(who *apitype.WhoIsResponse) string {
if who == nil {
return ""
}
vals, err := tailcfg.UnmarshalNodeCapJSON[string](who.Node.CapMap, tailcfg.NodeAttrNativeIPV4)
if err == nil && len(vals) > 0 {
return vals[0]
}
for _, nodeIP := range who.Node.Addresses {
if nodeIP.Addr().Is4() && nodeIP.IsSingleIP() {
return nodeIP.Addr().String()

View File

@@ -10,7 +10,6 @@ import (
"fmt"
"net/netip"
"slices"
"strings"
"sync"
"time"
@@ -36,7 +35,6 @@ import (
const (
reasonConnectorCreationFailed = "ConnectorCreationFailed"
reasonConnectorCreating = "ConnectorCreating"
reasonConnectorCreated = "ConnectorCreated"
reasonConnectorInvalid = "ConnectorInvalid"
@@ -115,7 +113,7 @@ func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Reque
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) {
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)
}
@@ -136,24 +134,17 @@ func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Reque
}
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 {
reason := reasonConnectorCreationFailed
logger.Errorf("error creating Connector resources: %w", err)
message := fmt.Sprintf(messageConnectorCreationFailed, err)
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
reason = reasonConnectorCreating
message = fmt.Sprintf("optimistic lock error, retrying: %s", err)
err = nil
logger.Info(message)
} else {
a.recorder.Eventf(cn, corev1.EventTypeWarning, reason, message)
}
return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionFalse, reason, message)
a.recorder.Eventf(cn, corev1.EventTypeWarning, reasonConnectorCreationFailed, message)
return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionFalse, reasonConnectorCreationFailed, message)
}
logger.Info("Connector resources synced")
@@ -198,7 +189,6 @@ func (a *ConnectorReconciler) maybeProvisionConnector(ctx context.Context, logge
isExitNode: cn.Spec.ExitNode,
},
ProxyClassName: proxyClass,
proxyType: proxyTypeConnector,
}
if cn.Spec.SubnetRouter != nil && len(cn.Spec.SubnetRouter.AdvertiseRoutes) > 0 {
@@ -243,27 +233,27 @@ func (a *ConnectorReconciler) maybeProvisionConnector(ctx context.Context, logge
return err
}
dev, err := a.ssr.DeviceInfo(ctx, crl, logger)
_, tsHost, ips, err := a.ssr.DeviceInfo(ctx, crl)
if err != nil {
return err
}
if dev == nil || dev.hostname == "" {
logger.Debugf("no Tailscale hostname known yet, waiting for Connector Pod to finish auth")
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 = dev.ips
cn.Status.Hostname = dev.hostname
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"), proxyTypeConnector); err != nil {
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")

View File

@@ -79,8 +79,8 @@ func TestConnector(t *testing.T) {
subnetRoutes: "10.40.0.0/14",
app: kubetypes.AppConnector,
}
expectEqual(t, fc, expectedSecret(t, fc, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
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"
@@ -106,7 +106,7 @@ func TestConnector(t *testing.T) {
opts.subnetRoutes = "10.40.0.0/14,10.44.0.0/20"
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
// Remove a route.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
@@ -114,7 +114,7 @@ func TestConnector(t *testing.T) {
})
opts.subnetRoutes = "10.44.0.0/20"
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
// Remove the subnet router.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
@@ -122,7 +122,7 @@ func TestConnector(t *testing.T) {
})
opts.subnetRoutes = ""
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
// Re-add the subnet router.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
@@ -132,7 +132,7 @@ func TestConnector(t *testing.T) {
})
opts.subnetRoutes = "10.44.0.0/20"
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
// Delete the Connector.
if err = fc.Delete(context.Background(), cn); err != nil {
@@ -175,8 +175,8 @@ func TestConnector(t *testing.T) {
hostname: "test-connector",
app: kubetypes.AppConnector,
}
expectEqual(t, fc, expectedSecret(t, fc, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
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) {
@@ -184,7 +184,7 @@ func TestConnector(t *testing.T) {
})
opts.isExitNode = true
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
// Delete the Connector.
if err = fc.Delete(context.Background(), cn); err != nil {
@@ -203,7 +203,7 @@ func TestConnectorWithProxyClass(t *testing.T) {
pc := &tsapi.ProxyClass{
ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"},
Spec: tsapi.ProxyClassSpec{StatefulSet: &tsapi.StatefulSet{
Labels: tsapi.Labels{"foo": "bar"},
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"}}}},
}
@@ -261,8 +261,8 @@ func TestConnectorWithProxyClass(t *testing.T) {
subnetRoutes: "10.40.0.0/14",
app: kubetypes.AppConnector,
}
expectEqual(t, fc, expectedSecret(t, fc, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
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
@@ -271,7 +271,7 @@ func TestConnectorWithProxyClass(t *testing.T) {
conn.Spec.ProxyClass = "custom-metadata"
})
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
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
@@ -286,7 +286,7 @@ func TestConnectorWithProxyClass(t *testing.T) {
})
opts.proxyClass = pc.Name
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
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
@@ -296,7 +296,7 @@ func TestConnectorWithProxyClass(t *testing.T) {
})
opts.proxyClass = ""
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
}
func TestConnectorWithAppConnector(t *testing.T) {
@@ -351,8 +351,8 @@ func TestConnectorWithAppConnector(t *testing.T) {
app: kubetypes.AppConnector,
isAppConnector: true,
}
expectEqual(t, fc, expectedSecret(t, fc, opts))
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation, removeResourceReqs)
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")
@@ -364,7 +364,7 @@ func TestConnectorWithAppConnector(t *testing.T) {
Reason: reasonConnectorCreated,
Message: reasonConnectorCreated,
}}
expectEqual(t, fc, cn)
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) {
@@ -379,7 +379,7 @@ func TestConnectorWithAppConnector(t *testing.T) {
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)
expectEqual(t, fc, cn, nil)
// 3. Connector with valid app connnector routes becomes ready
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {

View File

@@ -9,6 +9,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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
@@ -30,12 +31,10 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/context from github.com/aws/aws-sdk-go-v2/aws/retry+
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/middleware from github.com/aws/aws-sdk-go-v2/service/sso+
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
@@ -70,17 +69,16 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/metrics from github.com/aws/aws-sdk-go-v2/aws/retry+
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/tracing from github.com/aws/aws-sdk-go-v2/aws/middleware+
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
@@ -96,10 +94,8 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/fxamacker/cbor/v2 from tailscale.com/tka
github.com/gaissmai/bart from tailscale.com/net/ipset+
github.com/gaissmai/bart/internal/bitset from github.com/gaissmai/bart+
github.com/gaissmai/bart/internal/sparse from github.com/gaissmai/bart
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+
@@ -114,11 +110,11 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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+
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 tailscale.com/net/dnscache
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+
@@ -143,14 +139,15 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/v3 from tailscale.com/net/dns
L github.com/illarion/gonotify/v3/syscallf from github.com/illarion/gonotify/v3
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/feature/tap
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+
@@ -161,7 +158,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/feature/wakeonlan
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
@@ -175,7 +172,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
💣 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/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+
@@ -190,6 +187,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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
@@ -202,6 +200,10 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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+
@@ -223,6 +225,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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+
@@ -237,7 +240,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/local+
💣 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+
@@ -249,7 +252,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/editionssupport from google.golang.org/protobuf/reflect/protodesc
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
@@ -275,8 +277,8 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/evanphx/json-patch.v4 from k8s.io/client-go/testing
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
@@ -298,12 +300,12 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/transport/tcp
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/feature/tap+
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+
@@ -345,7 +347,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/v1alpha2 from k8s.io/client-go/applyconfigurations/coordination/v1alpha2+
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+
@@ -368,8 +369,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/v1alpha3 from k8s.io/client-go/applyconfigurations/resource/v1alpha3+
k8s.io/api/resource/v1beta1 from k8s.io/client-go/applyconfigurations/resource/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+
@@ -378,16 +378,14 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/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/meta/testrestmapper from k8s.io/client-go/testing
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/internalversion/validation from k8s.io/client-go/util/watchlist
💣 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+
@@ -399,9 +397,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/cbor from k8s.io/client-go/dynamic+
k8s.io/apimachinery/pkg/runtime/serializer/cbor/direct from k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1+
k8s.io/apimachinery/pkg/runtime/serializer/cbor/internal/modes from k8s.io/apimachinery/pkg/runtime/serializer/cbor+
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+
@@ -453,7 +448,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/v1alpha2 from k8s.io/client-go/kubernetes/typed/coordination/v1alpha2
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
@@ -478,8 +472,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/v1alpha3 from k8s.io/client-go/kubernetes/typed/resource/v1alpha3
k8s.io/client-go/applyconfigurations/resource/v1beta1 from k8s.io/client-go/kubernetes/typed/resource/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
@@ -489,80 +482,8 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/gentype from k8s.io/client-go/kubernetes/typed/admissionregistration/v1+
k8s.io/client-go/informers from k8s.io/client-go/tools/leaderelection
k8s.io/client-go/informers/admissionregistration from k8s.io/client-go/informers
k8s.io/client-go/informers/admissionregistration/v1 from k8s.io/client-go/informers/admissionregistration
k8s.io/client-go/informers/admissionregistration/v1alpha1 from k8s.io/client-go/informers/admissionregistration
k8s.io/client-go/informers/admissionregistration/v1beta1 from k8s.io/client-go/informers/admissionregistration
k8s.io/client-go/informers/apiserverinternal from k8s.io/client-go/informers
k8s.io/client-go/informers/apiserverinternal/v1alpha1 from k8s.io/client-go/informers/apiserverinternal
k8s.io/client-go/informers/apps from k8s.io/client-go/informers
k8s.io/client-go/informers/apps/v1 from k8s.io/client-go/informers/apps
k8s.io/client-go/informers/apps/v1beta1 from k8s.io/client-go/informers/apps
k8s.io/client-go/informers/apps/v1beta2 from k8s.io/client-go/informers/apps
k8s.io/client-go/informers/autoscaling from k8s.io/client-go/informers
k8s.io/client-go/informers/autoscaling/v1 from k8s.io/client-go/informers/autoscaling
k8s.io/client-go/informers/autoscaling/v2 from k8s.io/client-go/informers/autoscaling
k8s.io/client-go/informers/autoscaling/v2beta1 from k8s.io/client-go/informers/autoscaling
k8s.io/client-go/informers/autoscaling/v2beta2 from k8s.io/client-go/informers/autoscaling
k8s.io/client-go/informers/batch from k8s.io/client-go/informers
k8s.io/client-go/informers/batch/v1 from k8s.io/client-go/informers/batch
k8s.io/client-go/informers/batch/v1beta1 from k8s.io/client-go/informers/batch
k8s.io/client-go/informers/certificates from k8s.io/client-go/informers
k8s.io/client-go/informers/certificates/v1 from k8s.io/client-go/informers/certificates
k8s.io/client-go/informers/certificates/v1alpha1 from k8s.io/client-go/informers/certificates
k8s.io/client-go/informers/certificates/v1beta1 from k8s.io/client-go/informers/certificates
k8s.io/client-go/informers/coordination from k8s.io/client-go/informers
k8s.io/client-go/informers/coordination/v1 from k8s.io/client-go/informers/coordination
k8s.io/client-go/informers/coordination/v1alpha2 from k8s.io/client-go/informers/coordination
k8s.io/client-go/informers/coordination/v1beta1 from k8s.io/client-go/informers/coordination
k8s.io/client-go/informers/core from k8s.io/client-go/informers
k8s.io/client-go/informers/core/v1 from k8s.io/client-go/informers/core
k8s.io/client-go/informers/discovery from k8s.io/client-go/informers
k8s.io/client-go/informers/discovery/v1 from k8s.io/client-go/informers/discovery
k8s.io/client-go/informers/discovery/v1beta1 from k8s.io/client-go/informers/discovery
k8s.io/client-go/informers/events from k8s.io/client-go/informers
k8s.io/client-go/informers/events/v1 from k8s.io/client-go/informers/events
k8s.io/client-go/informers/events/v1beta1 from k8s.io/client-go/informers/events
k8s.io/client-go/informers/extensions from k8s.io/client-go/informers
k8s.io/client-go/informers/extensions/v1beta1 from k8s.io/client-go/informers/extensions
k8s.io/client-go/informers/flowcontrol from k8s.io/client-go/informers
k8s.io/client-go/informers/flowcontrol/v1 from k8s.io/client-go/informers/flowcontrol
k8s.io/client-go/informers/flowcontrol/v1beta1 from k8s.io/client-go/informers/flowcontrol
k8s.io/client-go/informers/flowcontrol/v1beta2 from k8s.io/client-go/informers/flowcontrol
k8s.io/client-go/informers/flowcontrol/v1beta3 from k8s.io/client-go/informers/flowcontrol
k8s.io/client-go/informers/internalinterfaces from k8s.io/client-go/informers+
k8s.io/client-go/informers/networking from k8s.io/client-go/informers
k8s.io/client-go/informers/networking/v1 from k8s.io/client-go/informers/networking
k8s.io/client-go/informers/networking/v1alpha1 from k8s.io/client-go/informers/networking
k8s.io/client-go/informers/networking/v1beta1 from k8s.io/client-go/informers/networking
k8s.io/client-go/informers/node from k8s.io/client-go/informers
k8s.io/client-go/informers/node/v1 from k8s.io/client-go/informers/node
k8s.io/client-go/informers/node/v1alpha1 from k8s.io/client-go/informers/node
k8s.io/client-go/informers/node/v1beta1 from k8s.io/client-go/informers/node
k8s.io/client-go/informers/policy from k8s.io/client-go/informers
k8s.io/client-go/informers/policy/v1 from k8s.io/client-go/informers/policy
k8s.io/client-go/informers/policy/v1beta1 from k8s.io/client-go/informers/policy
k8s.io/client-go/informers/rbac from k8s.io/client-go/informers
k8s.io/client-go/informers/rbac/v1 from k8s.io/client-go/informers/rbac
k8s.io/client-go/informers/rbac/v1alpha1 from k8s.io/client-go/informers/rbac
k8s.io/client-go/informers/rbac/v1beta1 from k8s.io/client-go/informers/rbac
k8s.io/client-go/informers/resource from k8s.io/client-go/informers
k8s.io/client-go/informers/resource/v1alpha3 from k8s.io/client-go/informers/resource
k8s.io/client-go/informers/resource/v1beta1 from k8s.io/client-go/informers/resource
k8s.io/client-go/informers/scheduling from k8s.io/client-go/informers
k8s.io/client-go/informers/scheduling/v1 from k8s.io/client-go/informers/scheduling
k8s.io/client-go/informers/scheduling/v1alpha1 from k8s.io/client-go/informers/scheduling
k8s.io/client-go/informers/scheduling/v1beta1 from k8s.io/client-go/informers/scheduling
k8s.io/client-go/informers/storage from k8s.io/client-go/informers
k8s.io/client-go/informers/storage/v1 from k8s.io/client-go/informers/storage
k8s.io/client-go/informers/storage/v1alpha1 from k8s.io/client-go/informers/storage
k8s.io/client-go/informers/storage/v1beta1 from k8s.io/client-go/informers/storage
k8s.io/client-go/informers/storagemigration from k8s.io/client-go/informers
k8s.io/client-go/informers/storagemigration/v1alpha1 from k8s.io/client-go/informers/storagemigration
k8s.io/client-go/kubernetes from k8s.io/client-go/tools/leaderelection/resourcelock+
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
@@ -586,7 +507,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/v1alpha2 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
@@ -609,8 +529,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/v1alpha3 from k8s.io/client-go/kubernetes
k8s.io/client-go/kubernetes/typed/resource/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
@@ -618,56 +537,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/listers from k8s.io/client-go/listers/admissionregistration/v1+
k8s.io/client-go/listers/admissionregistration/v1 from k8s.io/client-go/informers/admissionregistration/v1
k8s.io/client-go/listers/admissionregistration/v1alpha1 from k8s.io/client-go/informers/admissionregistration/v1alpha1
k8s.io/client-go/listers/admissionregistration/v1beta1 from k8s.io/client-go/informers/admissionregistration/v1beta1
k8s.io/client-go/listers/apiserverinternal/v1alpha1 from k8s.io/client-go/informers/apiserverinternal/v1alpha1
k8s.io/client-go/listers/apps/v1 from k8s.io/client-go/informers/apps/v1
k8s.io/client-go/listers/apps/v1beta1 from k8s.io/client-go/informers/apps/v1beta1
k8s.io/client-go/listers/apps/v1beta2 from k8s.io/client-go/informers/apps/v1beta2
k8s.io/client-go/listers/autoscaling/v1 from k8s.io/client-go/informers/autoscaling/v1
k8s.io/client-go/listers/autoscaling/v2 from k8s.io/client-go/informers/autoscaling/v2
k8s.io/client-go/listers/autoscaling/v2beta1 from k8s.io/client-go/informers/autoscaling/v2beta1
k8s.io/client-go/listers/autoscaling/v2beta2 from k8s.io/client-go/informers/autoscaling/v2beta2
k8s.io/client-go/listers/batch/v1 from k8s.io/client-go/informers/batch/v1
k8s.io/client-go/listers/batch/v1beta1 from k8s.io/client-go/informers/batch/v1beta1
k8s.io/client-go/listers/certificates/v1 from k8s.io/client-go/informers/certificates/v1
k8s.io/client-go/listers/certificates/v1alpha1 from k8s.io/client-go/informers/certificates/v1alpha1
k8s.io/client-go/listers/certificates/v1beta1 from k8s.io/client-go/informers/certificates/v1beta1
k8s.io/client-go/listers/coordination/v1 from k8s.io/client-go/informers/coordination/v1
k8s.io/client-go/listers/coordination/v1alpha2 from k8s.io/client-go/informers/coordination/v1alpha2
k8s.io/client-go/listers/coordination/v1beta1 from k8s.io/client-go/informers/coordination/v1beta1
k8s.io/client-go/listers/core/v1 from k8s.io/client-go/informers/core/v1
k8s.io/client-go/listers/discovery/v1 from k8s.io/client-go/informers/discovery/v1
k8s.io/client-go/listers/discovery/v1beta1 from k8s.io/client-go/informers/discovery/v1beta1
k8s.io/client-go/listers/events/v1 from k8s.io/client-go/informers/events/v1
k8s.io/client-go/listers/events/v1beta1 from k8s.io/client-go/informers/events/v1beta1
k8s.io/client-go/listers/extensions/v1beta1 from k8s.io/client-go/informers/extensions/v1beta1
k8s.io/client-go/listers/flowcontrol/v1 from k8s.io/client-go/informers/flowcontrol/v1
k8s.io/client-go/listers/flowcontrol/v1beta1 from k8s.io/client-go/informers/flowcontrol/v1beta1
k8s.io/client-go/listers/flowcontrol/v1beta2 from k8s.io/client-go/informers/flowcontrol/v1beta2
k8s.io/client-go/listers/flowcontrol/v1beta3 from k8s.io/client-go/informers/flowcontrol/v1beta3
k8s.io/client-go/listers/networking/v1 from k8s.io/client-go/informers/networking/v1
k8s.io/client-go/listers/networking/v1alpha1 from k8s.io/client-go/informers/networking/v1alpha1
k8s.io/client-go/listers/networking/v1beta1 from k8s.io/client-go/informers/networking/v1beta1
k8s.io/client-go/listers/node/v1 from k8s.io/client-go/informers/node/v1
k8s.io/client-go/listers/node/v1alpha1 from k8s.io/client-go/informers/node/v1alpha1
k8s.io/client-go/listers/node/v1beta1 from k8s.io/client-go/informers/node/v1beta1
k8s.io/client-go/listers/policy/v1 from k8s.io/client-go/informers/policy/v1
k8s.io/client-go/listers/policy/v1beta1 from k8s.io/client-go/informers/policy/v1beta1
k8s.io/client-go/listers/rbac/v1 from k8s.io/client-go/informers/rbac/v1
k8s.io/client-go/listers/rbac/v1alpha1 from k8s.io/client-go/informers/rbac/v1alpha1
k8s.io/client-go/listers/rbac/v1beta1 from k8s.io/client-go/informers/rbac/v1beta1
k8s.io/client-go/listers/resource/v1alpha3 from k8s.io/client-go/informers/resource/v1alpha3
k8s.io/client-go/listers/resource/v1beta1 from k8s.io/client-go/informers/resource/v1beta1
k8s.io/client-go/listers/scheduling/v1 from k8s.io/client-go/informers/scheduling/v1
k8s.io/client-go/listers/scheduling/v1alpha1 from k8s.io/client-go/informers/scheduling/v1alpha1
k8s.io/client-go/listers/scheduling/v1beta1 from k8s.io/client-go/informers/scheduling/v1beta1
k8s.io/client-go/listers/storage/v1 from k8s.io/client-go/informers/storage/v1
k8s.io/client-go/listers/storage/v1alpha1 from k8s.io/client-go/informers/storage/v1alpha1
k8s.io/client-go/listers/storage/v1beta1 from k8s.io/client-go/informers/storage/v1beta1
k8s.io/client-go/listers/storagemigration/v1alpha1 from k8s.io/client-go/informers/storagemigration/v1alpha1
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+
@@ -679,7 +548,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/testing from k8s.io/client-go/gentype
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
@@ -696,14 +564,11 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/apply from k8s.io/client-go/dynamic+
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/consistencydetector from k8s.io/client-go/dynamic+
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/watchlist from k8s.io/client-go/dynamic+
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
@@ -724,12 +589,11 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/golang-lru from k8s.io/utils/lru
k8s.io/utils/internal/third_party/forked/golang/net from k8s.io/utils/net
k8s.io/utils/lru from k8s.io/client-go/tools/record
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+
@@ -762,12 +626,12 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/admission/metrics from sigs.k8s.io/controller-runtime/pkg/webhook/admission
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+
@@ -778,12 +642,11 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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+
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/local from tailscale.com/client/tailscale+
tailscale.com/client/tailscale from tailscale.com/cmd/k8s-operator+
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+
@@ -800,27 +663,19 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/local+
tailscale.com/envknob from tailscale.com/client/local+
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/feature from tailscale.com/feature/wakeonlan+
tailscale.com/feature/capture from tailscale.com/feature/condregister
tailscale.com/feature/condregister from tailscale.com/tsnet
L tailscale.com/feature/tap from tailscale.com/feature/condregister
tailscale.com/feature/wakeonlan from tailscale.com/feature/condregister
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/client/tailscale from tailscale.com/cmd/k8s-operator
tailscale.com/internal/noiseconn from tailscale.com/control/controlclient
tailscale.com/ipn from tailscale.com/client/local+
tailscale.com/ipn/auditlog from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn from tailscale.com/client/tailscale+
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/ipn/desktop 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/local+
tailscale.com/ipn/localapi from tailscale.com/tsnet+
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
@@ -845,7 +700,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/bakedroots from tailscale.com/net/tlsdial+
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+
@@ -866,7 +720,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
💣 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/local+
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+
@@ -884,19 +738,17 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
💣 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/local+
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/local+
💣 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/local+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/taildrop from tailscale.com/ipn/ipnlocal+
tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
tailscale.com/tempfork/httprec from tailscale.com/control/controlclient
tailscale.com/tka from tailscale.com/client/local+
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+
@@ -905,11 +757,10 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/bools from tailscale.com/tsnet
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/local+
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+
@@ -922,7 +773,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/local+
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+
@@ -967,6 +818,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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+
@@ -978,6 +830,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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+
@@ -993,21 +846,18 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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 golang.org/x/crypto/ssh/internal/bcrypt_pbkdf
golang.org/x/crypto/chacha20 from golang.org/x/crypto/ssh+
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 golang.org/x/crypto/ssh+
golang.org/x/crypto/hkdf from tailscale.com/control/controlbase
golang.org/x/crypto/internal/alias from golang.org/x/crypto/chacha20+
golang.org/x/crypto/internal/poly1305 from golang.org/x/crypto/chacha20poly1305+
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+
LD golang.org/x/crypto/ssh from tailscale.com/ipn/ipnlocal
LD golang.org/x/crypto/ssh/internal/bcrypt_pbkdf from golang.org/x/crypto/ssh
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+
@@ -1020,10 +870,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/internal/httpcommon from golang.org/x/net/http2
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
golang.org/x/net/internal/socks from golang.org/x/net/proxy
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
@@ -1033,7 +879,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/tailscale/certstore+
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+
@@ -1056,7 +902,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
container/list from crypto/tls+
context from crypto/tls+
crypto from crypto/ecdh+
crypto/aes from crypto/internal/hpke+
crypto/aes from crypto/ecdsa+
crypto/cipher from crypto/aes+
crypto/des from crypto/tls+
crypto/dsa from crypto/x509+
@@ -1065,62 +911,22 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
crypto/ed25519 from crypto/tls+
crypto/elliptic from crypto/ecdsa+
crypto/hmac from crypto/tls+
crypto/internal/boring from crypto/aes+
crypto/internal/boring/bbig from crypto/ecdsa+
crypto/internal/boring/sig from crypto/internal/boring
crypto/internal/entropy from crypto/internal/fips140/drbg
crypto/internal/fips140 from crypto/internal/fips140/aes+
crypto/internal/fips140/aes from crypto/aes+
crypto/internal/fips140/aes/gcm from crypto/cipher+
crypto/internal/fips140/alias from crypto/cipher+
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
crypto/internal/fips140/check from crypto/internal/fips140/aes+
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
crypto/internal/fips140/ecdh from crypto/ecdh
crypto/internal/fips140/ecdsa from crypto/ecdsa
crypto/internal/fips140/ed25519 from crypto/ed25519
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
crypto/internal/fips140/hmac from crypto/hmac+
crypto/internal/fips140/mlkem from crypto/tls
crypto/internal/fips140/nistec from crypto/elliptic+
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
crypto/internal/fips140/rsa from crypto/rsa
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
crypto/internal/fips140/sha3 from crypto/internal/fips140/hmac+
crypto/internal/fips140/sha512 from crypto/internal/fips140/ecdsa+
crypto/internal/fips140/subtle from crypto/internal/fips140/aes+
crypto/internal/fips140/tls12 from crypto/tls
crypto/internal/fips140/tls13 from crypto/tls
crypto/internal/fips140deps/byteorder from crypto/internal/fips140/aes+
crypto/internal/fips140deps/cpu from crypto/internal/fips140/aes+
crypto/internal/fips140deps/godebug from crypto/internal/fips140+
crypto/internal/fips140hash from crypto/ecdsa+
crypto/internal/fips140only from crypto/cipher+
crypto/internal/hpke from crypto/tls
crypto/internal/impl from crypto/internal/fips140/aes+
crypto/internal/randutil from crypto/dsa+
crypto/internal/sysrand from crypto/internal/entropy+
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/sha3 from crypto/internal/fips140hash
crypto/sha512 from crypto/ecdsa+
crypto/subtle from crypto/cipher+
crypto/subtle from crypto/aes+
crypto/tls from github.com/aws/aws-sdk-go-v2/aws/transport/http+
crypto/tls/internal/fips140tls from crypto/tls
crypto/x509 from crypto/tls+
D crypto/x509/internal/macos from crypto/x509
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 github.com/tailscale/web-client-prebuilt+
embed from crypto/internal/nistec+
encoding from encoding/gob+
encoding/asn1 from crypto/x509+
encoding/base32 from github.com/fxamacker/cbor/v2+
@@ -1150,48 +956,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
hash/maphash from go4.org/mem
html from html/template+
html/template from github.com/gorilla/csrf
internal/abi from crypto/x509/internal/macos+
internal/asan from syscall+
internal/bisect from internal/godebug
internal/bytealg from bytes+
internal/byteorder from crypto/cipher+
internal/chacha8rand from math/rand/v2+
internal/coverage/rtcov from runtime
internal/cpu from crypto/internal/fips140deps/cpu+
internal/filepathlite from os+
internal/fmtsort from fmt+
internal/goarch from crypto/internal/fips140deps/cpu+
internal/godebug from archive/tar+
internal/godebugs from internal/godebug+
internal/goexperiment from runtime+
internal/goos from crypto/x509+
internal/itoa from internal/poll+
internal/lazyregexp from go/doc
internal/msan from syscall+
internal/nettrace from net+
internal/oserror from io/fs+
internal/poll from net+
internal/profile from net/http/pprof
internal/profilerecord from runtime+
internal/race from internal/poll+
internal/reflectlite from context+
internal/runtime/atomic from internal/runtime/exithook+
internal/runtime/exithook from runtime
internal/runtime/maps from reflect+
internal/runtime/math from internal/runtime/maps+
internal/runtime/sys from crypto/subtle+
L internal/runtime/syscall from runtime+
internal/saferio from debug/pe+
internal/singleflight from net
internal/stringslite from embed+
internal/sync from sync+
internal/syscall/execenv from os+
LD internal/syscall/unix from crypto/internal/sysrand+
W internal/syscall/windows from crypto/internal/sysrand+
W internal/syscall/windows/registry from mime+
W internal/syscall/windows/sysdll from internal/syscall/windows+
internal/testlog from os
internal/unsafeheader from internal/reflectlite+
io from archive/tar+
io/fs from archive/tar+
io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
@@ -1200,7 +964,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
log/internal from log+
log/slog from github.com/go-logr/logr+
log/slog/internal from log/slog
log/slog/internal/buffer from log/slog
maps from sigs.k8s.io/controller-runtime/pkg/predicate+
math from archive/tar+
math/big from crypto/dsa+
@@ -1212,15 +975,15 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
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/internal/ascii 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/internal/sysrand+
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+
@@ -1229,7 +992,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
reflect from archive/tar+
regexp from github.com/aws/aws-sdk-go-v2/internal/endpoints+
regexp/syntax from regexp
runtime from archive/tar+
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+
@@ -1249,5 +1011,3 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
unicode/utf16 from crypto/x509+
unicode/utf8 from bufio+
unique from net/netip
unsafe from bytes+
weak from unique

View File

@@ -35,13 +35,9 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
- name: oauth
{{- with .Values.oauthSecretVolume }}
{{- toYaml . | nindent 10 }}
{{- else }}
secret:
secretName: operator-oauth
{{- end }}
- name: oauth
secret:
secretName: operator-oauth
containers:
- name: operator
{{- with .Values.operatorConfig.securityContext }}
@@ -85,14 +81,6 @@ spec:
- 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 }}

View File

@@ -1,4 +1,3 @@
{{- if .Values.ingressClass.enabled }}
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
@@ -7,4 +6,3 @@ metadata:
spec:
controller: tailscale.com/ts-ingress # controller name currently can not be changed
# parameters: {} # currently no parameters are supported
{{- end }}

View File

@@ -6,10 +6,6 @@ kind: ServiceAccount
metadata:
name: operator
namespace: {{ .Release.Namespace }}
{{- with .Values.operatorConfig.serviceAccountAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
@@ -34,10 +30,6 @@ rules:
- apiGroups: ["tailscale.com"]
resources: ["recorders", "recorders/status"]
verbs: ["get", "list", "watch", "update"]
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["get", "list", "watch"]
resourceNames: ["servicemonitors.monitoring.coreos.com"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
@@ -63,10 +55,7 @@ rules:
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get","list","watch", "update"]
- apiGroups: [""]
resources: ["pods/status"]
verbs: ["update"]
verbs: ["get","list","watch"]
- apiGroups: ["apps"]
resources: ["statefulsets", "deployments"]
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
@@ -76,9 +65,6 @@ rules:
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["roles", "rolebindings"]
verbs: ["get", "create", "patch", "update", "list", "watch"]
- apiGroups: ["monitoring.coreos.com"]
resources: ["servicemonitors"]
verbs: ["get", "list", "update", "create", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding

View File

@@ -3,26 +3,11 @@
# 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 or oauthSecretVolume needs to be adjusted.
# This block will be overridden by oauthSecretVolume, if set.
# operator-oauth must be precreated.
oauth: {}
# clientId: ""
# clientSecret: ""
# Secret volume.
# If set it defines the volume the oauth secrets will be mounted from.
# The volume needs to contain two files named `client_id` and `client_secret`.
# If unset the volume will reference the Secret named operator-oauth.
# This block will override the oauth block.
oauthSecretVolume: {}
# csi:
# driver: secrets-store.csi.k8s.io
# readOnly: true
# volumeAttributes:
# secretProviderClass: tailscale-oauth
#
## NAME is pre-defined!
# 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.
@@ -55,9 +40,6 @@ operatorConfig:
podAnnotations: {}
podLabels: {}
serviceAccountAnnotations: {}
# eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/tailscale-operator-role
tolerations: []
affinity: {}
@@ -72,9 +54,6 @@ operatorConfig:
# - 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.

View File

@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: connectors.tailscale.com
spec:
group: tailscale.com

View File

@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: dnsconfigs.tailscale.com
spec:
group: tailscale.com

View File

@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: proxyclasses.tailscale.com
spec:
group: tailscale.com
@@ -73,45 +73,9 @@ spec:
enable:
description: |-
Setting enable to true will make the proxy serve Tailscale metrics
at <pod-ip>:9002/metrics.
A metrics Service named <proxy-statefulset>-metrics will also be created in the operator's namespace and will
serve the metrics at <service-ip>:9002/metrics.
In 1.78.x and 1.80.x, this field also serves as the default value for
.spec.statefulSet.pod.tailscaleContainer.debug.enable. From 1.82.0, both
fields will independently default to false.
at <pod-ip>:9001/debug/metrics.
Defaults to false.
type: boolean
serviceMonitor:
description: |-
Enable to create a Prometheus ServiceMonitor for scraping the proxy's Tailscale metrics.
The ServiceMonitor will select the metrics Service that gets created when metrics are enabled.
The ingested metrics for each Service monitor will have labels to identify the proxy:
ts_proxy_type: ingress_service|ingress_resource|connector|proxygroup
ts_proxy_parent_name: name of the parent resource (i.e name of the Connector, Tailscale Ingress, Tailscale Service or ProxyGroup)
ts_proxy_parent_namespace: namespace of the parent resource (if the parent resource is not cluster scoped)
job: ts_<proxy type>_[<parent namespace>]_<parent_name>
type: object
required:
- enable
properties:
enable:
description: If Enable is set to true, a Prometheus ServiceMonitor will be created. Enable can only be set to true if metrics are enabled.
type: boolean
labels:
description: |-
Labels to add to the ServiceMonitor.
Labels must be valid Kubernetes labels.
https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
type: object
additionalProperties:
type: string
maxLength: 63
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
x-kubernetes-validations:
- rule: '!(has(self.serviceMonitor) && self.serviceMonitor.enable && !self.enable)'
message: ServiceMonitor can only be enabled if metrics are enabled
statefulSet:
description: |-
Configuration parameters for the proxy's StatefulSet. Tailscale
@@ -143,8 +107,6 @@ spec:
type: object
additionalProperties:
type: string
maxLength: 63
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
pod:
description: Configuration for the proxy Pod.
type: object
@@ -428,7 +390,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -443,7 +405,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -600,7 +562,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -615,7 +577,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -773,7 +735,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -788,7 +750,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -945,7 +907,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -960,7 +922,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -1074,8 +1036,6 @@ spec:
type: object
additionalProperties:
type: string
maxLength: 63
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
nodeName:
description: |-
Proxy Pod's node name.
@@ -1174,32 +1134,6 @@ spec:
Note that this field cannot be set when spec.os.name is windows.
type: integer
format: int64
seLinuxChangePolicy:
description: |-
seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod.
It has no effect on nodes that do not support SELinux or to volumes does not support SELinux.
Valid values are "MountOption" and "Recursive".
"Recursive" means relabeling of all files on all Pod volumes by the container runtime.
This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node.
"MountOption" mounts all eligible Pod volumes with `-o context` mount option.
This requires all Pods that share the same volume to use the same SELinux label.
It is not possible to share the same volume among privileged and unprivileged Pods.
Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes
whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their
CSIDriver instance. Other volumes are always re-labelled recursively.
"MountOption" value is allowed only when SELinuxMount feature gate is enabled.
If not specified and SELinuxMount feature gate is enabled, "MountOption" is used.
If not specified and SELinuxMount feature gate is disabled, "MountOption" is used for ReadWriteOncePod volumes
and "Recursive" for all other volumes.
This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers.
All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state.
Note that this field cannot be set when spec.os.name is windows.
type: string
seLinuxOptions:
description: |-
The SELinux context to be applied to all containers.
@@ -1248,28 +1182,18 @@ spec:
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 and fsGroup (if specified). If
the SupplementalGroupsPolicy feature is enabled, the
supplementalGroupsPolicy field determines whether these are in addition
to or instead of any group memberships defined in the container image.
If unspecified, no additional groups are added, though group memberships
defined in the container image may still be used, depending on the
supplementalGroupsPolicy field.
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
supplementalGroupsPolicy:
description: |-
Defines how supplemental groups of the first container processes are calculated.
Valid values are "Merge" and "Strict". If not specified, "Merge" is used.
(Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled
and the container runtime must implement support for this feature.
Note that this field cannot be set when spec.os.name is windows.
type: string
sysctls:
description: |-
Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported
@@ -1325,25 +1249,6 @@ spec:
description: Configuration for the proxy container running tailscale.
type: object
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
type: object
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
env:
description: |-
List of environment variables to set in the container.
@@ -1425,12 +1330,6 @@ spec:
the Pod where this field is used. It makes that resource available
inside a container.
type: string
request:
description: |-
Request is the name chosen for a request in the referenced claim.
If empty, everything from the claim is made available, otherwise
only the result of this request.
type: string
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
@@ -1461,12 +1360,11 @@ spec:
securityContext:
description: |-
Container security context.
Security context specified here will override the security context set by the operator.
By default the operator sets the Tailscale container and the Tailscale init container to privileged
for proxies created for Tailscale ingress and egress Service, Connector and ProxyGroup.
You can reduce the permissions of the Tailscale container to cap NET_ADMIN by
installing device plugin in your cluster and configuring the proxies tun device to be created
by the device plugin, see https://github.com/tailscale/tailscale/issues/10814#issuecomment-2479977752
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:
@@ -1535,7 +1433,7 @@ spec:
procMount:
description: |-
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
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.
@@ -1655,25 +1553,6 @@ spec:
description: Configuration for the proxy init container that enables forwarding.
type: object
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
type: object
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
env:
description: |-
List of environment variables to set in the container.
@@ -1755,12 +1634,6 @@ spec:
the Pod where this field is used. It makes that resource available
inside a container.
type: string
request:
description: |-
Request is the name chosen for a request in the referenced claim.
If empty, everything from the claim is made available, otherwise
only the result of this request.
type: string
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
@@ -1791,12 +1664,11 @@ spec:
securityContext:
description: |-
Container security context.
Security context specified here will override the security context set by the operator.
By default the operator sets the Tailscale container and the Tailscale init container to privileged
for proxies created for Tailscale ingress and egress Service, Connector and ProxyGroup.
You can reduce the permissions of the Tailscale container to cap NET_ADMIN by
installing device plugin in your cluster and configuring the proxies tun device to be created
by the device plugin, see https://github.com/tailscale/tailscale/issues/10814#issuecomment-2479977752
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:
@@ -1865,7 +1737,7 @@ spec:
procMount:
description: |-
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
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.

View File

@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: proxygroups.tailscale.com
spec:
group: tailscale.com
@@ -20,24 +20,9 @@ spec:
jsonPath: .status.conditions[?(@.type == "ProxyGroupReady")].reason
name: Status
type: string
- description: ProxyGroup type.
jsonPath: .spec.type
name: Type
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
ProxyGroup defines a set of Tailscale devices that will act as proxies.
Currently only egress ProxyGroups are supported.
Use the tailscale.com/proxy-group annotation on a Service to specify that
the egress proxy should be implemented by a ProxyGroup instead of a single
dedicated proxy. In addition to running a highly available set of proxies,
ProxyGroup also allows for serving many annotated Services from a single
set of proxies to minimise resource consumption.
More info: https://tailscale.com/kb/1438/kubernetes-operator-cluster-egress
type: object
required:
- spec
@@ -88,7 +73,6 @@ spec:
Defaults to 2.
type: integer
format: int32
minimum: 0
tags:
description: |-
Tags that the Tailscale devices will be tagged with. Defaults to [tag:k8s].
@@ -102,16 +86,10 @@ spec:
type: string
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
type:
description: |-
Type of the ProxyGroup proxies. Supported types are egress and ingress.
Type is immutable once a ProxyGroup is created.
description: Type of the ProxyGroup proxies. Currently the only supported type is egress.
type: string
enum:
- egress
- ingress
x-kubernetes-validations:
- rule: self == oldSelf
message: ProxyGroup type is immutable
status:
description: |-
ProxyGroupStatus describes the status of the ProxyGroup resources. This is

View File

@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: recorders.tailscale.com
spec:
group: tailscale.com
@@ -27,12 +27,6 @@ spec:
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
Recorder defines a tsrecorder device for recording SSH sessions. By default,
it will store recordings in a local ephemeral volume. If you want to persist
recordings, you can configure an S3-compatible API for storage.
More info: https://tailscale.com/kb/1484/kubernetes-operator-deploying-tsrecorder
type: object
required:
- spec
@@ -372,7 +366,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -387,7 +381,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -544,7 +538,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -559,7 +553,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -717,7 +711,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -732,7 +726,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -889,7 +883,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -904,7 +898,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
type: array
items:
type: string
@@ -1066,12 +1060,6 @@ spec:
the Pod where this field is used. It makes that resource available
inside a container.
type: string
request:
description: |-
Request is the name chosen for a request in the referenced claim.
If empty, everything from the claim is made available, otherwise
only the result of this request.
type: string
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
@@ -1171,7 +1159,7 @@ spec:
procMount:
description: |-
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
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.
@@ -1407,32 +1395,6 @@ spec:
Note that this field cannot be set when spec.os.name is windows.
type: integer
format: int64
seLinuxChangePolicy:
description: |-
seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod.
It has no effect on nodes that do not support SELinux or to volumes does not support SELinux.
Valid values are "MountOption" and "Recursive".
"Recursive" means relabeling of all files on all Pod volumes by the container runtime.
This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node.
"MountOption" mounts all eligible Pod volumes with `-o context` mount option.
This requires all Pods that share the same volume to use the same SELinux label.
It is not possible to share the same volume among privileged and unprivileged Pods.
Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes
whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their
CSIDriver instance. Other volumes are always re-labelled recursively.
"MountOption" value is allowed only when SELinuxMount feature gate is enabled.
If not specified and SELinuxMount feature gate is enabled, "MountOption" is used.
If not specified and SELinuxMount feature gate is disabled, "MountOption" is used for ReadWriteOncePod volumes
and "Recursive" for all other volumes.
This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers.
All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state.
Note that this field cannot be set when spec.os.name is windows.
type: string
seLinuxOptions:
description: |-
The SELinux context to be applied to all containers.
@@ -1481,28 +1443,18 @@ spec:
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 and fsGroup (if specified). If
the SupplementalGroupsPolicy feature is enabled, the
supplementalGroupsPolicy field determines whether these are in addition
to or instead of any group memberships defined in the container image.
If unspecified, no additional groups are added, though group memberships
defined in the container image may still be used, depending on the
supplementalGroupsPolicy field.
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
supplementalGroupsPolicy:
description: |-
Defines how supplemental groups of the first container processes are calculated.
Valid values are "Merge" and "Strict". If not specified, "Merge" is used.
(Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled
and the container runtime must implement support for this feature.
Note that this field cannot be set when spec.os.name is windows.
type: string
sysctls:
description: |-
Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported

View File

@@ -31,7 +31,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: connectors.tailscale.com
spec:
group: tailscale.com
@@ -294,7 +294,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: dnsconfigs.tailscale.com
spec:
group: tailscale.com
@@ -476,7 +476,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: proxyclasses.tailscale.com
spec:
group: tailscale.com
@@ -540,48 +540,12 @@ spec:
enable:
description: |-
Setting enable to true will make the proxy serve Tailscale metrics
at <pod-ip>:9002/metrics.
A metrics Service named <proxy-statefulset>-metrics will also be created in the operator's namespace and will
serve the metrics at <service-ip>:9002/metrics.
In 1.78.x and 1.80.x, this field also serves as the default value for
.spec.statefulSet.pod.tailscaleContainer.debug.enable. From 1.82.0, both
fields will independently default to false.
at <pod-ip>:9001/debug/metrics.
Defaults to false.
type: boolean
serviceMonitor:
description: |-
Enable to create a Prometheus ServiceMonitor for scraping the proxy's Tailscale metrics.
The ServiceMonitor will select the metrics Service that gets created when metrics are enabled.
The ingested metrics for each Service monitor will have labels to identify the proxy:
ts_proxy_type: ingress_service|ingress_resource|connector|proxygroup
ts_proxy_parent_name: name of the parent resource (i.e name of the Connector, Tailscale Ingress, Tailscale Service or ProxyGroup)
ts_proxy_parent_namespace: namespace of the parent resource (if the parent resource is not cluster scoped)
job: ts_<proxy type>_[<parent namespace>]_<parent_name>
properties:
enable:
description: If Enable is set to true, a Prometheus ServiceMonitor will be created. Enable can only be set to true if metrics are enabled.
type: boolean
labels:
additionalProperties:
maxLength: 63
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
type: string
description: |-
Labels to add to the ServiceMonitor.
Labels must be valid Kubernetes labels.
https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
type: object
required:
- enable
type: object
required:
- enable
type: object
x-kubernetes-validations:
- message: ServiceMonitor can only be enabled if metrics are enabled
rule: '!(has(self.serviceMonitor) && self.serviceMonitor.enable && !self.enable)'
statefulSet:
description: |-
Configuration parameters for the proxy's StatefulSet. Tailscale
@@ -602,8 +566,6 @@ spec:
type: object
labels:
additionalProperties:
maxLength: 63
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
type: string
description: |-
Labels that will be added to the StatefulSet created for the proxy.
@@ -886,7 +848,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -901,7 +863,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -1062,7 +1024,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -1077,7 +1039,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -1231,7 +1193,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -1246,7 +1208,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -1407,7 +1369,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -1422,7 +1384,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -1534,8 +1496,6 @@ spec:
type: array
labels:
additionalProperties:
maxLength: 63
pattern: ^(([a-zA-Z0-9][-._a-zA-Z0-9]*)?[a-zA-Z0-9])?$
type: string
description: |-
Labels that will be added to the proxy Pod.
@@ -1641,32 +1601,6 @@ spec:
Note that this field cannot be set when spec.os.name is windows.
format: int64
type: integer
seLinuxChangePolicy:
description: |-
seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod.
It has no effect on nodes that do not support SELinux or to volumes does not support SELinux.
Valid values are "MountOption" and "Recursive".
"Recursive" means relabeling of all files on all Pod volumes by the container runtime.
This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node.
"MountOption" mounts all eligible Pod volumes with `-o context` mount option.
This requires all Pods that share the same volume to use the same SELinux label.
It is not possible to share the same volume among privileged and unprivileged Pods.
Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes
whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their
CSIDriver instance. Other volumes are always re-labelled recursively.
"MountOption" value is allowed only when SELinuxMount feature gate is enabled.
If not specified and SELinuxMount feature gate is enabled, "MountOption" is used.
If not specified and SELinuxMount feature gate is disabled, "MountOption" is used for ReadWriteOncePod volumes
and "Recursive" for all other volumes.
This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers.
All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state.
Note that this field cannot be set when spec.os.name is windows.
type: string
seLinuxOptions:
description: |-
The SELinux context to be applied to all containers.
@@ -1715,28 +1649,18 @@ spec:
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 and fsGroup (if specified). If
the SupplementalGroupsPolicy feature is enabled, the
supplementalGroupsPolicy field determines whether these are in addition
to or instead of any group memberships defined in the container image.
If unspecified, no additional groups are added, though group memberships
defined in the container image may still be used, depending on the
supplementalGroupsPolicy field.
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
supplementalGroupsPolicy:
description: |-
Defines how supplemental groups of the first container processes are calculated.
Valid values are "Merge" and "Strict". If not specified, "Merge" is used.
(Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled
and the container runtime must implement support for this feature.
Note that this field cannot be set when spec.os.name is windows.
type: string
sysctls:
description: |-
Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported
@@ -1792,25 +1716,6 @@ spec:
tailscaleContainer:
description: Configuration for the proxy container running tailscale.
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
type: object
env:
description: |-
List of environment variables to set in the container.
@@ -1887,12 +1792,6 @@ spec:
the Pod where this field is used. It makes that resource available
inside a container.
type: string
request:
description: |-
Request is the name chosen for a request in the referenced claim.
If empty, everything from the claim is made available, otherwise
only the result of this request.
type: string
required:
- name
type: object
@@ -1928,12 +1827,11 @@ spec:
securityContext:
description: |-
Container security context.
Security context specified here will override the security context set by the operator.
By default the operator sets the Tailscale container and the Tailscale init container to privileged
for proxies created for Tailscale ingress and egress Service, Connector and ProxyGroup.
You can reduce the permissions of the Tailscale container to cap NET_ADMIN by
installing device plugin in your cluster and configuring the proxies tun device to be created
by the device plugin, see https://github.com/tailscale/tailscale/issues/10814#issuecomment-2479977752
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:
@@ -2001,7 +1899,7 @@ spec:
procMount:
description: |-
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
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.
@@ -2122,25 +2020,6 @@ spec:
tailscaleInitContainer:
description: Configuration for the proxy init container that enables forwarding.
properties:
debug:
description: |-
Configuration for enabling extra debug information in the container.
Not recommended for production use.
properties:
enable:
description: |-
Enable tailscaled's HTTP pprof endpoints at <pod-ip>:9001/debug/pprof/
and internal debug metrics endpoint at <pod-ip>:9001/debug/metrics, where
9001 is a container port named "debug". The endpoints and their responses
may change in backwards incompatible ways in the future, and should not
be considered stable.
In 1.78.x and 1.80.x, this setting will default to the value of
.spec.metrics.enable, and requests to the "metrics" port matching the
mux pattern /debug/ will be forwarded to the "debug" port. In 1.82.x,
this setting will default to false, and no requests will be proxied.
type: boolean
type: object
env:
description: |-
List of environment variables to set in the container.
@@ -2217,12 +2096,6 @@ spec:
the Pod where this field is used. It makes that resource available
inside a container.
type: string
request:
description: |-
Request is the name chosen for a request in the referenced claim.
If empty, everything from the claim is made available, otherwise
only the result of this request.
type: string
required:
- name
type: object
@@ -2258,12 +2131,11 @@ spec:
securityContext:
description: |-
Container security context.
Security context specified here will override the security context set by the operator.
By default the operator sets the Tailscale container and the Tailscale init container to privileged
for proxies created for Tailscale ingress and egress Service, Connector and ProxyGroup.
You can reduce the permissions of the Tailscale container to cap NET_ADMIN by
installing device plugin in your cluster and configuring the proxies tun device to be created
by the device plugin, see https://github.com/tailscale/tailscale/issues/10814#issuecomment-2479977752
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:
@@ -2331,7 +2203,7 @@ spec:
procMount:
description: |-
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
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.
@@ -2765,7 +2637,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: proxygroups.tailscale.com
spec:
group: tailscale.com
@@ -2783,24 +2655,9 @@ spec:
jsonPath: .status.conditions[?(@.type == "ProxyGroupReady")].reason
name: Status
type: string
- description: ProxyGroup type.
jsonPath: .spec.type
name: Type
type: string
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
ProxyGroup defines a set of Tailscale devices that will act as proxies.
Currently only egress ProxyGroups are supported.
Use the tailscale.com/proxy-group annotation on a Service to specify that
the egress proxy should be implemented by a ProxyGroup instead of a single
dedicated proxy. In addition to running a highly available set of proxies,
ProxyGroup also allows for serving many annotated Services from a single
set of proxies to minimise resource consumption.
More info: https://tailscale.com/kb/1438/kubernetes-operator-cluster-egress
properties:
apiVersion:
description: |-
@@ -2844,7 +2701,6 @@ spec:
Replicas specifies how many replicas to create the StatefulSet with.
Defaults to 2.
format: int32
minimum: 0
type: integer
tags:
description: |-
@@ -2859,16 +2715,10 @@ spec:
type: string
type: array
type:
description: |-
Type of the ProxyGroup proxies. Supported types are egress and ingress.
Type is immutable once a ProxyGroup is created.
description: Type of the ProxyGroup proxies. Currently the only supported type is egress.
enum:
- egress
- ingress
type: string
x-kubernetes-validations:
- message: ProxyGroup type is immutable
rule: self == oldSelf
required:
- type
type: object
@@ -2975,7 +2825,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
name: recorders.tailscale.com
spec:
group: tailscale.com
@@ -3000,12 +2850,6 @@ spec:
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
Recorder defines a tsrecorder device for recording SSH sessions. By default,
it will store recordings in a local ephemeral volume. If you want to persist
recordings, you can configure an S3-compatible API for storage.
More info: https://tailscale.com/kb/1484/kubernetes-operator-deploying-tsrecorder
properties:
apiVersion:
description: |-
@@ -3329,7 +3173,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -3344,7 +3188,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -3505,7 +3349,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -3520,7 +3364,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -3674,7 +3518,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -3689,7 +3533,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -3850,7 +3694,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -3865,7 +3709,7 @@ spec:
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 a beta field and requires enabling MatchLabelKeysInPodAffinity feature gate (enabled by default).
This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
items:
type: string
type: array
@@ -4027,12 +3871,6 @@ spec:
the Pod where this field is used. It makes that resource available
inside a container.
type: string
request:
description: |-
Request is the name chosen for a request in the referenced claim.
If empty, everything from the claim is made available, otherwise
only the result of this request.
type: string
required:
- name
type: object
@@ -4136,7 +3974,7 @@ spec:
procMount:
description: |-
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
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.
@@ -4373,32 +4211,6 @@ spec:
Note that this field cannot be set when spec.os.name is windows.
format: int64
type: integer
seLinuxChangePolicy:
description: |-
seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod.
It has no effect on nodes that do not support SELinux or to volumes does not support SELinux.
Valid values are "MountOption" and "Recursive".
"Recursive" means relabeling of all files on all Pod volumes by the container runtime.
This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node.
"MountOption" mounts all eligible Pod volumes with `-o context` mount option.
This requires all Pods that share the same volume to use the same SELinux label.
It is not possible to share the same volume among privileged and unprivileged Pods.
Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes
whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their
CSIDriver instance. Other volumes are always re-labelled recursively.
"MountOption" value is allowed only when SELinuxMount feature gate is enabled.
If not specified and SELinuxMount feature gate is enabled, "MountOption" is used.
If not specified and SELinuxMount feature gate is disabled, "MountOption" is used for ReadWriteOncePod volumes
and "Recursive" for all other volumes.
This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers.
All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state.
Note that this field cannot be set when spec.os.name is windows.
type: string
seLinuxOptions:
description: |-
The SELinux context to be applied to all containers.
@@ -4447,28 +4259,18 @@ spec:
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 and fsGroup (if specified). If
the SupplementalGroupsPolicy feature is enabled, the
supplementalGroupsPolicy field determines whether these are in addition
to or instead of any group memberships defined in the container image.
If unspecified, no additional groups are added, though group memberships
defined in the container image may still be used, depending on the
supplementalGroupsPolicy field.
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
supplementalGroupsPolicy:
description: |-
Defines how supplemental groups of the first container processes are calculated.
Valid values are "Merge" and "Strict". If not specified, "Merge" is used.
(Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled
and the container runtime must implement support for this feature.
Note that this field cannot be set when spec.os.name is windows.
type: string
sysctls:
description: |-
Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported
@@ -4801,16 +4603,6 @@ rules:
- list
- watch
- update
- apiGroups:
- apiextensions.k8s.io
resourceNames:
- servicemonitors.monitoring.coreos.com
resources:
- customresourcedefinitions
verbs:
- get
- list
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
@@ -4854,13 +4646,6 @@ rules:
- get
- list
- watch
- update
- apiGroups:
- ""
resources:
- pods/status
verbs:
- update
- apiGroups:
- apps
resources:
@@ -4898,16 +4683,6 @@ rules:
- update
- list
- watch
- apiGroups:
- monitoring.coreos.com
resources:
- servicemonitors
verbs:
- get
- list
- update
- create
- delete
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
@@ -5008,14 +4783,6 @@ spec:
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

View File

@@ -39,4 +39,6 @@ spec:
fieldRef:
fieldPath: metadata.uid
securityContext:
privileged: true
capabilities:
add:
- NET_ADMIN

View File

@@ -10,7 +10,6 @@ import (
"encoding/json"
"fmt"
"slices"
"strings"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
@@ -99,15 +98,7 @@ func (dnsRR *dnsRecordsReconciler) Reconcile(ctx context.Context, req reconcile.
return reconcile.Result{}, nil
}
if err := dnsRR.maybeProvision(ctx, headlessSvc, logger); err != nil {
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
logger.Infof("optimistic lock error, retrying: %s", err)
} else {
return reconcile.Result{}, err
}
}
return reconcile.Result{}, nil
return reconcile.Result{}, dnsRR.maybeProvision(ctx, headlessSvc, logger)
}
// maybeProvision ensures that dnsrecords ConfigMap contains a record for the

View File

@@ -1,108 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package e2e
import (
"context"
"fmt"
"net/http"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/config"
kube "tailscale.com/k8s-operator"
"tailscale.com/tstest"
)
// See [TestMain] for test requirements.
func TestIngress(t *testing.T) {
if tsClient == nil {
t.Skip("TestIngress requires credentials for a tailscale client")
}
ctx := context.Background()
cfg := config.GetConfigOrDie()
cl, err := client.New(cfg, client.Options{})
if err != nil {
t.Fatal(err)
}
// Apply nginx
createAndCleanup(t, ctx, cl, &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "nginx",
Namespace: "default",
Labels: map[string]string{
"app.kubernetes.io/name": "nginx",
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "nginx",
Image: "nginx",
},
},
},
})
// Apply service to expose it as ingress
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress",
Namespace: "default",
Annotations: map[string]string{
"tailscale.com/expose": "true",
},
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{
"app.kubernetes.io/name": "nginx",
},
Ports: []corev1.ServicePort{
{
Name: "http",
Protocol: "TCP",
Port: 80,
},
},
},
}
createAndCleanup(t, ctx, cl, svc)
// TODO: instead of timing out only when test times out, cancel context after 60s or so.
if err := wait.PollUntilContextCancel(ctx, time.Millisecond*100, true, func(ctx context.Context) (done bool, err error) {
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta("default", "test-ingress")}
if err := get(ctx, cl, maybeReadySvc); err != nil {
return false, err
}
isReady := kube.SvcIsReady(maybeReadySvc)
if isReady {
t.Log("Service is ready")
}
return isReady, nil
}); err != nil {
t.Fatalf("error waiting for the Service to become Ready: %v", err)
}
var resp *http.Response
if err := tstest.WaitFor(time.Second*60, func() error {
// TODO(tomhjp): Get the tailnet DNS name from the associated secret instead.
// If we are not the first tailnet node with the requested name, we'll get
// a -N suffix.
resp, err = tsClient.HTTPClient.Get(fmt.Sprintf("http://%s-%s:80", svc.Namespace, svc.Name))
if err != nil {
return err
}
return nil
}); err != nil {
t.Fatalf("error trying to reach service: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("unexpected status: %v; response body s", resp.StatusCode)
}
}

View File

@@ -1,193 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package e2e
import (
"context"
"errors"
"fmt"
"log"
"os"
"slices"
"strings"
"testing"
"github.com/go-logr/zapr"
"github.com/tailscale/hujson"
"go.uber.org/zap/zapcore"
"golang.org/x/oauth2/clientcredentials"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
kzap "sigs.k8s.io/controller-runtime/pkg/log/zap"
"tailscale.com/internal/client/tailscale"
)
const (
e2eManagedComment = "// This is managed by the k8s-operator e2e tests"
)
var (
tsClient *tailscale.Client
testGrants = map[string]string{
"test-proxy": `{
"src": ["tag:e2e-test-proxy"],
"dst": ["tag:k8s-operator"],
"app": {
"tailscale.com/cap/kubernetes": [{
"impersonate": {
"groups": ["ts:e2e-test-proxy"],
},
}],
},
}`,
}
)
// This test suite is currently not run in CI.
// It requires some setup not handled by this code:
// - Kubernetes cluster with tailscale operator installed
// - Current kubeconfig context set to connect to that cluster (directly, no operator proxy)
// - Operator installed with --set apiServerProxyConfig.mode="true"
// - ACLs that define tag:e2e-test-proxy tag. TODO(tomhjp): Can maybe replace this prereq onwards with an API key
// - OAuth client ID and secret in TS_API_CLIENT_ID and TS_API_CLIENT_SECRET env
// - OAuth client must have auth_keys and policy_file write for tag:e2e-test-proxy tag
func TestMain(m *testing.M) {
code, err := runTests(m)
if err != nil {
log.Fatal(err)
}
os.Exit(code)
}
func runTests(m *testing.M) (int, error) {
zlog := kzap.NewRaw([]kzap.Opts{kzap.UseDevMode(true), kzap.Level(zapcore.DebugLevel)}...).Sugar()
logf.SetLogger(zapr.NewLogger(zlog.Desugar()))
if clientID := os.Getenv("TS_API_CLIENT_ID"); clientID != "" {
cleanup, err := setupClientAndACLs()
if err != nil {
return 0, err
}
defer func() {
err = errors.Join(err, cleanup())
}()
}
return m.Run(), nil
}
func setupClientAndACLs() (cleanup func() error, _ error) {
ctx := context.Background()
credentials := clientcredentials.Config{
ClientID: os.Getenv("TS_API_CLIENT_ID"),
ClientSecret: os.Getenv("TS_API_CLIENT_SECRET"),
TokenURL: "https://login.tailscale.com/api/v2/oauth/token",
Scopes: []string{"auth_keys", "policy_file"},
}
tsClient = tailscale.NewClient("-", nil)
tsClient.HTTPClient = credentials.Client(ctx)
if err := patchACLs(ctx, tsClient, func(acls *hujson.Value) {
for test, grant := range testGrants {
deleteTestGrants(test, acls)
addTestGrant(test, grant, acls)
}
}); err != nil {
return nil, err
}
return func() error {
return patchACLs(ctx, tsClient, func(acls *hujson.Value) {
for test := range testGrants {
deleteTestGrants(test, acls)
}
})
}, nil
}
func patchACLs(ctx context.Context, tsClient *tailscale.Client, patchFn func(*hujson.Value)) error {
acls, err := tsClient.ACLHuJSON(ctx)
if err != nil {
return err
}
hj, err := hujson.Parse([]byte(acls.ACL))
if err != nil {
return err
}
patchFn(&hj)
hj.Format()
acls.ACL = hj.String()
if _, err := tsClient.SetACLHuJSON(ctx, *acls, true); err != nil {
return err
}
return nil
}
func addTestGrant(test, grant string, acls *hujson.Value) error {
v, err := hujson.Parse([]byte(grant))
if err != nil {
return err
}
// Add the managed comment to the first line of the grant object contents.
v.Value.(*hujson.Object).Members[0].Name.BeforeExtra = hujson.Extra(fmt.Sprintf("%s: %s\n", e2eManagedComment, test))
if err := acls.Patch([]byte(fmt.Sprintf(`[{"op": "add", "path": "/grants/-", "value": %s}]`, v.String()))); err != nil {
return err
}
return nil
}
func deleteTestGrants(test string, acls *hujson.Value) error {
grants := acls.Find("/grants")
var patches []string
for i, g := range grants.Value.(*hujson.Array).Elements {
members := g.Value.(*hujson.Object).Members
if len(members) == 0 {
continue
}
comment := strings.TrimSpace(string(members[0].Name.BeforeExtra))
if name, found := strings.CutPrefix(comment, e2eManagedComment+": "); found && name == test {
patches = append(patches, fmt.Sprintf(`{"op": "remove", "path": "/grants/%d"}`, i))
}
}
// Remove in reverse order so we don't affect the found indices as we mutate.
slices.Reverse(patches)
if err := acls.Patch([]byte(fmt.Sprintf("[%s]", strings.Join(patches, ",")))); err != nil {
return err
}
return nil
}
func objectMeta(namespace, name string) metav1.ObjectMeta {
return metav1.ObjectMeta{
Namespace: namespace,
Name: name,
}
}
func createAndCleanup(t *testing.T, ctx context.Context, cl client.Client, obj client.Object) {
t.Helper()
if err := cl.Create(ctx, obj); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := cl.Delete(ctx, obj); err != nil {
t.Errorf("error cleaning up %s %s/%s: %s", obj.GetObjectKind().GroupVersionKind(), obj.GetNamespace(), obj.GetName(), err)
}
})
}
func get(ctx context.Context, cl client.Client, obj client.Object) error {
return cl.Get(ctx, client.ObjectKeyFromObject(obj), obj)
}

View File

@@ -1,156 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package e2e
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/config"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet"
"tailscale.com/tstest"
)
// See [TestMain] for test requirements.
func TestProxy(t *testing.T) {
if tsClient == nil {
t.Skip("TestProxy requires credentials for a tailscale client")
}
ctx := context.Background()
cfg := config.GetConfigOrDie()
cl, err := client.New(cfg, client.Options{})
if err != nil {
t.Fatal(err)
}
// Create role and role binding to allow a group we'll impersonate to do stuff.
createAndCleanup(t, ctx, cl, &rbacv1.Role{
ObjectMeta: objectMeta("tailscale", "read-secrets"),
Rules: []rbacv1.PolicyRule{{
APIGroups: []string{""},
Verbs: []string{"get"},
Resources: []string{"secrets"},
}},
})
createAndCleanup(t, ctx, cl, &rbacv1.RoleBinding{
ObjectMeta: objectMeta("tailscale", "read-secrets"),
Subjects: []rbacv1.Subject{{
Kind: "Group",
Name: "ts:e2e-test-proxy",
}},
RoleRef: rbacv1.RoleRef{
Kind: "Role",
Name: "read-secrets",
},
})
// Get operator host name from kube secret.
operatorSecret := corev1.Secret{
ObjectMeta: objectMeta("tailscale", "operator"),
}
if err := get(ctx, cl, &operatorSecret); err != nil {
t.Fatal(err)
}
// Connect to tailnet with test-specific tag so we can use the
// [testGrants] ACLs when connecting to the API server proxy
ts := tsnetServerWithTag(t, ctx, "tag:e2e-test-proxy")
proxyCfg := &rest.Config{
Host: fmt.Sprintf("https://%s:443", hostNameFromOperatorSecret(t, operatorSecret)),
Dial: ts.Dial,
}
proxyCl, err := client.New(proxyCfg, client.Options{})
if err != nil {
t.Fatal(err)
}
// Expect success.
allowedSecret := corev1.Secret{
ObjectMeta: objectMeta("tailscale", "operator"),
}
// Wait for up to a minute the first time we use the proxy, to give it time
// to provision the TLS certs.
if err := tstest.WaitFor(time.Second*60, func() error {
return get(ctx, proxyCl, &allowedSecret)
}); err != nil {
t.Fatal(err)
}
// Expect forbidden.
forbiddenSecret := corev1.Secret{
ObjectMeta: objectMeta("default", "operator"),
}
if err := get(ctx, proxyCl, &forbiddenSecret); err == nil || !apierrors.IsForbidden(err) {
t.Fatalf("expected forbidden error fetching secret from default namespace: %s", err)
}
}
func tsnetServerWithTag(t *testing.T, ctx context.Context, tag string) *tsnet.Server {
caps := tailscale.KeyCapabilities{
Devices: tailscale.KeyDeviceCapabilities{
Create: tailscale.KeyDeviceCreateCapabilities{
Reusable: false,
Preauthorized: true,
Ephemeral: true,
Tags: []string{tag},
},
},
}
authKey, authKeyMeta, err := tsClient.CreateKey(ctx, caps)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := tsClient.DeleteKey(ctx, authKeyMeta.ID); err != nil {
t.Errorf("error deleting auth key: %s", err)
}
})
ts := &tsnet.Server{
Hostname: "test-proxy",
Ephemeral: true,
Dir: t.TempDir(),
AuthKey: authKey,
}
_, err = ts.Up(ctx)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := ts.Close(); err != nil {
t.Errorf("error shutting down tsnet.Server: %s", err)
}
})
return ts
}
func hostNameFromOperatorSecret(t *testing.T, s corev1.Secret) string {
profiles := map[string]any{}
if err := json.Unmarshal(s.Data["_profiles"], &profiles); err != nil {
t.Fatal(err)
}
key, ok := strings.CutPrefix(string(s.Data["_current-profile"]), "profile-")
if !ok {
t.Fatal(string(s.Data["_current-profile"]))
}
profile, ok := profiles[key]
if !ok {
t.Fatal(profiles)
}
return ((profile.(map[string]any))["Name"]).(string)
}

View File

@@ -20,6 +20,7 @@ import (
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"
)
@@ -70,27 +71,25 @@ func (er *egressEpsReconciler) Reconcile(ctx context.Context, req reconcile.Requ
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.
proxyGroupName := eps.Labels[labelProxyGroup]
_, cfgs, err := egressSvcsConfigs(ctx, er.Client, proxyGroupName, er.tsNamespace)
if err != nil {
return res, fmt.Errorf("error retrieving tailnet services configuration: %w", err)
}
if cfgs == nil {
// TODO(irbekrm): this path would be hit if egress service was once exposed on a ProxyGroup that later
// got deleted. Probably the EndpointSlices then need to be deleted too- need to rethink this flow.
l.Debugf("No egress config found, likely because ProxyGroup has not been created")
return res, nil
}
cfg, ok := (*cfgs)[tailnetSvc]
if !ok {
l.Infof("[unexpected] configuration for tailnet service %s not found", tailnetSvc)

View File

@@ -112,7 +112,7 @@ func TestTailscaleEgressEndpointSlices(t *testing.T) {
Terminating: pointer.ToBool(false),
},
})
expectEqual(t, fc, eps)
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
@@ -122,7 +122,7 @@ func TestTailscaleEgressEndpointSlices(t *testing.T) {
})
expectReconciled(t, er, "operator-ns", "foo")
eps.Endpoints = []discoveryv1.Endpoint{}
expectEqual(t, fc, eps)
expectEqual(t, fc, eps, nil)
})
}

View File

@@ -1,274 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"sync/atomic"
"time"
"go.uber.org/zap"
xslices "golang.org/x/exp/slices"
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"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/logtail/backoff"
"tailscale.com/tstime"
"tailscale.com/util/httpm"
)
const tsEgressReadinessGate = "tailscale.com/egress-services"
// egressPodsReconciler is responsible for setting tailscale.com/egress-services condition on egress ProxyGroup Pods.
// The condition is used as a readiness gate for the Pod, meaning that kubelet will not mark the Pod as ready before the
// condition is set. The ProxyGroup StatefulSet updates are rolled out in such a way that no Pod is restarted, before
// the previous Pod is marked as ready, so ensuring that the Pod does not get marked as ready when it is not yet able to
// route traffic for egress service prevents downtime during restarts caused by no available endpoints left because
// every Pod has been recreated and is not yet added to endpoints.
// https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-readiness-gate
type egressPodsReconciler struct {
client.Client
logger *zap.SugaredLogger
tsNamespace string
clock tstime.Clock
httpClient doer // http client that can be set to a mock client in tests
maxBackoff time.Duration // max backoff period between health check calls
}
// Reconcile reconciles an egress ProxyGroup Pods on changes to those Pods and ProxyGroup EndpointSlices. It ensures
// that for each Pod who is ready to route traffic to all egress services for the ProxyGroup, the Pod has a
// tailscale.com/egress-services condition to set, so that kubelet will mark the Pod as ready.
//
// For the Pod to be ready
// to route traffic to the egress service, the kube proxy needs to have set up the Pod's IP as an endpoint for the
// ClusterIP Service corresponding to the egress service.
//
// Note that the endpoints for the ClusterIP Service are configured by the operator itself using custom
// EndpointSlices(egress-eps-reconciler), so the routing is not blocked on Pod's readiness.
//
// Each egress service has a corresponding ClusterIP Service, that exposes all user configured
// tailnet ports, as well as a health check port for the proxy.
//
// The reconciler calls the health check endpoint of each Service up to N number of times, where N is the number of
// replicas for the ProxyGroup x 3, and checks if the received response is healthy response from the Pod being reconciled.
//
// The health check response contains a header with the
// Pod's IP address- this is used to determine whether the response is received from this Pod.
//
// If the Pod does not appear to be serving the health check endpoint (pre-v1.80 proxies), the reconciler just sets the
// readiness condition for backwards compatibility reasons.
func (er *egressPodsReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
l := er.logger.With("Pod", req.NamespacedName)
l.Debugf("starting reconcile")
defer l.Debugf("reconcile finished")
pod := new(corev1.Pod)
err = er.Get(ctx, req.NamespacedName, pod)
if apierrors.IsNotFound(err) {
return reconcile.Result{}, nil
}
if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to get Pod: %w", err)
}
if !pod.DeletionTimestamp.IsZero() {
l.Debugf("Pod is being deleted, do nothing")
return res, nil
}
if pod.Labels[LabelParentType] != proxyTypeProxyGroup {
l.Infof("[unexpected] reconciler called for a Pod that is not a ProxyGroup Pod")
return res, nil
}
// If the Pod does not have the readiness gate set, there is no need to add the readiness condition. In practice
// this will happen if the user has configured custom TS_LOCAL_ADDR_PORT, thus disabling the graceful failover.
if !slices.ContainsFunc(pod.Spec.ReadinessGates, func(r corev1.PodReadinessGate) bool {
return r.ConditionType == tsEgressReadinessGate
}) {
l.Debug("Pod does not have egress readiness gate set, skipping")
return res, nil
}
proxyGroupName := pod.Labels[LabelParentName]
pg := new(tsapi.ProxyGroup)
if err := er.Get(ctx, types.NamespacedName{Name: proxyGroupName}, pg); err != nil {
return res, fmt.Errorf("error getting ProxyGroup %q: %w", proxyGroupName, err)
}
if pg.Spec.Type != typeEgress {
l.Infof("[unexpected] reconciler called for %q ProxyGroup Pod", pg.Spec.Type)
return res, nil
}
// Get all ClusterIP Services for all egress targets exposed to cluster via this ProxyGroup.
lbls := map[string]string{
LabelManaged: "true",
labelProxyGroup: proxyGroupName,
labelSvcType: typeEgress,
}
svcs := &corev1.ServiceList{}
if err := er.List(ctx, svcs, client.InNamespace(er.tsNamespace), client.MatchingLabels(lbls)); err != nil {
return res, fmt.Errorf("error listing ClusterIP Services")
}
idx := xslices.IndexFunc(pod.Status.Conditions, func(c corev1.PodCondition) bool {
return c.Type == tsEgressReadinessGate
})
if idx != -1 {
l.Debugf("Pod is already ready, do nothing")
return res, nil
}
var routesMissing atomic.Bool
errChan := make(chan error, len(svcs.Items))
for _, svc := range svcs.Items {
s := svc
go func() {
ll := l.With("service_name", s.Name)
d := retrieveClusterDomain(er.tsNamespace, ll)
healthCheckAddr := healthCheckForSvc(&s, d)
if healthCheckAddr == "" {
ll.Debugf("ClusterIP Service does not expose a health check endpoint, unable to verify if routing is set up")
errChan <- nil
return
}
var routesSetup bool
bo := backoff.NewBackoff(s.Name, ll.Infof, er.maxBackoff)
for range numCalls(pgReplicas(pg)) {
if ctx.Err() != nil {
errChan <- nil
return
}
state, err := er.lookupPodRouteViaSvc(ctx, pod, healthCheckAddr, ll)
if err != nil {
errChan <- fmt.Errorf("error validating if routing has been set up for Pod: %w", err)
return
}
if state == healthy || state == cannotVerify {
routesSetup = true
break
}
if state == unreachable || state == unhealthy || state == podNotReady {
bo.BackOff(ctx, errors.New("backoff"))
}
}
if !routesSetup {
ll.Debugf("Pod is not yet configured as Service endpoint")
routesMissing.Store(true)
}
errChan <- nil
}()
}
for range len(svcs.Items) {
e := <-errChan
err = errors.Join(err, e)
}
if err != nil {
return res, fmt.Errorf("error verifying conectivity: %w", err)
}
if rm := routesMissing.Load(); rm {
l.Info("Pod is not yet added as an endpoint for all egress targets, waiting...")
return reconcile.Result{RequeueAfter: shortRequeue}, nil
}
if err := er.setPodReady(ctx, pod, l); err != nil {
return res, fmt.Errorf("error setting Pod as ready: %w", err)
}
return res, nil
}
func (er *egressPodsReconciler) setPodReady(ctx context.Context, pod *corev1.Pod, l *zap.SugaredLogger) error {
if slices.ContainsFunc(pod.Status.Conditions, func(c corev1.PodCondition) bool {
return c.Type == tsEgressReadinessGate
}) {
return nil
}
l.Infof("Pod is ready to route traffic to all egress targets")
pod.Status.Conditions = append(pod.Status.Conditions, corev1.PodCondition{
Type: tsEgressReadinessGate,
Status: corev1.ConditionTrue,
LastTransitionTime: metav1.Time{Time: er.clock.Now()},
})
return er.Status().Update(ctx, pod)
}
// healthCheckState is the result of a single request to an egress Service health check endpoint with a goal to hit a
// specific backend Pod.
type healthCheckState int8
const (
cannotVerify healthCheckState = iota // not verifiable for this setup (i.e earlier proxy version)
unreachable // no backends or another network error
notFound // hit another backend
unhealthy // not 200
podNotReady // Pod is not ready, i.e does not have an IP address yet
healthy // 200
)
// lookupPodRouteViaSvc attempts to reach a Pod using a health check endpoint served by a Service and returns the state of the health check.
func (er *egressPodsReconciler) lookupPodRouteViaSvc(ctx context.Context, pod *corev1.Pod, healthCheckAddr string, l *zap.SugaredLogger) (healthCheckState, error) {
if !slices.ContainsFunc(pod.Spec.Containers[0].Env, func(e corev1.EnvVar) bool {
return e.Name == "TS_ENABLE_HEALTH_CHECK" && e.Value == "true"
}) {
l.Debugf("Pod does not have health check enabled, unable to verify if it is currently routable via Service")
return cannotVerify, nil
}
wantsIP, err := podIPv4(pod)
if err != nil {
return -1, fmt.Errorf("error determining Pod's IP address: %w", err)
}
if wantsIP == "" {
return podNotReady, nil
}
ctx, cancel := context.WithTimeout(ctx, time.Second*3)
defer cancel()
req, err := http.NewRequestWithContext(ctx, httpm.GET, healthCheckAddr, nil)
if err != nil {
return -1, fmt.Errorf("error creating new HTTP request: %w", err)
}
// Do not re-use the same connection for the next request so to maximize the chance of hitting all backends equally.
req.Close = true
resp, err := er.httpClient.Do(req)
if err != nil {
// This is most likely because this is the first Pod and is not yet added to Service endoints. Other
// error types are possible, but checking for those would likely make the system too fragile.
return unreachable, nil
}
defer resp.Body.Close()
gotIP := resp.Header.Get(kubetypes.PodIPv4Header)
if gotIP == "" {
l.Debugf("Health check does not return Pod's IP header, unable to verify if Pod is currently routable via Service")
return cannotVerify, nil
}
if !strings.EqualFold(wantsIP, gotIP) {
return notFound, nil
}
if resp.StatusCode != http.StatusOK {
return unhealthy, nil
}
return healthy, nil
}
// numCalls return the number of times an endpoint on a ProxyGroup Service should be called till it can be safely
// assumed that, if none of the responses came back from a specific Pod then traffic for the Service is currently not
// being routed to that Pod. This assumes that traffic for the Service is routed via round robin, so
// InternalTrafficPolicy must be 'Cluster' and session affinity must be None.
func numCalls(replicas int32) int32 {
return replicas * 3
}
// doer is an interface for HTTP client that can be set to a mock client in tests.
type doer interface {
Do(*http.Request) (*http.Response, error)
}

View File

@@ -1,525 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !plan9
package main
import (
"bytes"
"errors"
"fmt"
"io"
"log"
"net/http"
"sync"
"testing"
"time"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/tstest"
"tailscale.com/types/ptr"
)
func TestEgressPodReadiness(t *testing.T) {
// We need to pass a Pod object to WithStatusSubresource because of some quirks in how the fake client
// works. Without this code we would not be able to update Pod's status further down.
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithStatusSubresource(&corev1.Pod{}).
Build()
zl, _ := zap.NewDevelopment()
cl := tstest.NewClock(tstest.ClockOpts{})
rec := &egressPodsReconciler{
tsNamespace: "operator-ns",
Client: fc,
logger: zl.Sugar(),
clock: cl,
}
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: "dev",
},
Spec: tsapi.ProxyGroupSpec{
Type: "egress",
Replicas: ptr.To(int32(3)),
},
}
mustCreate(t, fc, pg)
podIP := "10.0.0.2"
podTemplate := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Namespace: "operator-ns",
Name: "pod",
Labels: map[string]string{
LabelParentType: "proxygroup",
LabelParentName: "dev",
},
},
Spec: corev1.PodSpec{
ReadinessGates: []corev1.PodReadinessGate{{
ConditionType: tsEgressReadinessGate,
}},
Containers: []corev1.Container{{
Name: "tailscale",
Env: []corev1.EnvVar{{
Name: "TS_ENABLE_HEALTH_CHECK",
Value: "true",
}},
}},
},
Status: corev1.PodStatus{
PodIPs: []corev1.PodIP{{IP: podIP}},
},
}
t.Run("no_egress_services", func(t *testing.T) {
pod := podTemplate.DeepCopy()
mustCreate(t, fc, pod)
expectReconciled(t, rec, "operator-ns", pod.Name)
// Pod should have readiness gate condition set.
podSetReady(pod, cl)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod)
})
t.Run("one_svc_already_routed_to", func(t *testing.T) {
pod := podTemplate.DeepCopy()
svc, hep := newSvc("svc", 9002)
mustCreateAll(t, fc, svc, pod)
resp := readyResps(podIP, 1)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{hep: resp},
}
rec.httpClient = &httpCl
expectReconciled(t, rec, "operator-ns", pod.Name)
// Pod should have readiness gate condition set.
podSetReady(pod, cl)
expectEqual(t, fc, pod)
// A subsequent reconcile should not change the Pod.
expectReconciled(t, rec, "operator-ns", pod.Name)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc)
})
t.Run("one_svc_many_backends_eventually_routed_to", func(t *testing.T) {
pod := podTemplate.DeepCopy()
svc, hep := newSvc("svc", 9002)
mustCreateAll(t, fc, svc, pod)
// For a 3 replica ProxyGroup the healthcheck endpoint should be called 9 times, make the 9th time only
// return with the right Pod IP.
resps := append(readyResps("10.0.0.3", 4), append(readyResps("10.0.0.4", 4), readyResps(podIP, 1)...)...)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{hep: resps},
}
rec.httpClient = &httpCl
expectReconciled(t, rec, "operator-ns", pod.Name)
// Pod should have readiness gate condition set.
podSetReady(pod, cl)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc)
})
t.Run("one_svc_one_backend_eventually_healthy", func(t *testing.T) {
pod := podTemplate.DeepCopy()
svc, hep := newSvc("svc", 9002)
mustCreateAll(t, fc, svc, pod)
// For a 3 replica ProxyGroup the healthcheck endpoint should be called 9 times, make the 9th time only
// return with 200 status code.
resps := append(unreadyResps(podIP, 8), readyResps(podIP, 1)...)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{hep: resps},
}
rec.httpClient = &httpCl
expectReconciled(t, rec, "operator-ns", pod.Name)
// Pod should have readiness gate condition set.
podSetReady(pod, cl)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc)
})
t.Run("one_svc_one_backend_never_routable", func(t *testing.T) {
pod := podTemplate.DeepCopy()
svc, hep := newSvc("svc", 9002)
mustCreateAll(t, fc, svc, pod)
// For a 3 replica ProxyGroup the healthcheck endpoint should be called 9 times and Pod should be
// requeued if neither of those succeed.
resps := readyResps("10.0.0.3", 9)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{hep: resps},
}
rec.httpClient = &httpCl
expectRequeue(t, rec, "operator-ns", pod.Name)
// Pod should not have readiness gate condition set.
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc)
})
t.Run("one_svc_many_backends_already_routable", func(t *testing.T) {
pod := podTemplate.DeepCopy()
svc, hep := newSvc("svc", 9002)
svc2, hep2 := newSvc("svc-2", 9002)
svc3, hep3 := newSvc("svc-3", 9002)
mustCreateAll(t, fc, svc, svc2, svc3, pod)
resps := readyResps(podIP, 1)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{
hep: resps,
hep2: resps,
hep3: resps,
},
}
rec.httpClient = &httpCl
expectReconciled(t, rec, "operator-ns", pod.Name)
// Pod should not have readiness gate condition set.
podSetReady(pod, cl)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc, svc2, svc3)
})
t.Run("one_svc_many_backends_eventually_routable_and_healthy", func(t *testing.T) {
pod := podTemplate.DeepCopy()
svc, hep := newSvc("svc", 9002)
svc2, hep2 := newSvc("svc-2", 9002)
svc3, hep3 := newSvc("svc-3", 9002)
mustCreateAll(t, fc, svc, svc2, svc3, pod)
resps := append(readyResps("10.0.0.3", 7), readyResps(podIP, 1)...)
resps2 := append(readyResps("10.0.0.3", 5), readyResps(podIP, 1)...)
resps3 := append(unreadyResps(podIP, 4), readyResps(podIP, 1)...)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{
hep: resps,
hep2: resps2,
hep3: resps3,
},
}
rec.httpClient = &httpCl
expectReconciled(t, rec, "operator-ns", pod.Name)
// Pod should have readiness gate condition set.
podSetReady(pod, cl)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc, svc2, svc3)
})
t.Run("one_svc_many_backends_never_routable_and_healthy", func(t *testing.T) {
pod := podTemplate.DeepCopy()
svc, hep := newSvc("svc", 9002)
svc2, hep2 := newSvc("svc-2", 9002)
svc3, hep3 := newSvc("svc-3", 9002)
mustCreateAll(t, fc, svc, svc2, svc3, pod)
// For a ProxyGroup with 3 replicas, each Service's health endpoint will be tried 9 times and the Pod
// will be requeued if neither succeeds.
resps := readyResps("10.0.0.3", 9)
resps2 := append(readyResps("10.0.0.3", 5), readyResps("10.0.0.4", 4)...)
resps3 := unreadyResps(podIP, 9)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{
hep: resps,
hep2: resps2,
hep3: resps3,
},
}
rec.httpClient = &httpCl
expectRequeue(t, rec, "operator-ns", pod.Name)
// Pod should not have readiness gate condition set.
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc, svc2, svc3)
})
t.Run("one_svc_many_backends_one_never_routable", func(t *testing.T) {
pod := podTemplate.DeepCopy()
svc, hep := newSvc("svc", 9002)
svc2, hep2 := newSvc("svc-2", 9002)
svc3, hep3 := newSvc("svc-3", 9002)
mustCreateAll(t, fc, svc, svc2, svc3, pod)
// For a ProxyGroup with 3 replicas, each Service's health endpoint will be tried 9 times and the Pod
// will be requeued if any one never succeeds.
resps := readyResps(podIP, 9)
resps2 := readyResps(podIP, 9)
resps3 := append(readyResps("10.0.0.3", 5), readyResps("10.0.0.4", 4)...)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{
hep: resps,
hep2: resps2,
hep3: resps3,
},
}
rec.httpClient = &httpCl
expectRequeue(t, rec, "operator-ns", pod.Name)
// Pod should not have readiness gate condition set.
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc, svc2, svc3)
})
t.Run("one_svc_many_backends_one_never_healthy", func(t *testing.T) {
pod := podTemplate.DeepCopy()
svc, hep := newSvc("svc", 9002)
svc2, hep2 := newSvc("svc-2", 9002)
svc3, hep3 := newSvc("svc-3", 9002)
mustCreateAll(t, fc, svc, svc2, svc3, pod)
// For a ProxyGroup with 3 replicas, each Service's health endpoint will be tried 9 times and the Pod
// will be requeued if any one never succeeds.
resps := readyResps(podIP, 9)
resps2 := unreadyResps(podIP, 9)
resps3 := readyResps(podIP, 9)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{
hep: resps,
hep2: resps2,
hep3: resps3,
},
}
rec.httpClient = &httpCl
expectRequeue(t, rec, "operator-ns", pod.Name)
// Pod should not have readiness gate condition set.
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc, svc2, svc3)
})
t.Run("one_svc_many_backends_different_ports_eventually_healthy_and_routable", func(t *testing.T) {
pod := podTemplate.DeepCopy()
svc, hep := newSvc("svc", 9003)
svc2, hep2 := newSvc("svc-2", 9004)
svc3, hep3 := newSvc("svc-3", 9010)
mustCreateAll(t, fc, svc, svc2, svc3, pod)
// For a ProxyGroup with 3 replicas, each Service's health endpoint will be tried up to 9 times and
// marked as success as soon as one try succeeds.
resps := append(readyResps("10.0.0.3", 7), readyResps(podIP, 1)...)
resps2 := append(readyResps("10.0.0.3", 5), readyResps(podIP, 1)...)
resps3 := append(unreadyResps(podIP, 4), readyResps(podIP, 1)...)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{
hep: resps,
hep2: resps2,
hep3: resps3,
},
}
rec.httpClient = &httpCl
expectReconciled(t, rec, "operator-ns", pod.Name)
// Pod should have readiness gate condition set.
podSetReady(pod, cl)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc, svc2, svc3)
})
// Proxies of 1.78 and earlier did not set the Pod IP header.
t.Run("pod_does_not_return_ip_header", func(t *testing.T) {
pod := podTemplate.DeepCopy()
pod.Name = "foo-bar"
svc, hep := newSvc("foo-bar", 9002)
mustCreateAll(t, fc, svc, pod)
// If a response does not contain Pod IP header, we assume that this is an earlier proxy version,
// readiness cannot be verified so the readiness gate is just set to true.
resps := unreadyResps("", 1)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{
hep: resps,
},
}
rec.httpClient = &httpCl
expectReconciled(t, rec, "operator-ns", pod.Name)
// Pod should have readiness gate condition set.
podSetReady(pod, cl)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc)
})
t.Run("one_svc_one_backend_eventually_healthy_and_routable", func(t *testing.T) {
pod := podTemplate.DeepCopy()
svc, hep := newSvc("svc", 9002)
mustCreateAll(t, fc, svc, pod)
// If a response errors, it is probably because the Pod is not yet properly running, so retry.
resps := append(erroredResps(8), readyResps(podIP, 1)...)
httpCl := fakeHTTPClient{
t: t,
state: map[string][]fakeResponse{
hep: resps,
},
}
rec.httpClient = &httpCl
expectReconciled(t, rec, "operator-ns", pod.Name)
// Pod should have readiness gate condition set.
podSetReady(pod, cl)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc)
})
t.Run("one_svc_one_backend_svc_does_not_have_health_port", func(t *testing.T) {
pod := podTemplate.DeepCopy()
// If a Service does not have health port set, we assume that it is not possible to determine Pod's
// readiness and set it to ready.
svc, _ := newSvc("svc", -1)
mustCreateAll(t, fc, svc, pod)
rec.httpClient = nil
expectReconciled(t, rec, "operator-ns", pod.Name)
// Pod should have readiness gate condition set.
podSetReady(pod, cl)
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc)
})
t.Run("error_setting_up_healthcheck", func(t *testing.T) {
pod := podTemplate.DeepCopy()
// This is not a realistic reason for error, but we are just testing the behaviour of a healthcheck
// lookup failing.
pod.Status.PodIPs = []corev1.PodIP{{IP: "not-an-ip"}}
svc, _ := newSvc("svc", 9002)
svc2, _ := newSvc("svc-2", 9002)
svc3, _ := newSvc("svc-3", 9002)
mustCreateAll(t, fc, svc, svc2, svc3, pod)
rec.httpClient = nil
expectError(t, rec, "operator-ns", pod.Name)
// Pod should not have readiness gate condition set.
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc, svc2, svc3)
})
t.Run("pod_does_not_have_an_ip_address", func(t *testing.T) {
pod := podTemplate.DeepCopy()
pod.Status.PodIPs = nil
svc, _ := newSvc("svc", 9002)
svc2, _ := newSvc("svc-2", 9002)
svc3, _ := newSvc("svc-3", 9002)
mustCreateAll(t, fc, svc, svc2, svc3, pod)
rec.httpClient = nil
expectRequeue(t, rec, "operator-ns", pod.Name)
// Pod should not have readiness gate condition set.
expectEqual(t, fc, pod)
mustDeleteAll(t, fc, pod, svc, svc2, svc3)
})
}
func readyResps(ip string, num int) (resps []fakeResponse) {
for range num {
resps = append(resps, fakeResponse{statusCode: 200, podIP: ip})
}
return resps
}
func unreadyResps(ip string, num int) (resps []fakeResponse) {
for range num {
resps = append(resps, fakeResponse{statusCode: 503, podIP: ip})
}
return resps
}
func erroredResps(num int) (resps []fakeResponse) {
for range num {
resps = append(resps, fakeResponse{err: errors.New("timeout")})
}
return resps
}
func newSvc(name string, port int32) (*corev1.Service, string) {
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Namespace: "operator-ns",
Name: name,
Labels: map[string]string{
LabelManaged: "true",
labelProxyGroup: "dev",
labelSvcType: typeEgress,
},
},
Spec: corev1.ServiceSpec{},
}
if port != -1 {
svc.Spec.Ports = []corev1.ServicePort{
{
Name: tsHealthCheckPortName,
Port: port,
TargetPort: intstr.FromInt(9002),
Protocol: "TCP",
},
}
}
return svc, fmt.Sprintf("http://%s.operator-ns.svc.cluster.local:%d/healthz", name, port)
}
func podSetReady(pod *corev1.Pod, cl *tstest.Clock) {
pod.Status.Conditions = append(pod.Status.Conditions, corev1.PodCondition{
Type: tsEgressReadinessGate,
Status: corev1.ConditionTrue,
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
})
}
// fakeHTTPClient is a mock HTTP client with a preset map of request URLs to list of responses. When it receives a
// request for a specific URL, it returns the preset response for that URL. It errors if an unexpected request is
// received.
type fakeHTTPClient struct {
t *testing.T
mu sync.Mutex // protects following
state map[string][]fakeResponse
}
func (f *fakeHTTPClient) Do(req *http.Request) (*http.Response, error) {
f.mu.Lock()
resps := f.state[req.URL.String()]
if len(resps) == 0 {
f.mu.Unlock()
log.Printf("\n\n\nURL %q\n\n\n", req.URL)
f.t.Fatalf("fakeHTTPClient received an unexpected request for %q", req.URL)
}
defer func() {
if len(resps) == 1 {
delete(f.state, req.URL.String())
f.mu.Unlock()
return
}
f.state[req.URL.String()] = f.state[req.URL.String()][1:]
f.mu.Unlock()
}()
resp := resps[0]
if resp.err != nil {
return nil, resp.err
}
r := http.Response{
StatusCode: resp.statusCode,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader([]byte{})),
}
r.Header.Add(kubetypes.PodIPv4Header, resp.podIP)
return &r, nil
}
type fakeResponse struct {
err error
statusCode int
podIP string // for the Pod IP header
}

View File

@@ -48,12 +48,11 @@ type egressSvcsReadinessReconciler struct {
// 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)
l.Debugf("starting reconcile")
defer l.Debugf("reconcile finished")
defer l.Info("reconcile finished")
svc := new(corev1.Service)
if err = esrr.Get(ctx, req.NamespacedName, svc); apierrors.IsNotFound(err) {
l.Debugf("Service not found")
l.Info("Service not found")
return res, nil
} else if err != nil {
return res, fmt.Errorf("failed to get Service: %w", err)
@@ -65,7 +64,7 @@ func (esrr *egressSvcsReadinessReconciler) Reconcile(ctx context.Context, req re
oldStatus := svc.Status.DeepCopy()
defer func() {
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcReady, st, reason, msg, esrr.clock, l)
if !apiequality.Semantic.DeepEqual(oldStatus, &svc.Status) {
if !apiequality.Semantic.DeepEqual(oldStatus, svc.Status) {
err = errors.Join(err, esrr.Status().Update(ctx, svc))
}
}()
@@ -128,16 +127,16 @@ func (esrr *egressSvcsReadinessReconciler) Reconcile(ctx context.Context, req re
return res, err
}
if pod == nil {
l.Warnf("[unexpected] ProxyGroup is ready, but replica %d was not found", i)
l.Infof("[unexpected] ProxyGroup is ready, but replica %d was not found", i)
reason, msg = reasonClusterResourcesNotReady, reasonClusterResourcesNotReady
return res, nil
}
l.Debugf("looking at Pod with IPs %v", pod.Status.PodIPs)
l.Infof("looking at Pod with IPs %v", pod.Status.PodIPs)
ready := false
for _, ep := range eps.Endpoints {
l.Debugf("looking at endpoint with addresses %v", ep.Addresses)
l.Infof("looking at endpoint with addresses %v", ep.Addresses)
if endpointReadyForPod(&ep, pod, l) {
l.Debugf("endpoint is ready for Pod")
l.Infof("endpoint is ready for Pod")
ready = true
break
}
@@ -166,7 +165,7 @@ func (esrr *egressSvcsReadinessReconciler) Reconcile(ctx context.Context, req re
func endpointReadyForPod(ep *discoveryv1.Endpoint, pod *corev1.Pod, l *zap.SugaredLogger) bool {
podIP, err := podIPv4(pod)
if err != nil {
l.Warnf("[unexpected] error retrieving Pod's IPv4 address: %v", err)
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.

View File

@@ -67,24 +67,24 @@ func TestEgressServiceReadiness(t *testing.T) {
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) // not ready
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) // still not ready
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) // still not ready
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)
expectEqual(t, fc, pg, nil)
for i := range pgReplicas(pg) {
p := pod(pg, i)
mustCreate(t, fc, p)
@@ -94,7 +94,7 @@ func TestEgressServiceReadiness(t *testing.T) {
}
expectReconciled(t, rec, "dev", "my-app")
setNotReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg))
expectEqual(t, fc, egressSvc) // still not ready
expectEqual(t, fc, egressSvc, nil) // still not ready
})
t.Run("one_ready_replica", func(t *testing.T) {
setEndpointForReplica(pg, 0, eps)
@@ -103,7 +103,7 @@ func TestEgressServiceReadiness(t *testing.T) {
})
setReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg), 1)
expectReconciled(t, rec, "dev", "my-app")
expectEqual(t, fc, egressSvc) // partially ready
expectEqual(t, fc, egressSvc, nil) // partially ready
})
t.Run("all_replicas_ready", func(t *testing.T) {
for i := range pgReplicas(pg) {
@@ -114,7 +114,7 @@ func TestEgressServiceReadiness(t *testing.T) {
})
setReady(egressSvc, cl, zl.Sugar(), pgReplicas(pg), pgReplicas(pg))
expectReconciled(t, rec, "dev", "my-app")
expectEqual(t, fc, egressSvc) // ready
expectEqual(t, fc, egressSvc, nil) // ready
})
}

View File

@@ -51,16 +51,14 @@ const (
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 [10000 - 11000). The
// 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 1000 is somewhat arbitrary, the
// netfilter rules). The limit of 10000 is somewhat arbitrary, the
// assumption is that this would not be hit in practice.
maxPorts = 1000
maxPorts = 10000
indexEgressProxyGroup = ".metadata.annotations.egress-proxy-group"
tsHealthCheckPortName = "tailscale-health-check"
)
var gaugeEgressServices = clientmetric.NewGauge(kubetypes.MetricEgressServiceCount)
@@ -125,7 +123,7 @@ func (esr *egressSvcsReconciler) Reconcile(ctx context.Context, req reconcile.Re
oldStatus := svc.Status.DeepCopy()
defer func() {
if !apiequality.Semantic.DeepEqual(oldStatus, &svc.Status) {
if !apiequality.Semantic.DeepEqual(oldStatus, svc.Status) {
err = errors.Join(err, esr.Status().Update(ctx, svc))
}
}()
@@ -138,8 +136,9 @@ func (esr *egressSvcsReconciler) Reconcile(ctx context.Context, req reconcile.Re
}
if !slices.Contains(svc.Finalizers, FinalizerName) {
l.Infof("configuring tailnet service") // logged exactly once
svc.Finalizers = append(svc.Finalizers, FinalizerName)
if err := esr.updateSvcSpec(ctx, svc); err != nil {
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)
@@ -158,15 +157,7 @@ func (esr *egressSvcsReconciler) Reconcile(ctx context.Context, req reconcile.Re
return res, err
}
if err := esr.maybeProvision(ctx, svc, l); err != nil {
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
l.Infof("optimistic lock error, retrying: %s", err)
} else {
return reconcile.Result{}, err
}
}
return res, nil
return res, esr.maybeProvision(ctx, svc, l)
}
func (esr *egressSvcsReconciler) maybeProvision(ctx context.Context, svc *corev1.Service, l *zap.SugaredLogger) (err error) {
@@ -207,7 +198,7 @@ func (esr *egressSvcsReconciler) maybeProvision(ctx context.Context, svc *corev1
if svc.Spec.ExternalName != clusterIPSvcFQDN {
l.Infof("Configuring ExternalName Service to point to ClusterIP Service %s", clusterIPSvcFQDN)
svc.Spec.ExternalName = clusterIPSvcFQDN
if err = esr.updateSvcSpec(ctx, svc); err != nil {
if err = esr.Update(ctx, svc); err != nil {
err = fmt.Errorf("error updating ExternalName Service: %w", err)
return err
}
@@ -231,16 +222,6 @@ func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName s
found := false
for _, wantsPM := range svc.Spec.Ports {
if wantsPM.Port == pm.Port && strings.EqualFold(string(wantsPM.Protocol), string(pm.Protocol)) {
// We want to both preserve the user set port names for ease of debugging, but also
// ensure that we name all unnamed ports as the ClusterIP Service that we create will
// always have at least two ports.
// https://kubernetes.io/docs/concepts/services-networking/service/#multi-port-services
// See also https://github.com/tailscale/tailscale/issues/13406#issuecomment-2507230388
if wantsPM.Name != "" {
clusterIPSvc.Spec.Ports[i].Name = wantsPM.Name
} else {
clusterIPSvc.Spec.Ports[i].Name = "tailscale-unnamed"
}
found = true
break
}
@@ -255,12 +236,6 @@ func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName s
// ClusterIP Service produce new target port and add a portmapping to
// the ClusterIP Service.
for _, wantsPM := range svc.Spec.Ports {
// Because we add a healthcheck port of our own, we will always have at least two ports. That
// means that we cannot have ports with name not set.
// https://kubernetes.io/docs/concepts/services-networking/service/#multi-port-services
if wantsPM.Name == "" {
wantsPM.Name = "tailscale-unnamed"
}
found := false
for _, gotPM := range clusterIPSvc.Spec.Ports {
if wantsPM.Port == gotPM.Port && strings.EqualFold(string(wantsPM.Protocol), string(gotPM.Protocol)) {
@@ -271,7 +246,7 @@ func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName s
if !found {
// Calculate a free port to expose on container and add
// a new PortMap to the ClusterIP Service.
if usedPorts.Len() >= maxPorts {
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)
@@ -287,25 +262,6 @@ func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName s
})
}
}
var healthCheckPort int32 = defaultLocalAddrPort
for {
if !slices.ContainsFunc(svc.Spec.Ports, func(p corev1.ServicePort) bool {
return p.Port == healthCheckPort
}) {
break
}
healthCheckPort++
if healthCheckPort > 10002 {
return nil, false, fmt.Errorf("unable to find a free port for internal health check in range [9002, 10002]")
}
}
clusterIPSvc.Spec.Ports = append(clusterIPSvc.Spec.Ports, corev1.ServicePort{
Name: tsHealthCheckPortName,
Port: healthCheckPort,
TargetPort: intstr.FromInt(defaultLocalAddrPort),
Protocol: "TCP",
})
if !reflect.DeepEqual(clusterIPSvc, oldClusterIPSvc) {
if clusterIPSvc, err = createOrUpdate(ctx, esr.Client, esr.tsNamespace, clusterIPSvc, func(svc *corev1.Service) {
svc.Labels = clusterIPSvc.Labels
@@ -348,7 +304,7 @@ func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName s
}
tailnetSvc := tailnetSvcName(svc)
gotCfg := (*cfgs)[tailnetSvc]
wantsCfg := egressSvcCfg(svc, clusterIPSvc, esr.tsNamespace, l)
wantsCfg := egressSvcCfg(svc, clusterIPSvc)
if !reflect.DeepEqual(gotCfg, wantsCfg) {
l.Debugf("updating egress services ConfigMap %s", cm.Name)
mak.Set(cfgs, tailnetSvc, wantsCfg)
@@ -523,6 +479,13 @@ func (esr *egressSvcsReconciler) validateClusterResources(ctx context.Context, s
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)
@@ -531,34 +494,11 @@ func (esr *egressSvcsReconciler) validateClusterResources(ctx context.Context, s
tsoperator.RemoveServiceCondition(svc, tsapi.EgressSvcConfigured)
return false, nil
}
if !tsoperator.ProxyGroupIsReady(pg) {
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionUnknown, reasonProxyGroupNotReady, reasonProxyGroupNotReady, esr.clock, l)
tsoperator.RemoveServiceCondition(svc, tsapi.EgressSvcConfigured)
}
l.Debugf("egress service is valid")
tsoperator.SetServiceCondition(svc, tsapi.EgressSvcValid, metav1.ConditionTrue, reasonEgressSvcValid, reasonEgressSvcValid, esr.clock, l)
return true, nil
}
func egressSvcCfg(externalNameSvc, clusterIPSvc *corev1.Service, ns string, l *zap.SugaredLogger) egressservices.Config {
d := retrieveClusterDomain(ns, l)
tt := tailnetTargetFromSvc(externalNameSvc)
hep := healthCheckForSvc(clusterIPSvc, d)
cfg := egressservices.Config{
TailnetTarget: tt,
HealthCheckEndpoint: hep,
}
for _, svcPort := range clusterIPSvc.Spec.Ports {
if svcPort.Name == tsHealthCheckPortName {
continue // exclude healthcheck from egress svcs configs
}
pm := portMap(svcPort)
mak.Set(&cfg.Ports, pm, struct{}{})
}
return cfg
}
func validateEgressService(svc *corev1.Service, pg *tsapi.ProxyGroup) []string {
violations := validateService(svc)
@@ -600,13 +540,13 @@ func svcNameBase(s string) string {
}
}
// unusedPort returns a port in range [10000 - 11000). The caller must ensure that
// usedPorts does not contain all ports in range [10000 - 11000).
// 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) + 10000
suggestPort = rand.Int32N(maxPorts) + 3000
if !usedPorts.Has(suggestPort) {
foundFreePort = true
}
@@ -628,13 +568,19 @@ func tailnetTargetFromSvc(svc *corev1.Service) egressservices.TailnetTarget {
}
}
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),
}
return egressservices.PortMap{Protocol: string(p.Protocol), MatchPort: uint16(p.TargetPort.IntVal), TargetPort: uint16(p.Port)}
}
func isEgressSvcForProxyGroup(obj client.Object) bool {
@@ -656,11 +602,7 @@ func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, ts
Namespace: tsNamespace,
},
}
err = cl.Get(ctx, client.ObjectKeyFromObject(cm), cm)
if apierrors.IsNotFound(err) { // ProxyGroup resources have not been created (yet)
return nil, nil, nil
}
if err != nil {
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{}
@@ -772,27 +714,3 @@ func epsPortsFromSvc(svc *corev1.Service) (ep []discoveryv1.EndpointPort) {
}
return ep
}
// updateSvcSpec ensures that the given Service's spec is updated in cluster, but the local Service object still retains
// the not-yet-applied status.
// TODO(irbekrm): once we do SSA for these patch updates, this will no longer be needed.
func (esr *egressSvcsReconciler) updateSvcSpec(ctx context.Context, svc *corev1.Service) error {
st := svc.Status.DeepCopy()
err := esr.Update(ctx, svc)
svc.Status = *st
return err
}
// healthCheckForSvc return the URL of the containerboot's health check endpoint served by this Service or empty string.
func healthCheckForSvc(svc *corev1.Service, clusterDomain string) string {
// This version of the operator always sets health check port on the egress Services. However, it is possible
// that this reconcile loops runs during a proxy upgrade from a version that did not set the health check port
// and parses a Service that does not have the port set yet.
i := slices.IndexFunc(svc.Spec.Ports, func(port corev1.ServicePort) bool {
return port.Name == tsHealthCheckPortName
})
if i == -1 {
return ""
}
return fmt.Sprintf("http://%s.%s.svc.%s:%d/healthz", svc.Name, svc.Namespace, clusterDomain, svc.Spec.Ports[i].Port)
}

View File

@@ -18,7 +18,6 @@ import (
discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
@@ -79,41 +78,55 @@ func TestTailscaleEgressServices(t *testing.T) {
Selector: nil,
Ports: []corev1.ServicePort{
{
Name: "http",
Protocol: "TCP",
Port: 80,
},
{
Name: "https",
Protocol: "TCP",
Port: 443,
},
},
},
}
t.Run("service_one_unnamed_port", func(t *testing.T) {
t.Run("proxy_group_not_ready", func(t *testing.T) {
mustCreate(t, fc, svc)
expectReconciled(t, esr, "default", "test")
validateReadyService(t, fc, esr, svc, clock, zl, cm)
// 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("service_add_two_named_ports", func(t *testing.T) {
svc.Spec.Ports = []corev1.ServicePort{{Protocol: "TCP", Port: 80, Name: "http"}, {Protocol: "TCP", Port: 443, Name: "https"}}
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.Spec.Ports = svc.Spec.Ports
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")
validateReadyService(t, fc, esr, svc, clock, zl, cm)
})
t.Run("service_add_udp_port", func(t *testing.T) {
svc.Spec.Ports = append(svc.Spec.Ports, corev1.ServicePort{Port: 53, Protocol: "UDP", Name: "dns"})
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.Spec.Ports = svc.Spec.Ports
})
expectReconciled(t, esr, "default", "test")
validateReadyService(t, fc, esr, svc, clock, zl, cm)
})
t.Run("service_change_protocol", func(t *testing.T) {
svc.Spec.Ports = []corev1.ServicePort{{Protocol: "TCP", Port: 80, Name: "http"}, {Protocol: "TCP", Port: 443, Name: "https"}, {Port: 53, Protocol: "TCP", Name: "tcp_dns"}}
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.Spec.Ports = svc.Spec.Ports
})
expectReconciled(t, esr, "default", "test")
validateReadyService(t, fc, esr, svc, clock, zl, cm)
// 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) {
@@ -130,29 +143,6 @@ func TestTailscaleEgressServices(t *testing.T) {
})
}
func validateReadyService(t *testing.T, fc client.WithWatch, esr *egressSvcsReconciler, svc *corev1.Service, clock *tstest.Clock, zl *zap.Logger, cm *corev1.ConfigMap) {
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))
// Verify that ConfigMap contains configuration for the new egress service.
mustHaveConfigForSvc(t, fc, svc, clusterSvc, cm, zl)
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.EgressSvcValid, metav1.ConditionTrue, "EgressSvcValid", "EgressSvcValid", clock),
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)
}
func condition(typ tsapi.ConditionType, st metav1.ConditionStatus, r, msg string, clock tstime.Clock) metav1.Condition {
return metav1.Condition{
Type: string(typ),
@@ -178,23 +168,6 @@ func findGenNameForEgressSvcResources(t *testing.T, client client.Client, svc *c
func clusterIPSvc(name string, extNSvc *corev1.Service) *corev1.Service {
labels := egressSvcChildResourceLabels(extNSvc)
ports := make([]corev1.ServicePort, len(extNSvc.Spec.Ports))
for i, port := range extNSvc.Spec.Ports {
ports[i] = corev1.ServicePort{ // Copy the port to avoid modifying the original.
Name: port.Name,
Port: port.Port,
Protocol: port.Protocol,
}
if port.Name == "" {
ports[i].Name = "tailscale-unnamed"
}
}
ports = append(ports, corev1.ServicePort{
Name: "tailscale-health-check",
Port: 9002,
TargetPort: intstr.FromInt(9002),
Protocol: "TCP",
})
return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: name,
@@ -204,7 +177,7 @@ func clusterIPSvc(name string, extNSvc *corev1.Service) *corev1.Service {
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Ports: ports,
Ports: extNSvc.Spec.Ports,
},
}
}
@@ -249,9 +222,9 @@ func portsForEndpointSlice(svc *corev1.Service) []discoveryv1.EndpointPort {
return ports
}
func mustHaveConfigForSvc(t *testing.T, cl client.Client, extNSvc, clusterIPSvc *corev1.Service, cm *corev1.ConfigMap, l *zap.Logger) {
func mustHaveConfigForSvc(t *testing.T, cl client.Client, extNSvc, clusterIPSvc *corev1.Service, cm *corev1.ConfigMap) {
t.Helper()
wantsCfg := egressSvcCfg(extNSvc, clusterIPSvc, clusterIPSvc.Namespace, l.Sugar())
wantsCfg := egressSvcCfg(extNSvc, clusterIPSvc)
if err := cl.Get(context.Background(), client.ObjectKeyFromObject(cm), cm); err != nil {
t.Fatalf("Error retrieving ConfigMap: %v", err)
}

Some files were not shown because too many files have changed in this diff Show More