Merge branch 'main' into dylan/derp-hosting-provider

This commit is contained in:
Dylan Bargatze 2025-06-16 17:13:31 -04:00
commit 058a44558d
No known key found for this signature in database
33 changed files with 1228 additions and 399 deletions

View File

@ -55,7 +55,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
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@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
uses: github/codeql-action/autobuild@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
# 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@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0

View File

@ -15,6 +15,10 @@ env:
# - false: we expect fuzzing to be happy, and should report failure if it's not.
# - true: we expect fuzzing is broken, and should report failure if it start working.
TS_FUZZ_CURRENTLY_BROKEN: false
# GOMODCACHE is the same definition on all OSes. Within the workspace, we use
# toplevel directories "src" (for the checked out source code), and "gomodcache"
# and other caches as siblings to follow.
GOMODCACHE: ${{ github.workspace }}/gomodcache
on:
push:
@ -38,8 +42,42 @@ concurrency:
cancel-in-progress: true
jobs:
gomod-cache:
runs-on: ubuntu-24.04
outputs:
cache-key: ${{ steps.hash.outputs.key }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Compute cache key from go.{mod,sum}
id: hash
run: echo "key=gomod-cross3-${{ hashFiles('src/go.mod', 'src/go.sum') }}" >> $GITHUB_OUTPUT
# See if the cache entry already exists to avoid downloading it
# and doing the cache write again.
- id: check-cache
uses: actions/cache/restore@v4
with:
path: gomodcache # relative to workspace; see env note at top of file
key: ${{ steps.hash.outputs.key }}
lookup-only: true
enableCrossOsArchive: true
- name: Download modules
if: steps.check-cache.outputs.cache-hit != 'true'
working-directory: src
run: go mod download
- name: Cache Go modules
if: steps.check-cache.outputs.cache-hit != 'true'
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache # relative to workspace; see env note at top of file
key: ${{ steps.hash.outputs.key }}
enableCrossOsArchive: true
race-root-integration:
runs-on: ubuntu-24.04
needs: gomod-cache
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
@ -51,9 +89,19 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: build test wrapper
working-directory: src
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: integration tests as root
working-directory: src
run: PATH=$PWD/tool:$PATH /tmp/testwrapper -exec "sudo -E" -race ./tstest/integration/
env:
TS_TEST_SHARD: ${{ matrix.shard }}
@ -75,9 +123,18 @@ jobs:
shard: '3/3'
- goarch: "386" # thanks yaml
runs-on: ubuntu-24.04
needs: gomod-cache
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: Restore Cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
@ -87,7 +144,6 @@ jobs:
# fetched and extracted by tar
path: |
~/.cache/go-build
~/go/pkg/mod/cache
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
@ -97,11 +153,13 @@ jobs:
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-
- name: build all
if: matrix.buildflags == '' # skip on race builder
working-directory: src
run: ./tool/go build ${{matrix.buildflags}} ./...
env:
GOARCH: ${{ matrix.goarch }}
- name: build variant CLIs
if: matrix.buildflags == '' # skip on race builder
working-directory: src
run: |
export TS_USE_TOOLCHAIN=1
./build_dist.sh --extra-small ./cmd/tailscaled
@ -116,19 +174,24 @@ jobs:
sudo apt-get -y update
sudo apt-get -y install qemu-user
- name: build test wrapper
working-directory: src
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: test all
working-directory: src
run: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
env:
GOARCH: ${{ matrix.goarch }}
TS_TEST_SHARD: ${{ matrix.shard }}
- name: bench all
working-directory: src
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
env:
GOARCH: ${{ matrix.goarch }}
- name: check that no tracked files changed
working-directory: src
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
- name: check that no new files were added
working-directory: src
run: |
# Note: The "error: pathspec..." you see below is normal!
# In the success case in which there are no new untracked files,
@ -140,22 +203,33 @@ jobs:
exit 1
fi
- name: Tidy cache
working-directory: src
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
needs: gomod-cache
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Install Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version-file: go.mod
go-version-file: src/go.mod
cache: false
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: Restore Cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
@ -165,7 +239,6 @@ jobs:
# fetched and extracted by tar
path: |
~/.cache/go-build
~/go/pkg/mod/cache
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
@ -174,19 +247,22 @@ jobs:
${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-go-2-
- name: test
working-directory: src
run: go run ./cmd/testwrapper ./...
- name: bench all
working-directory: src
# Don't use -bench=. -benchtime=1x.
# Somewhere in the layers (powershell?)
# the equals signs cause great confusion.
run: go test ./... -bench . -benchtime 1x -run "^$"
- name: Tidy cache
working-directory: src
shell: bash
run: |
find $(go env GOCACHE) -type f -mmin +90 -delete
find $(go env GOMODCACHE)/cache -type f -mmin +90 -delete
privileged:
needs: gomod-cache
runs-on: ubuntu-24.04
container:
image: golang:latest
@ -194,36 +270,47 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: chown
working-directory: src
run: chown -R $(id -u):$(id -g) $PWD
- name: privileged tests
working-directory: src
run: ./tool/go test ./util/linuxfw ./derp/xdp
vm:
needs: gomod-cache
runs-on: ["self-hosted", "linux", "vm"]
# VM tests run with some privileges, don't let them run on 3p PRs.
if: github.repository == 'tailscale/tailscale'
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: Run VM tests
working-directory: src
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
env:
HOME: "/var/lib/ghrunner/home"
TMPDIR: "/tmp"
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
race-build:
runs-on: ubuntu-24.04
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: build all
run: ./tool/go install -race ./cmd/...
- name: build tests
run: ./tool/go test -race -exec=true ./...
cross: # cross-compile checks, build only.
needs: gomod-cache
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
@ -262,6 +349,8 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Restore Cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
@ -271,7 +360,6 @@ jobs:
# fetched and extracted by tar
path: |
~/.cache/go-build
~/go/pkg/mod/cache
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
@ -279,7 +367,14 @@ jobs:
restore-keys: |
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: build all
working-directory: src
run: ./tool/go build ./cmd/...
env:
GOOS: ${{ matrix.goos }}
@ -287,30 +382,42 @@ jobs:
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: "0"
- name: build tests
working-directory: src
run: ./tool/go test -exec=true ./...
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
- name: Tidy cache
working-directory: src
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.
# make it build a few smoke packages.
runs-on: ubuntu-24.04
needs: gomod-cache
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: build some
working-directory: src
run: ./tool/go build ./ipn/... ./ssh/tailssh ./wgengine/ ./types/... ./control/controlclient
env:
GOOS: ios
GOARCH: arm64
crossmin: # cross-compile for platforms where we only check cmd/tailscale{,d}
needs: gomod-cache
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
@ -332,6 +439,8 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Restore Cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
@ -341,7 +450,6 @@ jobs:
# fetched and extracted by tar
path: |
~/.cache/go-build
~/go/pkg/mod/cache
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
@ -349,7 +457,14 @@ jobs:
restore-keys: |
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: build core
working-directory: src
run: ./tool/go build ./cmd/tailscale ./cmd/tailscaled
env:
GOOS: ${{ matrix.goos }}
@ -357,24 +472,34 @@ jobs:
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: "0"
- name: Tidy cache
working-directory: src
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
# repo. We should fix those pieces, they're small, but as a stepping stone,
# only test the subset of android that our past smoke test checked.
runs-on: ubuntu-24.04
needs: gomod-cache
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
# Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed
# and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch
# some Android breakages early.
# TODO(bradfitz): better; see https://github.com/tailscale/tailscale/issues/4482
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: build some
working-directory: src
run: ./tool/go install ./net/netns ./ipn/ipnlocal ./wgengine/magicsock/ ./wgengine/ ./wgengine/router/ ./wgengine/netstack ./util/dnsname/ ./ipn/ ./net/netmon ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version ./ssh/tailssh
env:
GOOS: android
@ -382,9 +507,12 @@ jobs:
wasm: # builds tsconnect, which is the only wasm build we support
runs-on: ubuntu-24.04
needs: gomod-cache
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Restore Cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
@ -394,7 +522,6 @@ jobs:
# fetched and extracted by tar
path: |
~/.cache/go-build
~/go/pkg/mod/cache
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
@ -402,28 +529,45 @@ jobs:
restore-keys: |
${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-go-2-
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: build tsconnect client
working-directory: src
run: ./tool/go build ./cmd/tsconnect/wasm ./cmd/tailscale/cli
env:
GOOS: js
GOARCH: wasm
- name: build tsconnect server
working-directory: src
# Note, no GOOS/GOARCH in env on this build step, we're running a build
# tool that handles the build itself.
run: |
./tool/go run ./cmd/tsconnect --fast-compression build
./tool/go run ./cmd/tsconnect --fast-compression build-pkg
- name: Tidy cache
working-directory: src
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-24.04
needs: gomod-cache
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set GOMODCACHE env
run: echo "GOMODCACHE=$HOME/.cache/go-mod" >> $GITHUB_ENV
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: test tailscale_go
run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/...
@ -477,7 +621,7 @@ jobs:
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master
with:
oss-fuzz-project-name: 'tailscale'
fuzz-seconds: 300
fuzz-seconds: 150
dry-run: false
language: go
- name: Set artifacts_path in env (workaround for actions/upload-artifact#176)
@ -493,19 +637,40 @@ jobs:
depaware:
runs-on: ubuntu-24.04
needs: gomod-cache
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Set GOMODCACHE env
run: echo "GOMODCACHE=$HOME/.cache/go-mod" >> $GITHUB_ENV
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: check depaware
run: |
make depaware
working-directory: src
run: make depaware
go_generate:
runs-on: ubuntu-24.04
needs: gomod-cache
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: check that 'go generate' is clean
working-directory: src
run: |
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator|xdp')
./tool/go generate $pkgs
@ -515,10 +680,20 @@ jobs:
go_mod_tidy:
runs-on: ubuntu-24.04
needs: gomod-cache
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: check that 'go mod tidy' is clean
working-directory: src
run: |
./tool/go mod tidy
echo
@ -527,14 +702,27 @@ jobs:
licenses:
runs-on: ubuntu-24.04
needs: gomod-cache
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: src
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: check licenses
run: ./scripts/check_license_headers.sh .
working-directory: src
run: |
grep -q TestLicenseHeaders *.go || (echo "Expected a test named TestLicenseHeaders"; exit 1)
./tool/go test -v -run=TestLicenseHeaders
staticcheck:
runs-on: ubuntu-24.04
needs: gomod-cache
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
@ -546,16 +734,22 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: install staticcheck
run: GOBIN=~/.local/bin ./tool/go install honnef.co/go/tools/cmd/staticcheck
with:
path: src
- name: Restore Go module cache
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: run staticcheck
working-directory: src
run: |
export GOROOT=$(./tool/go env GOROOT)
export PATH=$GOROOT/bin:$PATH
staticcheck -- $(./tool/go list ./... | grep -v tempfork)
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
./tool/go run -exec \
"env GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }}" \
honnef.co/go/tools/cmd/staticcheck -- \
$(env GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} ./tool/go list ./... | grep -v tempfork)
notify_slack:
if: always()

View File

@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package chirp
import (

View File

@ -1,12 +1,11 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.22
// Package local contains a Go client for the Tailscale LocalAPI.
package local
import (
"bufio"
"bytes"
"cmp"
"context"
@ -16,6 +15,7 @@ import (
"errors"
"fmt"
"io"
"iter"
"net"
"net/http"
"net/http/httptrace"
@ -42,6 +42,7 @@ import (
"tailscale.com/types/dnstype"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
"tailscale.com/util/eventbus"
"tailscale.com/util/syspolicy/setting"
)
@ -414,24 +415,42 @@ func (lc *Client) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
return res.Body, nil
}
// StreamBusEvents returns a stream of the Tailscale bus events as they arrive.
// Close the context to stop the stream.
// Expected response from the server is newline-delimited JSON.
// The caller must close the reader when it is finished reading.
func (lc *Client) StreamBusEvents(ctx context.Context) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, "GET",
"http://"+apitype.LocalAPIHost+"/localapi/v0/debug-bus-events", nil)
if err != nil {
return nil, err
// StreamBusEvents returns an iterator of Tailscale bus events as they arrive.
// Each pair is a valid event and a nil error, or a zero event a non-nil error.
// In case of error, the iterator ends after the pair reporting the error.
// Iteration stops if ctx ends.
func (lc *Client) StreamBusEvents(ctx context.Context) iter.Seq2[eventbus.DebugEvent, error] {
return func(yield func(eventbus.DebugEvent, error) bool) {
req, err := http.NewRequestWithContext(ctx, "GET",
"http://"+apitype.LocalAPIHost+"/localapi/v0/debug-bus-events", nil)
if err != nil {
yield(eventbus.DebugEvent{}, err)
return
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
yield(eventbus.DebugEvent{}, err)
return
}
if res.StatusCode != http.StatusOK {
yield(eventbus.DebugEvent{}, errors.New(res.Status))
return
}
defer res.Body.Close()
dec := json.NewDecoder(bufio.NewReader(res.Body))
for {
var evt eventbus.DebugEvent
if err := dec.Decode(&evt); err == io.EOF {
return
} else if err != nil {
yield(eventbus.DebugEvent{}, err)
return
}
if !yield(evt, nil) {
return
}
}
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK {
return nil, errors.New(res.Status)
}
return res.Body, nil
}
// Pprof returns a pprof profile of the Tailscale daemon.

View File

@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (

View File

@ -157,7 +157,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/eventbus from tailscale.com/net/netmon
tailscale.com/util/eventbus from tailscale.com/net/netmon+
💣 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+

View File

@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (

View File

@ -252,7 +252,7 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
return false, fmt.Errorf("error determining DNS name base: %w", err)
}
dnsName := hostname + "." + tcd
if err := r.ensureCertResources(ctx, pgName, dnsName, ing); err != nil {
if err := r.ensureCertResources(ctx, pg, dnsName, ing); err != nil {
return false, fmt.Errorf("error ensuring cert resources: %w", err)
}
@ -931,18 +931,31 @@ func ownersAreSetAndEqual(a, b *tailscale.VIPService) bool {
// (domain) is a valid Kubernetes resource name.
// https://github.com/tailscale/tailscale/blob/8b1e7f646ee4730ad06c9b70c13e7861b964949b/util/dnsname/dnsname.go#L99
// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names
func (r *HAIngressReconciler) ensureCertResources(ctx context.Context, pgName, domain string, ing *networkingv1.Ingress) error {
secret := certSecret(pgName, r.tsNamespace, domain, ing)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, secret, nil); err != nil {
func (r *HAIngressReconciler) ensureCertResources(ctx context.Context, pg *tsapi.ProxyGroup, domain string, ing *networkingv1.Ingress) error {
secret := certSecret(pg.Name, r.tsNamespace, domain, ing)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, secret, func(s *corev1.Secret) {
// Labels might have changed if the Ingress has been updated to use a
// different ProxyGroup.
s.Labels = secret.Labels
}); err != nil {
return fmt.Errorf("failed to create or update Secret %s: %w", secret.Name, err)
}
role := certSecretRole(pgName, r.tsNamespace, domain)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, role, nil); err != nil {
role := certSecretRole(pg.Name, r.tsNamespace, domain)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, role, func(r *rbacv1.Role) {
// Labels might have changed if the Ingress has been updated to use a
// different ProxyGroup.
r.Labels = role.Labels
}); err != nil {
return fmt.Errorf("failed to create or update Role %s: %w", role.Name, err)
}
rb := certSecretRoleBinding(pgName, r.tsNamespace, domain)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, rb, nil); err != nil {
return fmt.Errorf("failed to create or update RoleBinding %s: %w", rb.Name, err)
rolebinding := certSecretRoleBinding(pg.Name, r.tsNamespace, domain)
if _, err := createOrUpdate(ctx, r.Client, r.tsNamespace, rolebinding, func(rb *rbacv1.RoleBinding) {
// Labels and subjects might have changed if the Ingress has been updated to use a
// different ProxyGroup.
rb.Labels = rolebinding.Labels
rb.Subjects = rolebinding.Subjects
}); err != nil {
return fmt.Errorf("failed to create or update RoleBinding %s: %w", rolebinding.Name, err)
}
return nil
}

View File

@ -69,7 +69,7 @@ func TestIngressPGReconciler(t *testing.T) {
expectReconciled(t, ingPGR, "default", "test-ingress")
verifyServeConfig(t, fc, "svc:my-svc", false)
verifyTailscaleService(t, ft, "svc:my-svc", []string{"tcp:443"})
verifyTailscaledConfig(t, fc, []string{"svc:my-svc"})
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:my-svc"})
// Verify that Role and RoleBinding have been created for the first Ingress.
// Do not verify the cert Secret as that was already verified implicitly above.
@ -132,7 +132,7 @@ func TestIngressPGReconciler(t *testing.T) {
verifyServeConfig(t, fc, "svc:my-other-svc", false)
verifyTailscaleService(t, ft, "svc:my-other-svc", []string{"tcp:443"})
// Verify that Role and RoleBinding have been created for the first Ingress.
// Verify that Role and RoleBinding have been created for the second Ingress.
// Do not verify the cert Secret as that was already verified implicitly above.
expectEqual(t, fc, certSecretRole("test-pg", "operator-ns", "my-other-svc.ts.net"))
expectEqual(t, fc, certSecretRoleBinding("test-pg", "operator-ns", "my-other-svc.ts.net"))
@ -141,7 +141,7 @@ func TestIngressPGReconciler(t *testing.T) {
verifyServeConfig(t, fc, "svc:my-svc", false)
verifyTailscaleService(t, ft, "svc:my-svc", []string{"tcp:443"})
verifyTailscaledConfig(t, fc, []string{"svc:my-svc", "svc:my-other-svc"})
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:my-svc", "svc:my-other-svc"})
// Delete second Ingress
if err := fc.Delete(context.Background(), ing2); err != nil {
@ -172,11 +172,20 @@ func TestIngressPGReconciler(t *testing.T) {
t.Error("second Ingress service config was not cleaned up")
}
verifyTailscaledConfig(t, fc, []string{"svc:my-svc"})
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:my-svc"})
expectMissing[corev1.Secret](t, fc, "operator-ns", "my-other-svc.ts.net")
expectMissing[rbacv1.Role](t, fc, "operator-ns", "my-other-svc.ts.net")
expectMissing[rbacv1.RoleBinding](t, fc, "operator-ns", "my-other-svc.ts.net")
// Test Ingress ProxyGroup change
createPGResources(t, fc, "test-pg-second")
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
ing.Annotations["tailscale.com/proxy-group"] = "test-pg-second"
})
expectReconciled(t, ingPGR, "default", "test-ingress")
expectEqual(t, fc, certSecretRole("test-pg-second", "operator-ns", "my-svc.ts.net"))
expectEqual(t, fc, certSecretRoleBinding("test-pg-second", "operator-ns", "my-svc.ts.net"))
// Delete the first Ingress and verify cleanup
if err := fc.Delete(context.Background(), ing); err != nil {
t.Fatalf("deleting Ingress: %v", err)
@ -187,7 +196,7 @@ func TestIngressPGReconciler(t *testing.T) {
// Verify the ConfigMap was cleaned up
cm = &corev1.ConfigMap{}
if err := fc.Get(context.Background(), types.NamespacedName{
Name: "test-pg-ingress-config",
Name: "test-pg-second-ingress-config",
Namespace: "operator-ns",
}, cm); err != nil {
t.Fatalf("getting ConfigMap: %v", err)
@ -201,7 +210,7 @@ func TestIngressPGReconciler(t *testing.T) {
if len(cfg.Services) > 0 {
t.Error("serve config not cleaned up")
}
verifyTailscaledConfig(t, fc, nil)
verifyTailscaledConfig(t, fc, "test-pg-second", nil)
// Add verification that cert resources were cleaned up
expectMissing[corev1.Secret](t, fc, "operator-ns", "my-svc.ts.net")
@ -245,7 +254,7 @@ func TestIngressPGReconciler_UpdateIngressHostname(t *testing.T) {
expectReconciled(t, ingPGR, "default", "test-ingress")
verifyServeConfig(t, fc, "svc:my-svc", false)
verifyTailscaleService(t, ft, "svc:my-svc", []string{"tcp:443"})
verifyTailscaledConfig(t, fc, []string{"svc:my-svc"})
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:my-svc"})
// Update the Ingress hostname and make sure the original Tailscale Service is deleted.
mustUpdate(t, fc, "default", "test-ingress", func(ing *networkingv1.Ingress) {
@ -256,7 +265,7 @@ func TestIngressPGReconciler_UpdateIngressHostname(t *testing.T) {
expectReconciled(t, ingPGR, "default", "test-ingress")
verifyServeConfig(t, fc, "svc:updated-svc", false)
verifyTailscaleService(t, ft, "svc:updated-svc", []string{"tcp:443"})
verifyTailscaledConfig(t, fc, []string{"svc:updated-svc"})
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:updated-svc"})
_, err := ft.GetVIPService(context.Background(), tailcfg.ServiceName("svc:my-svc"))
if err == nil {
@ -550,183 +559,6 @@ func TestIngressPGReconciler_HTTPEndpoint(t *testing.T) {
}
}
func verifyTailscaleService(t *testing.T, ft *fakeTSClient, serviceName string, wantPorts []string) {
t.Helper()
tsSvc, err := ft.GetVIPService(context.Background(), tailcfg.ServiceName(serviceName))
if err != nil {
t.Fatalf("getting Tailscale Service %q: %v", serviceName, err)
}
if tsSvc == nil {
t.Fatalf("Tailscale Service %q not created", serviceName)
}
gotPorts := slices.Clone(tsSvc.Ports)
slices.Sort(gotPorts)
slices.Sort(wantPorts)
if !slices.Equal(gotPorts, wantPorts) {
t.Errorf("incorrect ports for Tailscale Service %q: got %v, want %v", serviceName, gotPorts, wantPorts)
}
}
func verifyServeConfig(t *testing.T, fc client.Client, serviceName string, wantHTTP bool) {
t.Helper()
cm := &corev1.ConfigMap{}
if err := fc.Get(context.Background(), types.NamespacedName{
Name: "test-pg-ingress-config",
Namespace: "operator-ns",
}, cm); err != nil {
t.Fatalf("getting ConfigMap: %v", err)
}
cfg := &ipn.ServeConfig{}
if err := json.Unmarshal(cm.BinaryData["serve-config.json"], cfg); err != nil {
t.Fatalf("unmarshaling serve config: %v", err)
}
t.Logf("Looking for service %q in config: %+v", serviceName, cfg)
svc := cfg.Services[tailcfg.ServiceName(serviceName)]
if svc == nil {
t.Fatalf("service %q not found in serve config, services: %+v", serviceName, maps.Keys(cfg.Services))
}
wantHandlers := 1
if wantHTTP {
wantHandlers = 2
}
// Check TCP handlers
if len(svc.TCP) != wantHandlers {
t.Errorf("incorrect number of TCP handlers for service %q: got %d, want %d", serviceName, len(svc.TCP), wantHandlers)
}
if wantHTTP {
if h, ok := svc.TCP[uint16(80)]; !ok {
t.Errorf("HTTP (port 80) handler not found for service %q", serviceName)
} else if !h.HTTP {
t.Errorf("HTTP not enabled for port 80 handler for service %q", serviceName)
}
}
if h, ok := svc.TCP[uint16(443)]; !ok {
t.Errorf("HTTPS (port 443) handler not found for service %q", serviceName)
} else if !h.HTTPS {
t.Errorf("HTTPS not enabled for port 443 handler for service %q", serviceName)
}
// Check Web handlers
if len(svc.Web) != wantHandlers {
t.Errorf("incorrect number of Web handlers for service %q: got %d, want %d", serviceName, len(svc.Web), wantHandlers)
}
}
func verifyTailscaledConfig(t *testing.T, fc client.Client, expectedServices []string) {
t.Helper()
var expected string
if expectedServices != nil && len(expectedServices) > 0 {
expectedServicesJSON, err := json.Marshal(expectedServices)
if err != nil {
t.Fatalf("marshaling expected services: %v", err)
}
expected = fmt.Sprintf(`,"AdvertiseServices":%s`, expectedServicesJSON)
}
expectEqual(t, fc, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: pgConfigSecretName("test-pg", 0),
Namespace: "operator-ns",
Labels: pgSecretLabels("test-pg", "config"),
},
Data: map[string][]byte{
tsoperator.TailscaledConfigFileName(106): []byte(fmt.Sprintf(`{"Version":""%s}`, expected)),
},
})
}
func setupIngressTest(t *testing.T) (*HAIngressReconciler, client.Client, *fakeTSClient) {
tsIngressClass := &networkingv1.IngressClass{
ObjectMeta: metav1.ObjectMeta{Name: "tailscale"},
Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"},
}
// Pre-create the ProxyGroup
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pg",
Generation: 1,
},
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress,
},
}
// Pre-create the ConfigMap for the ProxyGroup
pgConfigMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pg-ingress-config",
Namespace: "operator-ns",
},
BinaryData: map[string][]byte{
"serve-config.json": []byte(`{"Services":{}}`),
},
}
// Pre-create a config Secret for the ProxyGroup
pgCfgSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: pgConfigSecretName("test-pg", 0),
Namespace: "operator-ns",
Labels: pgSecretLabels("test-pg", "config"),
},
Data: map[string][]byte{
tsoperator.TailscaledConfigFileName(106): []byte("{}"),
},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(pg, pgCfgSecret, pgConfigMap, tsIngressClass).
WithStatusSubresource(pg).
Build()
// Set ProxyGroup status to ready
pg.Status.Conditions = []metav1.Condition{
{
Type: string(tsapi.ProxyGroupReady),
Status: metav1.ConditionTrue,
ObservedGeneration: 1,
},
}
if err := fc.Status().Update(context.Background(), pg); err != nil {
t.Fatal(err)
}
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
lc := &fakeLocalClient{
status: &ipnstate.Status{
CurrentTailnet: &ipnstate.TailnetStatus{
MagicDNSSuffix: "ts.net",
},
},
}
ingPGR := &HAIngressReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
tsNamespace: "operator-ns",
tsnetServer: fakeTsnetServer,
logger: zl.Sugar(),
recorder: record.NewFakeRecorder(10),
lc: lc,
}
return ingPGR, fc, ft
}
func TestIngressPGReconciler_MultiCluster(t *testing.T) {
ingPGR, fc, ft := setupIngressTest(t)
ingPGR.operatorID = "operator-1"
@ -837,3 +669,187 @@ func populateTLSSecret(ctx context.Context, c client.Client, pgName, domain stri
})
return err
}
func verifyTailscaleService(t *testing.T, ft *fakeTSClient, serviceName string, wantPorts []string) {
t.Helper()
tsSvc, err := ft.GetVIPService(context.Background(), tailcfg.ServiceName(serviceName))
if err != nil {
t.Fatalf("getting Tailscale Service %q: %v", serviceName, err)
}
if tsSvc == nil {
t.Fatalf("Tailscale Service %q not created", serviceName)
}
gotPorts := slices.Clone(tsSvc.Ports)
slices.Sort(gotPorts)
slices.Sort(wantPorts)
if !slices.Equal(gotPorts, wantPorts) {
t.Errorf("incorrect ports for Tailscale Service %q: got %v, want %v", serviceName, gotPorts, wantPorts)
}
}
func verifyServeConfig(t *testing.T, fc client.Client, serviceName string, wantHTTP bool) {
t.Helper()
cm := &corev1.ConfigMap{}
if err := fc.Get(context.Background(), types.NamespacedName{
Name: "test-pg-ingress-config",
Namespace: "operator-ns",
}, cm); err != nil {
t.Fatalf("getting ConfigMap: %v", err)
}
cfg := &ipn.ServeConfig{}
if err := json.Unmarshal(cm.BinaryData["serve-config.json"], cfg); err != nil {
t.Fatalf("unmarshaling serve config: %v", err)
}
t.Logf("Looking for service %q in config: %+v", serviceName, cfg)
svc := cfg.Services[tailcfg.ServiceName(serviceName)]
if svc == nil {
t.Fatalf("service %q not found in serve config, services: %+v", serviceName, maps.Keys(cfg.Services))
}
wantHandlers := 1
if wantHTTP {
wantHandlers = 2
}
// Check TCP handlers
if len(svc.TCP) != wantHandlers {
t.Errorf("incorrect number of TCP handlers for service %q: got %d, want %d", serviceName, len(svc.TCP), wantHandlers)
}
if wantHTTP {
if h, ok := svc.TCP[uint16(80)]; !ok {
t.Errorf("HTTP (port 80) handler not found for service %q", serviceName)
} else if !h.HTTP {
t.Errorf("HTTP not enabled for port 80 handler for service %q", serviceName)
}
}
if h, ok := svc.TCP[uint16(443)]; !ok {
t.Errorf("HTTPS (port 443) handler not found for service %q", serviceName)
} else if !h.HTTPS {
t.Errorf("HTTPS not enabled for port 443 handler for service %q", serviceName)
}
// Check Web handlers
if len(svc.Web) != wantHandlers {
t.Errorf("incorrect number of Web handlers for service %q: got %d, want %d", serviceName, len(svc.Web), wantHandlers)
}
}
func verifyTailscaledConfig(t *testing.T, fc client.Client, pgName string, expectedServices []string) {
t.Helper()
var expected string
if expectedServices != nil && len(expectedServices) > 0 {
expectedServicesJSON, err := json.Marshal(expectedServices)
if err != nil {
t.Fatalf("marshaling expected services: %v", err)
}
expected = fmt.Sprintf(`,"AdvertiseServices":%s`, expectedServicesJSON)
}
expectEqual(t, fc, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: pgConfigSecretName(pgName, 0),
Namespace: "operator-ns",
Labels: pgSecretLabels(pgName, "config"),
},
Data: map[string][]byte{
tsoperator.TailscaledConfigFileName(106): []byte(fmt.Sprintf(`{"Version":""%s}`, expected)),
},
})
}
func createPGResources(t *testing.T, fc client.Client, pgName string) {
t.Helper()
// Pre-create the ProxyGroup
pg := &tsapi.ProxyGroup{
ObjectMeta: metav1.ObjectMeta{
Name: pgName,
Generation: 1,
},
Spec: tsapi.ProxyGroupSpec{
Type: tsapi.ProxyGroupTypeIngress,
},
}
mustCreate(t, fc, pg)
// Pre-create the ConfigMap for the ProxyGroup
pgConfigMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-ingress-config", pgName),
Namespace: "operator-ns",
},
BinaryData: map[string][]byte{
"serve-config.json": []byte(`{"Services":{}}`),
},
}
mustCreate(t, fc, pgConfigMap)
// Pre-create a config Secret for the ProxyGroup
pgCfgSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: pgConfigSecretName(pgName, 0),
Namespace: "operator-ns",
Labels: pgSecretLabels(pgName, "config"),
},
Data: map[string][]byte{
tsoperator.TailscaledConfigFileName(106): []byte("{}"),
},
}
mustCreate(t, fc, pgCfgSecret)
pg.Status.Conditions = []metav1.Condition{
{
Type: string(tsapi.ProxyGroupReady),
Status: metav1.ConditionTrue,
ObservedGeneration: 1,
},
}
if err := fc.Status().Update(context.Background(), pg); err != nil {
t.Fatal(err)
}
}
func setupIngressTest(t *testing.T) (*HAIngressReconciler, client.Client, *fakeTSClient) {
tsIngressClass := &networkingv1.IngressClass{
ObjectMeta: metav1.ObjectMeta{Name: "tailscale"},
Spec: networkingv1.IngressClassSpec{Controller: "tailscale.com/ts-ingress"},
}
fc := fake.NewClientBuilder().
WithScheme(tsapi.GlobalScheme).
WithObjects(tsIngressClass).
WithStatusSubresource(&tsapi.ProxyGroup{}).
Build()
createPGResources(t, fc, "test-pg")
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
lc := &fakeLocalClient{
status: &ipnstate.Status{
CurrentTailnet: &ipnstate.TailnetStatus{
MagicDNSSuffix: "ts.net",
},
},
}
ingPGR := &HAIngressReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
tsNamespace: "operator-ns",
tsnetServer: fakeTsnetServer,
logger: zl.Sugar(),
recorder: record.NewFakeRecorder(10),
lc: lc,
}
return ingPGR, fc, ft
}

View File

@ -46,7 +46,7 @@ func TestServicePGReconciler(t *testing.T) {
config = append(config, fmt.Sprintf("svc:default-%s", svc.Name))
verifyTailscaleService(t, ft, fmt.Sprintf("svc:default-%s", svc.Name), []string{"do-not-validate"})
verifyTailscaledConfig(t, fc, config)
verifyTailscaledConfig(t, fc, "test-pg", config)
}
for i, svc := range svcs {
@ -75,7 +75,7 @@ func TestServicePGReconciler(t *testing.T) {
}
config = removeEl(config, fmt.Sprintf("svc:default-%s", svc.Name))
verifyTailscaledConfig(t, fc, config)
verifyTailscaledConfig(t, fc, "test-pg", config)
}
}
@ -88,7 +88,7 @@ func TestServicePGReconciler_UpdateHostname(t *testing.T) {
expectReconciled(t, svcPGR, "default", svc.Name)
verifyTailscaleService(t, ft, fmt.Sprintf("svc:default-%s", svc.Name), []string{"do-not-validate"})
verifyTailscaledConfig(t, fc, []string{fmt.Sprintf("svc:default-%s", svc.Name)})
verifyTailscaledConfig(t, fc, "test-pg", []string{fmt.Sprintf("svc:default-%s", svc.Name)})
hostname := "foobarbaz"
mustUpdate(t, fc, svc.Namespace, svc.Name, func(s *corev1.Service) {
@ -100,7 +100,7 @@ func TestServicePGReconciler_UpdateHostname(t *testing.T) {
expectReconciled(t, svcPGR, "default", svc.Name)
verifyTailscaleService(t, ft, fmt.Sprintf("svc:%s", hostname), []string{"do-not-validate"})
verifyTailscaledConfig(t, fc, []string{fmt.Sprintf("svc:%s", hostname)})
verifyTailscaledConfig(t, fc, "test-pg", []string{fmt.Sprintf("svc:%s", hostname)})
_, err := ft.GetVIPService(context.Background(), tailcfg.ServiceName(fmt.Sprintf("svc:default-%s", svc.Name)))
if err == nil {
@ -334,7 +334,7 @@ func TestIgnoreRegularService(t *testing.T) {
mustCreate(t, fc, svc)
expectReconciled(t, pgr, "default", "test")
verifyTailscaledConfig(t, fc, nil)
verifyTailscaledConfig(t, fc, "test-pg", nil)
tsSvcs, err := ft.ListVIPServices(context.Background())
if err == nil {

View File

@ -54,6 +54,7 @@ func main() {
hostname = fs.String("hostname", "", "Hostname to register the service under")
siteID = fs.Uint("site-id", 1, "an integer site ID to use for the ULA prefix which allows for multiple proxies to act in a HA configuration")
v4PfxStr = fs.String("v4-pfx", "100.64.1.0/24", "comma-separated list of IPv4 prefixes to advertise")
dnsServers = fs.String("dns-servers", "", "comma separated list of upstream DNS to use, including host and port (use system if empty)")
verboseTSNet = fs.Bool("verbose-tsnet", false, "enable verbose logging in tsnet")
printULA = fs.Bool("print-ula", false, "print the ULA prefix and exit")
ignoreDstPfxStr = fs.String("ignore-destinations", "", "comma-separated list of prefixes to ignore")
@ -78,7 +79,7 @@ func main() {
}
var ignoreDstTable *bart.Table[bool]
for _, s := range strings.Split(*ignoreDstPfxStr, ",") {
for s := range strings.SplitSeq(*ignoreDstPfxStr, ",") {
s := strings.TrimSpace(s)
if s == "" {
continue
@ -185,11 +186,37 @@ func main() {
ipPool: ipp,
routes: routes,
dnsAddr: dnsAddr,
resolver: net.DefaultResolver,
resolver: getResolver(*dnsServers),
}
c.run(ctx, lc)
}
// getResolver parses serverFlag and returns either the default resolver, or a
// resolver that uses the provided comma-separated DNS server AddrPort's, or
// panics.
func getResolver(serverFlag string) lookupNetIPer {
if serverFlag == "" {
return net.DefaultResolver
}
var addrs []string
for s := range strings.SplitSeq(serverFlag, ",") {
s = strings.TrimSpace(s)
addr, err := netip.ParseAddrPort(s)
if err != nil {
log.Fatalf("dns server provided: %q does not parse: %v", s, err)
}
addrs = append(addrs, addr.String())
}
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network string, address string) (net.Conn, error) {
var dialer net.Dialer
// TODO(raggi): perhaps something other than random?
return dialer.DialContext(ctx, network, addrs[rand.N(len(addrs))])
},
}
}
func calculateAddresses(prefixes []netip.Prefix) (*netipx.IPSet, netip.Addr, *netipx.IPSet) {
var ipsb netipx.IPSetBuilder
for _, p := range prefixes {

View File

@ -9,6 +9,7 @@ import (
"io"
"net"
"net/netip"
"sync"
"testing"
"time"
@ -480,3 +481,198 @@ func TestV6V4(t *testing.T) {
}
}
}
// echoServer is a simple server that just echos back data set to it.
type echoServer struct {
listener net.Listener
addr string
wg sync.WaitGroup
done chan struct{}
}
// newEchoServer creates a new test DNS server on the specified network and address
func newEchoServer(t *testing.T, network, addr string) *echoServer {
listener, err := net.Listen(network, addr)
if err != nil {
t.Fatalf("Failed to create test DNS server: %v", err)
}
server := &echoServer{
listener: listener,
addr: listener.Addr().String(),
done: make(chan struct{}),
}
server.wg.Add(1)
go server.serve()
return server
}
func (s *echoServer) serve() {
defer s.wg.Done()
for {
select {
case <-s.done:
return
default:
conn, err := s.listener.Accept()
if err != nil {
select {
case <-s.done:
return
default:
continue
}
}
go s.handleConnection(conn)
}
}
}
func (s *echoServer) handleConnection(conn net.Conn) {
defer conn.Close()
// Simple response - just echo back some data to confirm connectivity
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
return
}
conn.Write(buf[:n])
}
func (s *echoServer) close() {
close(s.done)
s.listener.Close()
s.wg.Wait()
}
func TestGetResolver(t *testing.T) {
tests := []struct {
name string
network string
addr string
}{
{
name: "ipv4_loopback",
network: "tcp4",
addr: "127.0.0.1:0",
},
{
name: "ipv6_loopback",
network: "tcp6",
addr: "[::1]:0",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := newEchoServer(t, tc.network, tc.addr)
defer server.close()
serverAddr := server.addr
resolver := getResolver(serverAddr)
if resolver == nil {
t.Fatal("getResolver returned nil")
}
netResolver, ok := resolver.(*net.Resolver)
if !ok {
t.Fatal("getResolver did not return a *net.Resolver")
}
if netResolver.Dial == nil {
t.Fatal("resolver.Dial is nil")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := netResolver.Dial(ctx, "tcp", "dummy.address:53")
if err != nil {
t.Fatalf("Failed to dial test DNS server: %v", err)
}
defer conn.Close()
testData := []byte("test")
_, err = conn.Write(testData)
if err != nil {
t.Fatalf("Failed to write to connection: %v", err)
}
response := make([]byte, len(testData))
_, err = conn.Read(response)
if err != nil {
t.Fatalf("Failed to read from connection: %v", err)
}
if string(response) != string(testData) {
t.Fatalf("Expected echo response %q, got %q", testData, response)
}
})
}
}
func TestGetResolverMultipleServers(t *testing.T) {
server1 := newEchoServer(t, "tcp4", "127.0.0.1:0")
defer server1.close()
server2 := newEchoServer(t, "tcp4", "127.0.0.1:0")
defer server2.close()
serverFlag := server1.addr + ", " + server2.addr
resolver := getResolver(serverFlag)
netResolver, ok := resolver.(*net.Resolver)
if !ok {
t.Fatal("getResolver did not return a *net.Resolver")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
servers := map[string]bool{
server1.addr: false,
server2.addr: false,
}
// Try up to 1000 times to hit all servers, this should be very quick, and
// if this fails randomness has regressed beyond reason.
for range 1000 {
conn, err := netResolver.Dial(ctx, "tcp", "dummy.address:53")
if err != nil {
t.Fatalf("Failed to dial test DNS server: %v", err)
}
remoteAddr := conn.RemoteAddr().String()
conn.Close()
servers[remoteAddr] = true
var allDone = true
for _, done := range servers {
if !done {
allDone = false
break
}
}
if allDone {
break
}
}
var allDone = true
for _, done := range servers {
if !done {
allDone = false
break
}
}
if !allDone {
t.Errorf("after 1000 queries, not all servers were hit, significant lack of randomness: %#v", servers)
}
}
func TestGetResolverEmpty(t *testing.T) {
resolver := getResolver("")
if resolver != net.DefaultResolver {
t.Fatal(`getResolver("") should return net.DefaultResolver`)
}
}

View File

@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (

View File

@ -791,21 +791,14 @@ func runDaemonLogs(ctx context.Context, args []string) error {
}
func runDaemonBusEvents(ctx context.Context, args []string) error {
logs, err := localClient.StreamBusEvents(ctx)
if err != nil {
return err
}
defer logs.Close()
d := json.NewDecoder(bufio.NewReader(logs))
for {
var line eventbus.DebugEvent
err := d.Decode(&line)
for line, err := range localClient.StreamBusEvents(ctx) {
if err != nil {
return err
}
fmt.Printf("[%d][%q][from: %q][to: %q] %s\n", line.Count, line.Type,
line.From, line.To, line.Event)
}
return nil
}
var metricsArgs struct {

View File

@ -326,6 +326,9 @@ func runNetworkLockRemove(ctx context.Context, args []string) error {
if !st.Enabled {
return errors.New("tailnet lock is not enabled")
}
if len(st.TrustedKeys) == 1 {
return errors.New("cannot remove the last trusted signing key; use 'tailscale lock disable' to disable tailnet lock instead, or add another signing key before removing one")
}
if nlRemoveArgs.resign {
// Validate we are not removing trust in ourselves while resigning. This is because

View File

@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (

2
go.mod
View File

@ -1,6 +1,6 @@
module tailscale.com
go 1.24.0
go 1.24.4
require (
filippo.io/mkcert v1.4.4

View File

@ -98,6 +98,7 @@ import (
"tailscale.com/util/clientmetric"
"tailscale.com/util/deephash"
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus"
"tailscale.com/util/goroutines"
"tailscale.com/util/httpm"
"tailscale.com/util/mak"
@ -168,6 +169,17 @@ type watchSession struct {
var metricCaptivePortalDetected = clientmetric.NewCounter("captiveportal_detected")
var (
// errShutdown indicates that the [LocalBackend.Shutdown] was called.
errShutdown = errors.New("shutting down")
// errNodeContextChanged indicates that [LocalBackend] has switched
// to a different [localNodeContext], usually due to a profile change.
// It is used as a context cancellation cause for the old context
// and can be returned when an operation is performed on it.
errNodeContextChanged = errors.New("profile changed")
)
// LocalBackend is the glue between the major pieces of the Tailscale
// network software: the cloud control plane (via controlclient), the
// network data plane (via wgengine), and the user-facing UIs and CLIs
@ -180,11 +192,11 @@ var metricCaptivePortalDetected = clientmetric.NewCounter("captiveportal_detecte
// state machine generates events back out to zero or more components.
type LocalBackend struct {
// Elements that are thread-safe or constant after construction.
ctx context.Context // canceled by [LocalBackend.Shutdown]
ctxCancel context.CancelFunc // cancels ctx
logf logger.Logf // general logging
keyLogf logger.Logf // for printing list of peers on change
statsLogf logger.Logf // for printing peers stats on change
ctx context.Context // canceled by [LocalBackend.Shutdown]
ctxCancel context.CancelCauseFunc // cancels ctx
logf logger.Logf // general logging
keyLogf logger.Logf // for printing list of peers on change
statsLogf logger.Logf // for printing peers stats on change
sys *tsd.System
health *health.Tracker // always non-nil
metrics metrics
@ -463,7 +475,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
envknob.LogCurrent(logf)
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancelCause(context.Background())
clock := tstime.StdClock{}
// Until we transition to a Running state, use a canceled context for
@ -503,7 +515,10 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
captiveCancel: nil, // so that we start checkCaptivePortalLoop when Running
needsCaptiveDetection: make(chan bool),
}
b.currentNodeAtomic.Store(newNodeBackend())
nb := newNodeBackend(ctx, b.sys.Bus.Get())
b.currentNodeAtomic.Store(nb)
nb.ready()
mConn.SetNetInfoCallback(b.setNetInfo)
if sys.InitialConfig != nil {
@ -585,9 +600,18 @@ func (b *LocalBackend) currentNode() *nodeBackend {
if v := b.currentNodeAtomic.Load(); v != nil || !testenv.InTest() {
return v
}
// Auto-init one in tests for LocalBackend created without the NewLocalBackend constructor...
v := newNodeBackend()
b.currentNodeAtomic.CompareAndSwap(nil, v)
// Auto-init [nodeBackend] in tests for LocalBackend created without the
// NewLocalBackend() constructor. Same reasoning for checking b.sys.
var bus *eventbus.Bus
if b.sys == nil {
bus = eventbus.New()
} else {
bus = b.sys.Bus.Get()
}
v := newNodeBackend(cmp.Or(b.ctx, context.Background()), bus)
if b.currentNodeAtomic.CompareAndSwap(nil, v) {
v.ready()
}
return b.currentNodeAtomic.Load()
}
@ -1089,8 +1113,9 @@ func (b *LocalBackend) Shutdown() {
if cc != nil {
cc.Shutdown()
}
b.ctxCancel(errShutdown)
b.currentNode().shutdown(errShutdown)
extHost.Shutdown()
b.ctxCancel()
b.e.Close()
<-b.e.Done()
b.awaitNoGoroutinesInTest()
@ -6992,7 +7017,11 @@ func (b *LocalBackend) resetForProfileChangeLockedOnEntry(unlock unlockOnce) err
// down, so no need to do any work.
return nil
}
b.currentNodeAtomic.Store(newNodeBackend())
newNode := newNodeBackend(b.ctx, b.sys.Bus.Get())
if oldNode := b.currentNodeAtomic.Swap(newNode); oldNode != nil {
oldNode.shutdown(errNodeContextChanged)
}
defer newNode.ready()
b.setNetMapLocked(nil) // Reset netmap.
b.updateFilterLocked(ipn.PrefsView{})
// Reset the NetworkMap in the engine

View File

@ -5,6 +5,7 @@ package ipnlocal
import (
"cmp"
"context"
"net/netip"
"slices"
"sync"
@ -22,9 +23,11 @@ import (
"tailscale.com/types/ptr"
"tailscale.com/types/views"
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/magicsock"
)
// nodeBackend is node-specific [LocalBackend] state. It is usually the current node.
@ -39,7 +42,7 @@ import (
// Two pointers to different [nodeBackend] instances represent different local nodes.
// However, there's currently a bug where a new [nodeBackend] might not be created
// during an implicit node switch (see tailscale/corp#28014).
//
// In the future, we might want to include at least the following in this struct (in addition to the current fields).
// However, not everything should be exported or otherwise made available to the outside world (e.g. [ipnext] extensions,
// peer API handlers, etc.).
@ -61,13 +64,24 @@ import (
// Even if they're tied to the local node, instead of moving them here, we should extract the entire feature
// into a separate package and have it install proper hooks.
type nodeBackend struct {
ctx context.Context // canceled by [nodeBackend.shutdown]
ctxCancel context.CancelCauseFunc // cancels ctx
// filterAtomic is a stateful packet filter. Immutable once created, but can be
// replaced with a new one.
filterAtomic atomic.Pointer[filter.Filter]
// initialized once and immutable
eventClient *eventbus.Client
filterUpdates *eventbus.Publisher[magicsock.FilterUpdate]
nodeUpdates *eventbus.Publisher[magicsock.NodeAddrsHostInfoUpdate]
// TODO(nickkhyl): maybe use sync.RWMutex?
mu sync.Mutex // protects the following fields
shutdownOnce sync.Once // guards calling [nodeBackend.shutdown]
readyCh chan struct{} // closed by [nodeBackend.ready]; nil after shutdown
// NetMap is the most recently set full netmap from the controlclient.
// It can't be mutated in place once set. Because it can't be mutated in place,
// delta updates from the control server don't apply to it. Instead, use
@ -88,12 +102,28 @@ type nodeBackend struct {
nodeByAddr map[netip.Addr]tailcfg.NodeID
}
func newNodeBackend() *nodeBackend {
cn := &nodeBackend{}
func newNodeBackend(ctx context.Context, bus *eventbus.Bus) *nodeBackend {
ctx, ctxCancel := context.WithCancelCause(ctx)
nb := &nodeBackend{
ctx: ctx,
ctxCancel: ctxCancel,
eventClient: bus.Client("ipnlocal.nodeBackend"),
readyCh: make(chan struct{}),
}
// Default filter blocks everything and logs nothing.
noneFilter := filter.NewAllowNone(logger.Discard, &netipx.IPSet{})
cn.filterAtomic.Store(noneFilter)
return cn
nb.filterAtomic.Store(noneFilter)
nb.filterUpdates = eventbus.Publish[magicsock.FilterUpdate](nb.eventClient)
nb.nodeUpdates = eventbus.Publish[magicsock.NodeAddrsHostInfoUpdate](nb.eventClient)
nb.filterUpdates.Publish(magicsock.FilterUpdate{Filter: nb.filterAtomic.Load()})
return nb
}
// Context returns a context that is canceled when the [nodeBackend] shuts down,
// either because [LocalBackend] is switching to a different [nodeBackend]
// or is shutting down itself.
func (nb *nodeBackend) Context() context.Context {
return nb.ctx
}
func (nb *nodeBackend) Self() tailcfg.NodeView {
@ -399,9 +429,16 @@ func (nb *nodeBackend) updatePeersLocked() {
nb.peers[k] = tailcfg.NodeView{}
}
changed := magicsock.NodeAddrsHostInfoUpdate{
Complete: true,
}
// Second pass, add everything wanted.
for _, p := range nm.Peers {
mak.Set(&nb.peers, p.ID(), p)
mak.Set(&changed.NodesByID, p.ID(), magicsock.NodeAddrsHostInfo{
Addresses: p.Addresses(),
Hostinfo: p.Hostinfo(),
})
}
// Third pass, remove deleted things.
@ -410,6 +447,7 @@ func (nb *nodeBackend) updatePeersLocked() {
delete(nb.peers, k)
}
}
nb.nodeUpdates.Publish(changed)
}
func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bool) {
@ -424,6 +462,9 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
// call (e.g. its endpoints + online status both change)
var mutableNodes map[tailcfg.NodeID]*tailcfg.Node
changed := magicsock.NodeAddrsHostInfoUpdate{
Complete: false,
}
for _, m := range muts {
n, ok := mutableNodes[m.NodeIDBeingMutated()]
if !ok {
@ -438,8 +479,14 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
m.Apply(n)
}
for nid, n := range mutableNodes {
nb.peers[nid] = n.View()
nv := n.View()
nb.peers[nid] = nv
mak.Set(&changed.NodesByID, nid, magicsock.NodeAddrsHostInfo{
Addresses: nv.Addresses(),
Hostinfo: nv.Hostinfo(),
})
}
nb.nodeUpdates.Publish(changed)
return true
}
@ -461,6 +508,7 @@ func (nb *nodeBackend) filter() *filter.Filter {
func (nb *nodeBackend) setFilter(f *filter.Filter) {
nb.filterAtomic.Store(f)
nb.filterUpdates.Publish(magicsock.FilterUpdate{Filter: f})
}
func (nb *nodeBackend) dnsConfigForNetmap(prefs ipn.PrefsView, selfExpired bool, logf logger.Logf, versionOS string) *dns.Config {
@ -475,6 +523,60 @@ func (nb *nodeBackend) exitNodeCanProxyDNS(exitNodeID tailcfg.StableNodeID) (doh
return exitNodeCanProxyDNS(nb.netMap, nb.peers, exitNodeID)
}
// ready signals that [LocalBackend] has completed the switch to this [nodeBackend]
// and any pending calls to [nodeBackend.Wait] must be unblocked.
func (nb *nodeBackend) ready() {
nb.mu.Lock()
defer nb.mu.Unlock()
if nb.readyCh != nil {
close(nb.readyCh)
}
}
// Wait blocks until [LocalBackend] completes the switch to this [nodeBackend]
// and calls [nodeBackend.ready]. It returns an error if the provided context
// is canceled or if the [nodeBackend] shuts down or is already shut down.
//
// It must not be called with the [LocalBackend]'s internal mutex held as [LocalBackend]
// may need to acquire it to complete the switch.
//
// TODO(nickkhyl): Relax this restriction once [LocalBackend]'s state machine
// runs in its own goroutine, or if we decide that waiting for the state machine
// restart to finish isn't necessary for [LocalBackend] to consider the switch complete.
// We mostly need this because of [LocalBackend.Start] acquiring b.mu and the fact that
// methods like [LocalBackend.SwitchProfile] must report any errors returned by it.
// Perhaps we could report those errors asynchronously as [health.Warnable]s?
func (nb *nodeBackend) Wait(ctx context.Context) error {
nb.mu.Lock()
readyCh := nb.readyCh
nb.mu.Unlock()
select {
case <-ctx.Done():
return ctx.Err()
case <-nb.ctx.Done():
return context.Cause(nb.ctx)
case <-readyCh:
return nil
}
}
// shutdown shuts down the [nodeBackend] and cancels its context
// with the provided cause.
func (nb *nodeBackend) shutdown(cause error) {
nb.shutdownOnce.Do(func() {
nb.doShutdown(cause)
})
}
func (nb *nodeBackend) doShutdown(cause error) {
nb.mu.Lock()
defer nb.mu.Unlock()
nb.ctxCancel(cause)
nb.readyCh = nil
nb.eventClient.Close()
}
// dnsConfigForNetmap returns a *dns.Config for the given netmap,
// prefs, client OS version, and cloud hosting environment.
//

View File

@ -0,0 +1,123 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"errors"
"testing"
"time"
"tailscale.com/util/eventbus"
)
func TestNodeBackendReadiness(t *testing.T) {
nb := newNodeBackend(t.Context(), eventbus.New())
// The node backend is not ready until [nodeBackend.ready] is called,
// and [nodeBackend.Wait] should fail with [context.DeadlineExceeded].
ctx, cancelCtx := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancelCtx()
if err := nb.Wait(ctx); err != ctx.Err() {
t.Fatalf("Wait: got %v; want %v", err, ctx.Err())
}
// Start a goroutine to wait for the node backend to become ready.
waitDone := make(chan struct{})
go func() {
if err := nb.Wait(context.Background()); err != nil {
t.Errorf("Wait: got %v; want nil", err)
}
close(waitDone)
}()
// Call [nodeBackend.ready] to indicate that the node backend is now ready.
go nb.ready()
// Once the backend is called, [nodeBackend.Wait] should return immediately without error.
if err := nb.Wait(context.Background()); err != nil {
t.Fatalf("Wait: got %v; want nil", err)
}
// And any pending waiters should also be unblocked.
<-waitDone
}
func TestNodeBackendShutdown(t *testing.T) {
nb := newNodeBackend(t.Context(), eventbus.New())
shutdownCause := errors.New("test shutdown")
// Start a goroutine to wait for the node backend to become ready.
// This test expects it to block until the node backend shuts down
// and then return the specified shutdown cause.
waitDone := make(chan struct{})
go func() {
if err := nb.Wait(context.Background()); err != shutdownCause {
t.Errorf("Wait: got %v; want %v", err, shutdownCause)
}
close(waitDone)
}()
// Call [nodeBackend.shutdown] to indicate that the node backend is shutting down.
nb.shutdown(shutdownCause)
// Calling it again is fine, but should not change the shutdown cause.
nb.shutdown(errors.New("test shutdown again"))
// After shutdown, [nodeBackend.Wait] should return with the specified shutdown cause.
if err := nb.Wait(context.Background()); err != shutdownCause {
t.Fatalf("Wait: got %v; want %v", err, shutdownCause)
}
// The context associated with the node backend should also be cancelled
// and its cancellation cause should match the shutdown cause.
if err := nb.Context().Err(); !errors.Is(err, context.Canceled) {
t.Fatalf("Context.Err: got %v; want %v", err, context.Canceled)
}
if cause := context.Cause(nb.Context()); cause != shutdownCause {
t.Fatalf("Cause: got %v; want %v", cause, shutdownCause)
}
// And any pending waiters should also be unblocked.
<-waitDone
}
func TestNodeBackendReadyAfterShutdown(t *testing.T) {
nb := newNodeBackend(t.Context(), eventbus.New())
shutdownCause := errors.New("test shutdown")
nb.shutdown(shutdownCause)
nb.ready() // Calling ready after shutdown is a no-op, but should not panic, etc.
if err := nb.Wait(context.Background()); err != shutdownCause {
t.Fatalf("Wait: got %v; want %v", err, shutdownCause)
}
}
func TestNodeBackendParentContextCancellation(t *testing.T) {
ctx, cancelCtx := context.WithCancel(context.Background())
nb := newNodeBackend(ctx, eventbus.New())
cancelCtx()
// Cancelling the parent context should cause [nodeBackend.Wait]
// to return with [context.Canceled].
if err := nb.Wait(context.Background()); !errors.Is(err, context.Canceled) {
t.Fatalf("Wait: got %v; want %v", err, context.Canceled)
}
// And the node backend's context should also be cancelled.
if err := nb.Context().Err(); !errors.Is(err, context.Canceled) {
t.Fatalf("Context.Err: got %v; want %v", err, context.Canceled)
}
}
func TestNodeBackendConcurrentReadyAndShutdown(t *testing.T) {
nb := newNodeBackend(t.Context(), eventbus.New())
// Calling [nodeBackend.ready] and [nodeBackend.shutdown] concurrently
// should not cause issues, and [nodeBackend.Wait] should unblock,
// but the result of [nodeBackend.Wait] is intentionally undefined.
go nb.ready()
go nb.shutdown(errors.New("test shutdown"))
nb.Wait(context.Background())
}

View File

@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipn
import (

117
license_test.go Normal file
View File

@ -0,0 +1,117 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailscaleroot
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"tailscale.com/util/set"
)
func normalizeLineEndings(b []byte) []byte {
return bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n"))
}
// TestLicenseHeaders checks that all Go files in the tree
// directory tree have a correct-looking Tailscale license header.
func TestLicenseHeaders(t *testing.T) {
want := normalizeLineEndings([]byte(strings.TrimLeft(`
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
`, "\n")))
exceptions := set.Of(
// Subprocess test harness code
"util/winutil/testdata/testrestartableprocesses/main.go",
"util/winutil/subprocess_windows_test.go",
// WireGuard copyright
"cmd/tailscale/cli/authenticode_windows.go",
"wgengine/router/ifconfig_windows.go",
// noiseexplorer.com copyright
"control/controlbase/noiseexplorer_test.go",
// Generated eBPF management code
"derp/xdp/bpf_bpfeb.go",
"derp/xdp/bpf_bpfel.go",
// Generated kube deepcopy funcs file starts with a Go build tag + an empty line
"k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go",
)
err := filepath.Walk(".", func(path string, fi os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("path %s: %v", path, err)
}
if exceptions.Contains(filepath.ToSlash(path)) {
return nil
}
base := filepath.Base(path)
switch base {
case ".git", "node_modules", "tempfork":
return filepath.SkipDir
}
switch base {
case "zsyscall_windows.go":
// Generated code.
return nil
}
if strings.HasSuffix(base, ".config.ts") {
return nil
}
if strings.HasSuffix(base, "_string.go") {
// Generated file from go:generate stringer
return nil
}
ext := filepath.Ext(base)
switch ext {
default:
return nil
case ".go", ".ts", ".tsx":
}
buf := make([]byte, 512)
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
if n, err := io.ReadAtLeast(f, buf, 512); err != nil && err != io.ErrUnexpectedEOF {
return err
} else {
buf = buf[:n]
}
buf = normalizeLineEndings(buf)
bufNoTrunc := buf
if i := bytes.Index(buf, []byte("\npackage ")); i != -1 {
buf = buf[:i]
}
if bytes.Contains(buf, want) {
return nil
}
if bytes.Contains(bufNoTrunc, []byte("BSD-3-Clause\npackage ")) {
t.Errorf("file %s has license header as a package doc; add a blank line before the package line", path)
return nil
}
t.Errorf("file %s is missing Tailscale copyright header:\n\n%s", path, want)
return nil
})
if err != nil {
t.Fatalf("Walk: %v", err)
}
}

View File

@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tstun
import (

View File

@ -1,77 +0,0 @@
#!/bin/sh
#
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
#
# check_license_headers.sh checks that all Go files in the given
# directory tree have a correct-looking Tailscale license header.
check_file() {
got=$1
want=$(cat <<EOF
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
EOF
)
if [ "$got" = "$want" ]; then
return 0
fi
return 1
}
if [ $# != 1 ]; then
echo "Usage: $0 rootdir" >&2
exit 1
fi
fail=0
for file in $(find $1 \( -name '*.go' -or -name '*.tsx' -or -name '*.ts' -not -name '*.config.ts' \) -not -path '*/.git/*' -not -path '*/node_modules/*'); do
case $file in
$1/tempfork/*)
# Skip, tempfork of third-party code
;;
$1/wgengine/router/ifconfig_windows.go)
# WireGuard copyright.
;;
$1/cmd/tailscale/cli/authenticode_windows.go)
# WireGuard copyright.
;;
*_string.go)
# Generated file from go:generate stringer
;;
$1/control/controlbase/noiseexplorer_test.go)
# Noiseexplorer.com copyright.
;;
*/zsyscall_windows.go)
# Generated syscall wrappers
;;
$1/util/winutil/subprocess_windows_test.go)
# Subprocess test harness code
;;
$1/util/winutil/testdata/testrestartableprocesses/main.go)
# Subprocess test harness code
;;
*$1/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go)
# Generated kube deepcopy funcs file starts with a Go build tag + an empty line
header="$(head -5 $file | tail -n+3 )"
;;
$1/derp/xdp/bpf_bpfe*.go)
# Generated eBPF management code
;;
*)
header="$(head -2 $file)"
;;
esac
if [ ! -z "$header" ]; then
if ! check_file "$header"; then
fail=1
echo "${file#$1/} doesn't have the right copyright header:"
echo "$header" | sed -e 's/^/ /g'
fi
fi
done
if [ $fail -ne 0 ]; then
exit 1
fi

View File

@ -5,6 +5,7 @@ package tka
import (
"crypto/ed25519"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
@ -90,6 +91,20 @@ func TestAuthorityBuilderRemoveKey(t *testing.T) {
if _, err := a.state.GetKey(key2.MustID()); err != ErrNoSuchKey {
t.Errorf("GetKey(key2).err = %v, want %v", err, ErrNoSuchKey)
}
// Check that removing the remaining key errors out.
b = a.NewUpdater(signer25519(priv))
if err := b.RemoveKey(key.MustID()); err != nil {
t.Fatalf("RemoveKey(%v) failed: %v", key, err)
}
updates, err = b.Finalize(storage)
if err != nil {
t.Fatalf("Finalize() failed: %v", err)
}
wantErr := "cannot remove the last key"
if err := a.Inform(storage, updates); err == nil || !strings.Contains(err.Error(), wantErr) {
t.Fatalf("expected Inform() to return error %q, got: %v", wantErr, err)
}
}
func TestAuthorityBuilderSetKeyVote(t *testing.T) {

View File

@ -440,6 +440,13 @@ func aumVerify(aum AUM, state State, isGenesisAUM bool) error {
return fmt.Errorf("signature %d: %v", i, err)
}
}
if aum.MessageKind == AUMRemoveKey && len(state.Keys) == 1 {
if kid, err := state.Keys[0].ID(); err == nil && bytes.Equal(aum.KeyID, kid) {
return errors.New("cannot remove the last key in the state")
}
}
return nil
}

View File

@ -87,29 +87,29 @@ func (a *authorization) Refresh(ctx context.Context) error {
}
func (a *authorization) AllowsHost(addr netip.Addr) bool {
a.mu.Lock()
defer a.mu.Unlock()
if a.peers == nil {
return false
}
a.mu.Lock()
defer a.mu.Unlock()
return a.peers.addrs.Contains(addr)
}
func (a *authorization) SelfAllowed() bool {
a.mu.Lock()
defer a.mu.Unlock()
if a.peers == nil {
return false
}
a.mu.Lock()
defer a.mu.Unlock()
return a.peers.status.Self.Tags != nil && views.SliceContains(*a.peers.status.Self.Tags, a.tag)
}
func (a *authorization) AllowedPeers() views.Slice[*ipnstate.PeerStatus] {
a.mu.Lock()
defer a.mu.Unlock()
if a.peers == nil {
return views.Slice[*ipnstate.PeerStatus]{}
}
a.mu.Lock()
defer a.mu.Unlock()
return views.SliceOf(a.peers.statuses)
}

View File

@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package promvarz
import (

View File

@ -113,15 +113,16 @@ func (c *Client) shouldPublish(t reflect.Type) bool {
// Subscribe requests delivery of events of type T through the given
// Queue. Panics if the queue already has a subscriber for T.
func Subscribe[T any](c *Client) *Subscriber[T] {
return newSubscriber[T](c.subscribeState())
r := c.subscribeState()
s := newSubscriber[T](r)
r.addSubscriber(s)
return s
}
// Publisher returns a publisher for event type T using the given
// client.
func Publish[T any](c *Client) *Publisher[T] {
ret := newPublisher[T](c)
c.mu.Lock()
defer c.mu.Unlock()
c.pub.Add(ret)
return ret
p := newPublisher[T](c)
c.addPublisher(p)
return p
}

View File

@ -21,11 +21,7 @@ type Publisher[T any] struct {
}
func newPublisher[T any](c *Client) *Publisher[T] {
ret := &Publisher[T]{
client: c,
}
c.addPublisher(ret)
return ret
return &Publisher[T]{client: c}
}
// Close closes the publisher.

View File

@ -91,7 +91,7 @@ func (q *subscribeState) pump(ctx context.Context) {
}
} else {
// Keep the cases in this select in sync with
// Subscriber.dispatch below. The only different should be
// Subscriber.dispatch below. The only difference should be
// that this select doesn't deliver queued values to
// anyone, and unconditionally accepts new values.
select {
@ -134,9 +134,10 @@ func (s *subscribeState) subscribeTypes() []reflect.Type {
return ret
}
func (s *subscribeState) addSubscriber(t reflect.Type, sub subscriber) {
func (s *subscribeState) addSubscriber(sub subscriber) {
s.outputsMu.Lock()
defer s.outputsMu.Unlock()
t := sub.subscribeType()
if s.outputs[t] != nil {
panic(fmt.Errorf("double subscription for event %s", t))
}
@ -183,15 +184,10 @@ type Subscriber[T any] struct {
}
func newSubscriber[T any](r *subscribeState) *Subscriber[T] {
t := reflect.TypeFor[T]()
ret := &Subscriber[T]{
return &Subscriber[T]{
read: make(chan T),
unregister: func() { r.deleteSubscriber(t) },
unregister: func() { r.deleteSubscriber(reflect.TypeFor[T]()) },
}
r.addSubscriber(t, ret)
return ret
}
func newMonitor[T any](attach func(fn func(T)) (cancel func())) *Subscriber[T] {

View File

@ -23,3 +23,11 @@ func Get[T any](v T, err error) T {
}
return v
}
// Get2 returns v1 and v2 as is. It panics if err is non-nil.
func Get2[T any, U any](v1 T, v2 U, err error) (T, U) {
if err != nil {
panic(err)
}
return v1, v2
}

View File

@ -63,6 +63,7 @@ import (
"tailscale.com/util/set"
"tailscale.com/util/testenv"
"tailscale.com/util/usermetric"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/wgint"
)
@ -502,6 +503,30 @@ func (o *Options) derpActiveFunc() func() {
return o.DERPActiveFunc
}
// NodeAddrsHostInfoUpdate represents an update event of the addresses and
// [tailcfg.HostInfoView] for a node set. This event is published over an
// [eventbus.Bus]. [magicsock.Conn] is the sole subscriber as of 2025-06. If
// you are adding more subscribers consider moving this type out of magicsock.
type NodeAddrsHostInfoUpdate struct {
NodesByID map[tailcfg.NodeID]NodeAddrsHostInfo
Complete bool // true if NodesByID contains all known nodes, false if it may be a subset
}
// NodeAddrsHostInfo represents the addresses and [tailcfg.HostinfoView] for a
// Tailscale node.
type NodeAddrsHostInfo struct {
Addresses views.Slice[netip.Prefix]
Hostinfo tailcfg.HostinfoView
}
// FilterUpdate represents an update event for a [*filter.Filter]. This event is
// signaled over an [eventbus.Bus]. [magicsock.Conn] is the sole subscriber as
// of 2025-06. If you are adding more subscribers consider moving this type out
// of magicsock.
type FilterUpdate struct {
*filter.Filter
}
// newConn is the error-free, network-listening-side-effect-free based
// of NewConn. Mostly for tests.
func newConn(logf logger.Logf) *Conn {
@ -535,6 +560,20 @@ func newConn(logf logger.Logf) *Conn {
return c
}
// consumeEventbusTopic consumes events from sub and passes them to
// handlerFn until sub.Done() is closed.
func consumeEventbusTopic[T any](sub *eventbus.Subscriber[T], handlerFn func(t T)) {
defer sub.Close()
for {
select {
case evt := <-sub.Events():
handlerFn(evt)
case <-sub.Done():
return
}
}
}
// NewConn creates a magic Conn listening on opts.Port.
// As the set of possible endpoints for a Conn changes, the
// callback opts.EndpointsFunc is called.
@ -562,17 +601,17 @@ func NewConn(opts Options) (*Conn, error) {
c.eventClient = c.eventBus.Client("magicsock.Conn")
pmSub := eventbus.Subscribe[portmapper.Mapping](c.eventClient)
go func() {
defer pmSub.Close()
for {
select {
case <-pmSub.Events():
c.onPortMapChanged()
case <-pmSub.Done():
return
}
}
}()
go consumeEventbusTopic(pmSub, func(_ portmapper.Mapping) {
c.onPortMapChanged()
})
filterSub := eventbus.Subscribe[FilterUpdate](c.eventClient)
go consumeEventbusTopic(filterSub, func(t FilterUpdate) {
// TODO(jwhited): implement
})
nodeSub := eventbus.Subscribe[NodeAddrsHostInfoUpdate](c.eventClient)
go consumeEventbusTopic(nodeSub, func(t NodeAddrsHostInfoUpdate) {
// TODO(jwhited): implement
})
// Disable the explicit callback from the portmapper, the subscriber handles it.
onPortMapChanged = nil
@ -2798,6 +2837,10 @@ func (c *connBind) Close() error {
return nil
}
c.closed = true
// Close the [eventbus.Client].
if c.eventClient != nil {
c.eventClient.Close()
}
// Unblock all outstanding receives.
c.pconn4.Close()
c.pconn6.Close()