WIP: rebase fork onto upstream/main (v1.103.0) #15
+60
-2
@@ -1,2 +1,60 @@
|
||||
go.mod filter=go-mod
|
||||
*.go diff=golang
|
||||
go.mod filter=go-mod eol=lf text
|
||||
*.go diff=golang eol=lf text
|
||||
*.adml eol=lf text
|
||||
*.admx eol=lf text
|
||||
*.bash eol=lf text
|
||||
*.c eol=lf text
|
||||
*.cgi eol=lf text
|
||||
*.conf eol=lf text
|
||||
*.css eol=lf text
|
||||
*.csv eol=lf text
|
||||
*.desktop eol=lf text
|
||||
*.fish eol=lf text
|
||||
*.gitattributes eol=lf text
|
||||
*.gitignore eol=lf text
|
||||
*.gitkeep eol=lf text
|
||||
*.go eol=lf text
|
||||
*.h eol=lf text
|
||||
*.helmignore eol=lf text
|
||||
*.htaccess eol=lf text
|
||||
*.html eol=lf text
|
||||
*.hujson eol=lf text
|
||||
*.in eol=lf text
|
||||
*.init eol=lf text
|
||||
*.js eol=lf text
|
||||
*.json eol=lf text
|
||||
*.lock eol=lf text
|
||||
*.lua eol=lf text
|
||||
*.md eol=lf text
|
||||
*.mod eol=lf text
|
||||
*.nix eol=lf text
|
||||
*.openrc eol=lf text
|
||||
*.pbxproj eol=lf text
|
||||
*.pem eol=lf text
|
||||
*.plg eol=lf text
|
||||
*.plist eol=lf text
|
||||
*.rc eol=lf text
|
||||
*.resolved eol=lf text
|
||||
*.rev eol=lf text
|
||||
*.rs eol=lf text
|
||||
*.sc eol=lf text
|
||||
*.service eol=lf text
|
||||
*.sh eol=lf text
|
||||
*.socket eol=lf text
|
||||
*.stignore eol=lf text
|
||||
*.sum eol=lf text
|
||||
*.svg eol=lf text
|
||||
*.swift eol=lf text
|
||||
*.tmpl eol=lf text
|
||||
*.toml eol=lf text
|
||||
*.ts eol=lf text
|
||||
*.tsx eol=lf text
|
||||
*.txt eol=lf text
|
||||
*.version eol=lf text
|
||||
*.xcscheme eol=lf text
|
||||
*.xcsettings eol=lf text
|
||||
*.xib eol=lf text
|
||||
*.xml eol=lf text
|
||||
*.yaml eol=lf text
|
||||
*.yml eol=lf text
|
||||
*.zsh eol=lf text
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
# Install a more recent Go that understands modern go.mod content.
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # zizmor: ignore[cache-poisoning] v6.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install govulncheck
|
||||
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@0782b76014f15f24e22a438f30f308df42899ba1 # 1.3.0
|
||||
|
||||
- name: Scan source code for known vulnerabilities
|
||||
run: PATH=$PWD/tool/:$PATH "$(./tool/go env GOPATH)/bin/govulncheck" -test ./...
|
||||
|
||||
@@ -37,8 +37,6 @@ jobs:
|
||||
- "elementary/docker:stable"
|
||||
- "elementary/docker:unstable"
|
||||
- "parrotsec/core:latest"
|
||||
- "kalilinux/kali-rolling"
|
||||
- "kalilinux/kali-dev"
|
||||
- "oraclelinux:9"
|
||||
- "oraclelinux:8"
|
||||
- "fedora:latest"
|
||||
@@ -61,6 +59,9 @@ jobs:
|
||||
- { image: "debian:stable-slim", deps: "curl" }
|
||||
- { image: "ubuntu:24.04", deps: "curl" }
|
||||
- { image: "fedora:latest", deps: "curl" }
|
||||
# Kali doesn't have ca-certificates installed by default anymore
|
||||
- { image: "kalilinux/kali-dev", "deps": "curl ca-certificates"}
|
||||
- { image: "kalilinux/kali-rolling", "deps": "curl ca-certificates"}
|
||||
# Test TAILSCALE_VERSION pinning on a subset of distros.
|
||||
# Skip Alpine as community repos don't reliably keep old versions.
|
||||
- { image: "debian:stable-slim", deps: "curl", version: "1.80.0" }
|
||||
@@ -68,7 +69,7 @@ jobs:
|
||||
- { image: "fedora:latest", deps: "curl", version: "1.80.0" }
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
image: ${{ matrix.image }} # zizmor: ignore[unpinned-images]
|
||||
options: --user root
|
||||
steps:
|
||||
- name: install dependencies (pacman)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Run some natlab integration tests.
|
||||
# Run a single natlab smoke test on every PR. The full natlab suite
|
||||
# is opt-in and lives in .github/workflows/natlab-test.yml.
|
||||
# See https://github.com/tailscale/tailscale/issues/13038
|
||||
name: "natlab-integrationtest"
|
||||
name: "natlab-basic"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
@@ -17,17 +18,28 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
jobs:
|
||||
natlab-integrationtest:
|
||||
EasyEasy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
- name: Install qemu
|
||||
run: |
|
||||
sudo rm -f /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: Build VM image
|
||||
# The test will build this if missing, but we do it explicitly
|
||||
# to avoid cutting into the go test -timeout budget, and to
|
||||
# fail earlier with a clearer error if the image build breaks.
|
||||
run: |
|
||||
make -C gokrazy natlab
|
||||
- name: Run natlab integration tests
|
||||
run: |
|
||||
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/integration/nat --run-vm-tests
|
||||
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/natlab/vmtest --run-vm-tests
|
||||
@@ -0,0 +1,182 @@
|
||||
# Run the full natlab/vmtest opt-in test suite. These tests boot QEMU VMs
|
||||
# (gokrazy, Ubuntu, FreeBSD) and exercise vnet-driven networking scenarios.
|
||||
# They are gated behind --run-vm-tests because they need KVM and are slow.
|
||||
#
|
||||
# This workflow runs:
|
||||
# - on demand (workflow_dispatch)
|
||||
# - on PRs that carry the "run-natlab-tests" label
|
||||
# - on main, every 12 hours, via cron
|
||||
#
|
||||
# Layout:
|
||||
# - "prepare" builds the gokrazy VM image, downloads the cloud images
|
||||
# (Ubuntu, FreeBSD), and discovers every Test* function in the two
|
||||
# opt-in packages.
|
||||
# - "test" is a per-TestFoo matrix that depends on prepare. Each matrix
|
||||
# job restores the shared caches and runs a single test. Adding a new
|
||||
# TestFoo automatically gets its own job — no workflow edits needed.
|
||||
#
|
||||
# A separate workflow (.github/workflows/natlab-basic.yml) runs a single
|
||||
# canary natlab test on every PR; this one runs the full suite.
|
||||
name: "natlab-test"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [labeled, synchronize, reopened]
|
||||
schedule:
|
||||
# Every 12 hours, off-the-hour to avoid GitHub's :00 cron-stampede window.
|
||||
- cron: "23 3,15 * * *"
|
||||
|
||||
jobs:
|
||||
# prepare warms the per-workflow-run caches (gokrazy image, cloud VM
|
||||
# images) and emits the dynamic matrix of test names. By doing the work
|
||||
# once here, the matrix test jobs never race to rebuild or re-download
|
||||
# the same artifacts on a cold cache.
|
||||
prepare:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-natlab-tests'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
matrix: ${{ steps.list.outputs.matrix }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# The cloud VM image cache is keyed only on images.go (image URLs and
|
||||
# SHAs), so it survives across workflow runs and is invalidated only
|
||||
# when a new image source is added.
|
||||
- name: Cache cloud VM images
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: ~/.cache/tailscale/vmtest/images
|
||||
key: natlab-vmimages-${{ hashFiles('tstest/natlab/vmtest/images.go') }}
|
||||
|
||||
# The gokrazy VM image is keyed by github.sha. That means we rebuild
|
||||
# it once per commit but matrix test jobs in the same run all share
|
||||
# the result. Per-PR re-runs of the same sha (e.g. a rerun-failed)
|
||||
# also get the cache.
|
||||
- name: Cache gokrazy VM image
|
||||
id: gokrazy-cache
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: gokrazy/natlabapp.qcow2
|
||||
key: natlab-gokrazy-${{ github.sha }}
|
||||
|
||||
# qemu-utils provides qemu-img, which the gokrazy Makefile uses to
|
||||
# convert natlabapp.img to qcow2. Only install if we need it (cache
|
||||
# miss); the test matrix jobs install qemu separately for the runtime.
|
||||
- name: Install qemu-utils
|
||||
if: steps.gokrazy-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
sudo rm -f /var/lib/man-db/auto-update
|
||||
sudo apt-get -y update
|
||||
sudo apt-get -y remove man-db
|
||||
sudo apt-get install -y qemu-utils
|
||||
|
||||
- name: Download cloud VM images
|
||||
# natlabprep is idempotent: it checks the cache before downloading.
|
||||
run: |
|
||||
./tool/go run ./tstest/natlab/vmtest/cmd/natlabprep
|
||||
|
||||
- name: Build gokrazy VM image
|
||||
if: steps.gokrazy-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
make -C gokrazy natlab
|
||||
|
||||
- name: Discover tests
|
||||
id: list
|
||||
# Grep the test files directly rather than invoking `go test -list`
|
||||
# so we don't pay the cost of compiling the test binaries here. The
|
||||
# only test functions in these packages use the canonical
|
||||
# `func TestFoo(t *testing.T)` signature.
|
||||
#
|
||||
# exclude is the set of tests that need special invocation
|
||||
# (extra flags, a specific environment) and don't fit the
|
||||
# single-test-per-matrix-job model. They stay runnable locally.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
exclude='^(TestGrid|TestVnetPerf.*)$'
|
||||
tmp=$(mktemp)
|
||||
for pkg_dir in tstest/natlab/vmtest tstest/integration/nat; do
|
||||
pkg="./${pkg_dir}/"
|
||||
for f in "${pkg_dir}"/*_test.go; do
|
||||
[ -e "$f" ] || continue
|
||||
{ grep -hE '^func Test[A-Z][A-Za-z0-9_]*\(t \*testing\.T\)' "$f" || true; } \
|
||||
| sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/' \
|
||||
| { grep -vE "$exclude" || true; } \
|
||||
| while read -r t; do
|
||||
jq -nc --arg pkg "$pkg" --arg test "$t" \
|
||||
'{pkg: $pkg, test: $test}' >> "$tmp"
|
||||
done
|
||||
done
|
||||
done
|
||||
matrix=$(jq -s -c . "$tmp")
|
||||
echo "matrix=${matrix}" >> "$GITHUB_OUTPUT"
|
||||
echo "Discovered tests:"
|
||||
jq . "$tmp"
|
||||
|
||||
test:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
name: "${{ matrix.test }}"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Install qemu
|
||||
run: |
|
||||
sudo rm -f /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
|
||||
|
||||
# restore-only: prepare is the single writer of these caches, so
|
||||
# matrix jobs don't write back. fail-on-cache-miss would be too
|
||||
# strict for the gokrazy cache (e.g. a non-fatal cache eviction
|
||||
# between prepare and us); we just rebuild on miss instead.
|
||||
- name: Restore cloud VM images
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: ~/.cache/tailscale/vmtest/images
|
||||
key: natlab-vmimages-${{ hashFiles('tstest/natlab/vmtest/images.go') }}
|
||||
|
||||
- name: Restore gokrazy VM image
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: gokrazy/natlabapp.qcow2
|
||||
key: natlab-gokrazy-${{ github.sha }}
|
||||
|
||||
# The gokrazy-based tests boot the kernel directly from
|
||||
# vmlinuz that ships in the gokrazy/kernel.amd64 module.
|
||||
# Tests look it up under GOMODCACHE via findKernelPath, so the
|
||||
# module has to be present even though no Go source imports it
|
||||
# in the test package itself.
|
||||
- name: Download kernel.amd64 module
|
||||
run: |
|
||||
./tool/go mod download github.com/gokrazy/kernel.amd64
|
||||
|
||||
- name: Run ${{ matrix.test }}
|
||||
# Per-test timeout is well above the few-minute typical runtime
|
||||
# but small enough that a stuck test fails fast instead of holding
|
||||
# the runner for the job's 20-minute budget.
|
||||
run: |
|
||||
./tool/go test -v -timeout=15m -count=1 ${{ matrix.pkg }} \
|
||||
-run='^${{ matrix.test }}$' --run-vm-tests
|
||||
@@ -0,0 +1,45 @@
|
||||
name: policybot-test
|
||||
|
||||
env:
|
||||
HOME: ${{ github.workspace }}
|
||||
GOMODCACHE: ${{ github.workspace }}/gomodcache
|
||||
CMD_GO_USE_GIT_HASH: "true"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-branch/*"
|
||||
paths:
|
||||
- .github/workflows/policybot-test.yml
|
||||
- .policy.yml
|
||||
- .policy-tests.yml
|
||||
- go.mod
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/policybot-test.yml
|
||||
- .policy.yml
|
||||
- .policy-tests.yml
|
||||
- go.mod
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
policybot-test:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
path: src
|
||||
|
||||
# The version of github.com/tailscale/policybottest used here is
|
||||
# pinned by go.mod via internal/tooldeps/tooldeps.go; bump it with
|
||||
# "go get github.com/tailscale/policybottest@<sha> && go mod tidy".
|
||||
- name: Run policy tests
|
||||
working-directory: src
|
||||
run: ./tool/go run github.com/tailscale/policybottest -policy .policy.yml -tests .policy-tests.yml
|
||||
@@ -2,7 +2,7 @@ name: request-dataplane-review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened, ready_for_review ]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- ".github/workflows/request-dataplane-review.yml"
|
||||
- "**/*derp*"
|
||||
@@ -15,8 +15,6 @@ jobs:
|
||||
name: Request Dataplane Review
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Get access token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
id: generate-token
|
||||
@@ -24,6 +22,8 @@ jobs:
|
||||
# Get token for app: https://github.com/apps/change-visibility-bot
|
||||
app-id: ${{ secrets.VISIBILITY_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.VISIBILITY_BOT_APP_PRIVATE_KEY }}
|
||||
# Limit the token to only requesting reviewers on pull requests.
|
||||
permission-pull-requests: write
|
||||
- name: Add reviewers
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: request-k8s-review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- ".github/workflows/request-k8s-review.yml"
|
||||
- "k8s-operator/**"
|
||||
- "kube/**"
|
||||
- "cmd/k8s-operator/**"
|
||||
- "cmd/k8s-proxy/**"
|
||||
- "cmd/k8s-nameserver/**"
|
||||
- "cmd/containerboot/**"
|
||||
- "cmd/sync-containers/**"
|
||||
- "ipn/store/kubestore/**"
|
||||
- "docs/k8s/**"
|
||||
- "!**/depaware.txt"
|
||||
|
||||
jobs:
|
||||
request-k8s-review:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: Request K8s Review
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get access token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
id: generate-token
|
||||
with:
|
||||
# Get token for app: https://github.com/apps/change-visibility-bot
|
||||
app-id: ${{ secrets.VISIBILITY_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.VISIBILITY_BOT_APP_PRIVATE_KEY }}
|
||||
# Limit the token to only requesting reviewers on pull requests.
|
||||
permission-pull-requests: write
|
||||
- name: Add reviewers
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||
url: ${{ github.event.pull_request.html_url }}
|
||||
run: |
|
||||
gh pr edit "$url" --add-reviewer tailscale/k8s-devs
|
||||
@@ -1,5 +1,5 @@
|
||||
# Run the ssh integration tests with `make sshintegrationtest`.
|
||||
# These tests can also be running locally.
|
||||
# Run the ssh integration tests in various Docker containers.
|
||||
# These tests can also be run locally via `make sshintegrationtest`.
|
||||
name: "ssh-integrationtest"
|
||||
|
||||
concurrency:
|
||||
@@ -15,9 +15,25 @@ on:
|
||||
jobs:
|
||||
ssh-integrationtest:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- base: "ubuntu:focal"
|
||||
tag: "ssh-ubuntu-focal"
|
||||
- base: "ubuntu:jammy"
|
||||
tag: "ssh-ubuntu-jammy"
|
||||
- base: "ubuntu:noble"
|
||||
tag: "ssh-ubuntu-noble"
|
||||
- base: "alpine:latest"
|
||||
tag: "ssh-alpine-latest"
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Run SSH integration tests
|
||||
- name: Build test binaries
|
||||
run: |
|
||||
make sshintegrationtest
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled
|
||||
- name: Run SSH integration tests (${{ matrix.base }})
|
||||
run: |
|
||||
docker build --build-arg="BASE=${{ matrix.base }}" -t "${{ matrix.tag }}" ssh/tailssh/testcontainers
|
||||
|
||||
+22
-32
@@ -70,7 +70,7 @@ jobs:
|
||||
run: go mod download
|
||||
- name: Cache Go modules
|
||||
if: steps.check-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # zizmor: ignore[cache-poisoning] v5.0.4
|
||||
with:
|
||||
path: gomodcache # relative to workspace; see env note at top of file
|
||||
key: ${{ steps.hash.outputs.key }}
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
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)
|
||||
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
|
||||
@@ -261,6 +261,7 @@ jobs:
|
||||
cigocached-host: ${{ vars.CIGOCACHED_AZURE_HOST }}
|
||||
|
||||
- name: test
|
||||
shell: bash
|
||||
if: matrix.key != 'win-bench' # skip on bench builder
|
||||
working-directory: src
|
||||
run: ./tool/go run ./cmd/testwrapper sharded:${{ matrix.shard }}
|
||||
@@ -268,9 +269,10 @@ jobs:
|
||||
NOPWSHDEBUG: "true" # to quiet tool/gocross/gocross-wrapper.ps1 in CI
|
||||
|
||||
- name: bench all
|
||||
shell: bash
|
||||
if: matrix.key == 'win-bench'
|
||||
working-directory: src
|
||||
run: ./tool/go test ./... -bench=. -benchtime=1x -run="^$"
|
||||
run: ./tool/go test -bench=. -benchtime=1x -run='^$' $(for x in $(git grep -l '^func Benchmark' | xargs dirname | sort | uniq); do echo "./$x"; done)
|
||||
env:
|
||||
NOPWSHDEBUG: "true" # to quiet tool/gocross/gocross-wrapper.ps1 in CI
|
||||
|
||||
@@ -343,7 +345,7 @@ jobs:
|
||||
needs: gomod-cache
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: golang:latest
|
||||
image: golang:latest # zizmor: ignore[unpinned-images]
|
||||
options: --privileged
|
||||
steps:
|
||||
- name: checkout
|
||||
@@ -361,31 +363,7 @@ jobs:
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
path: src
|
||||
- name: Restore Go module cache
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
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=TestRunUbuntu2404
|
||||
env:
|
||||
HOME: "/var/lib/ghrunner/home"
|
||||
TMPDIR: "/tmp"
|
||||
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
run: ./tool/go test $(./tool/go run ./tool/listpkgs --has-root-tests)
|
||||
|
||||
cross: # cross-compile checks, build only.
|
||||
needs: gomod-cache
|
||||
@@ -642,6 +620,13 @@ jobs:
|
||||
run: |
|
||||
./tool/go run ./cmd/tsconnect --fast-compression build
|
||||
./tool/go run ./cmd/tsconnect --fast-compression build-pkg
|
||||
- name: verify Google Chrome is available
|
||||
run: |
|
||||
which google-chrome
|
||||
google-chrome --version
|
||||
- name: tsconnect js/wasm headless-browser tests
|
||||
working-directory: src
|
||||
run: ./tool/go test ./tstest/integration/jswasmtest/ -v -timeout 180s --run-headless-browser-tests
|
||||
- name: Tidy cache
|
||||
working-directory: src
|
||||
shell: bash
|
||||
@@ -787,6 +772,14 @@ jobs:
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
|
||||
- name: check that 'genreadme' is clean
|
||||
working-directory: src
|
||||
run: |
|
||||
./tool/go run ./misc/genreadme
|
||||
git add -N . # ensure untracked files are noticed
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run './tool/go run ./misc/genreadme'."; exit 1)
|
||||
|
||||
make_tidy:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -895,7 +888,6 @@ jobs:
|
||||
- test
|
||||
- windows
|
||||
- macos
|
||||
- vm
|
||||
- cross
|
||||
- ios
|
||||
- wasm
|
||||
@@ -941,7 +933,6 @@ jobs:
|
||||
- test
|
||||
- windows
|
||||
- macos
|
||||
- vm
|
||||
- cross
|
||||
- ios
|
||||
- wasm
|
||||
@@ -991,7 +982,6 @@ jobs:
|
||||
- test
|
||||
- windows
|
||||
- macos
|
||||
- vm
|
||||
- wasm
|
||||
- fuzz
|
||||
- race-root-integration
|
||||
|
||||
@@ -23,8 +23,8 @@ jobs:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Run update-flakes
|
||||
run: ./update-flake.sh
|
||||
- name: Run updateflakes
|
||||
run: ./tool/go run ./tool/updateflakes
|
||||
|
||||
- name: Get access token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
@@ -33,6 +33,9 @@ jobs:
|
||||
# Get token for app: https://github.com/apps/tailscale-code-updater
|
||||
app-id: ${{ secrets.CODE_UPDATER_APP_ID }}
|
||||
private-key: ${{ secrets.CODE_UPDATER_APP_PRIVATE_KEY }}
|
||||
# Limit the token to only pushing a branch and opening a pull request.
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 #v8.1.0
|
||||
@@ -41,8 +44,8 @@ jobs:
|
||||
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
committer: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
branch: flakes
|
||||
commit-message: "go.mod.sri: update SRI hash for go.mod changes"
|
||||
title: "go.mod.sri: update SRI hash for go.mod changes"
|
||||
commit-message: "flakehashes.json: update SRI hash for go.mod changes"
|
||||
title: "flakehashes.json: update SRI hash for go.mod changes"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
|
||||
@@ -29,6 +29,9 @@ jobs:
|
||||
# Get token for app: https://github.com/apps/tailscale-code-updater
|
||||
app-id: ${{ secrets.CODE_UPDATER_APP_ID }}
|
||||
private-key: ${{ secrets.CODE_UPDATER_APP_PRIVATE_KEY }}
|
||||
# Limit the token to only pushing a branch and opening a pull request.
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Send pull request
|
||||
id: pull-request
|
||||
|
||||
@@ -14,15 +14,17 @@ on:
|
||||
- main
|
||||
- "release-branch/*"
|
||||
paths:
|
||||
- .github/workflows/vet.yml
|
||||
- "**.go"
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/vet.yml
|
||||
- "**.go"
|
||||
|
||||
jobs:
|
||||
vet:
|
||||
runs-on: [ self-hosted, linux ]
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: security lint GitHub Actions with zizmor
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
with:
|
||||
min-severity: high
|
||||
advanced-security: false
|
||||
annotations: true
|
||||
+6
-1
@@ -1,12 +1,15 @@
|
||||
# Binaries for programs and plugins
|
||||
*~
|
||||
*.tmp
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.spk
|
||||
|
||||
*.exe
|
||||
# tool/go.exe is built specially and committed.
|
||||
!/tool/go.exe
|
||||
|
||||
cmd/tailscale/tailscale
|
||||
cmd/tailscaled/tailscaled
|
||||
ssh/tailssh/testcontainers/tailscaled
|
||||
@@ -55,3 +58,5 @@ client/web/build/assets
|
||||
|
||||
# Ignore syncthing state directory.
|
||||
/.stfolder
|
||||
fbstatus
|
||||
gafpush
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Tests for testdata/tailscale.com.policy.yml.
|
||||
#
|
||||
# Run from the parent directory with:
|
||||
# go run . -policy testdata/tailscale.com.policy.yml \
|
||||
# -tests testdata/tailscale.com.policy-test.yml
|
||||
|
||||
teams:
|
||||
# Members are picked solely so tests below can refer to them.
|
||||
# `alice` and `bob` are control-protocol-owners *and* dev. `carol`,
|
||||
# `dave`, and `eve` are dev only. `outsider` is in nothing.
|
||||
tailscale/control-protocol-owners:
|
||||
- alice
|
||||
- bob
|
||||
tailscale/dev:
|
||||
- alice
|
||||
- bob
|
||||
- carol
|
||||
- dave
|
||||
- eve
|
||||
|
||||
tests:
|
||||
# ------------------------------------------------------------------
|
||||
# Baseline: every PR (touching tailcfg/ or not) requires a +1 from a
|
||||
# tailscale/dev member.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
- name: non-tailcfg PR with no review is pending
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- README.md
|
||||
- cmd/tailscale/main.go
|
||||
expect:
|
||||
status: pending
|
||||
rules:
|
||||
"tailcfg changes approved by control-protocol-owners": skipped
|
||||
"tailcfg changes overridden by another tailscale/dev": skipped
|
||||
"any tailscale/dev review": pending
|
||||
|
||||
- name: non-tailcfg PR with dev review is approved
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- README.md
|
||||
reviews:
|
||||
- user: dave
|
||||
state: approved
|
||||
expect:
|
||||
status: approved
|
||||
rules:
|
||||
"any tailscale/dev review": approved
|
||||
|
||||
- name: non-tailcfg PR with thumbs-up comment from dev is approved
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- README.md
|
||||
comments:
|
||||
- user: dave
|
||||
body: ":+1:"
|
||||
expect:
|
||||
status: approved
|
||||
rules:
|
||||
"any tailscale/dev review": approved
|
||||
|
||||
- name: non-tailcfg PR with non-dev review is pending
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- README.md
|
||||
reviews:
|
||||
- user: outsider
|
||||
state: approved
|
||||
expect:
|
||||
status: pending
|
||||
rules:
|
||||
"any tailscale/dev review": pending
|
||||
|
||||
- name: PR with no files at all is pending without a dev review
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files: []
|
||||
expect:
|
||||
status: pending
|
||||
rules:
|
||||
"any tailscale/dev review": pending
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# tailcfg/ requires BOTH a control-protocol-owners review (or an
|
||||
# override comment) AND a dev +1. Owners are in tailscale/dev too,
|
||||
# so their single review satisfies both gates.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
- name: tailcfg change without anything is pending
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
expect:
|
||||
status: pending
|
||||
rules:
|
||||
"tailcfg changes approved by control-protocol-owners": pending
|
||||
"tailcfg changes overridden by another tailscale/dev": pending
|
||||
"any tailscale/dev review": pending
|
||||
|
||||
- name: a single owner review approves both the owner rule and the dev rule
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
reviews:
|
||||
- user: alice
|
||||
state: approved
|
||||
expect:
|
||||
status: approved
|
||||
rules:
|
||||
"tailcfg changes approved by control-protocol-owners": approved
|
||||
"any tailscale/dev review": approved
|
||||
|
||||
- name: owner cannot self-approve their own tailcfg PR
|
||||
pull_request:
|
||||
author: alice
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
reviews:
|
||||
- user: alice
|
||||
state: approved
|
||||
expect:
|
||||
status: pending
|
||||
|
||||
- name: non-owner dev review alone leaves the tailcfg gate pending
|
||||
pull_request:
|
||||
author: alice
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
reviews:
|
||||
- user: carol
|
||||
state: approved
|
||||
expect:
|
||||
status: pending
|
||||
rules:
|
||||
"tailcfg changes approved by control-protocol-owners": pending
|
||||
"tailcfg changes overridden by another tailscale/dev": pending
|
||||
"any tailscale/dev review": approved
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# policybot-override flow on a tailcfg/ PR.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# An override comment by itself only satisfies the override rule.
|
||||
# The baseline dev-review rule still needs a separate +1.
|
||||
- name: override comment alone leaves baseline dev review pending
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
comments:
|
||||
- user: dave
|
||||
body: "policybot-override: emergency rollback"
|
||||
expect:
|
||||
status: pending
|
||||
rules:
|
||||
"tailcfg changes overridden by another tailscale/dev": approved
|
||||
"any tailscale/dev review": pending
|
||||
|
||||
- name: override comment plus a separate dev review approves
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
comments:
|
||||
- user: dave
|
||||
body: "policybot-override: emergency rollback"
|
||||
reviews:
|
||||
- user: eve
|
||||
state: approved
|
||||
expect:
|
||||
status: approved
|
||||
|
||||
# One dev doing BOTH (post the override AND a regular +1) works too.
|
||||
- name: same dev posts both the override and a +1 review
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
comments:
|
||||
- user: dave
|
||||
body: "policybot-override: shipping a typo fix"
|
||||
reviews:
|
||||
- user: dave
|
||||
state: approved
|
||||
expect:
|
||||
status: approved
|
||||
|
||||
- name: override comment from the author does not approve the override
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
comments:
|
||||
- user: carol
|
||||
body: "policybot-override: please let me ship this"
|
||||
expect:
|
||||
status: pending
|
||||
rules:
|
||||
"tailcfg changes overridden by another tailscale/dev": pending
|
||||
|
||||
- name: override comment from a non-dev does not approve the override
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
comments:
|
||||
- user: outsider
|
||||
body: "policybot-override: I am not on the dev team"
|
||||
expect:
|
||||
rules:
|
||||
"tailcfg changes overridden by another tailscale/dev": pending
|
||||
|
||||
- name: empty-reason policybot-override does not approve
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
comments:
|
||||
- user: dave
|
||||
body: "policybot-override:"
|
||||
expect:
|
||||
rules:
|
||||
"tailcfg changes overridden by another tailscale/dev": pending
|
||||
|
||||
# Defaults regression guards: a normal review approval from a dev
|
||||
# member must NOT silently approve the override rule. Same for a
|
||||
# plain :+1: comment. Without the explicit `methods: github_review:
|
||||
# false` / `comments: []` on the override rule, both would.
|
||||
- name: a regular dev review does not silently satisfy the override rule
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
reviews:
|
||||
- user: dave
|
||||
state: approved
|
||||
expect:
|
||||
rules:
|
||||
"tailcfg changes overridden by another tailscale/dev": pending
|
||||
|
||||
- name: a thumbs-up comment from a dev does not silently satisfy the override rule
|
||||
pull_request:
|
||||
author: carol
|
||||
changed_files:
|
||||
- tailcfg/tailcfg.go
|
||||
comments:
|
||||
- user: dave
|
||||
body: ":+1:"
|
||||
expect:
|
||||
rules:
|
||||
"tailcfg changes overridden by another tailscale/dev": pending
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
# Approval policy for this repository, enforced by policy-bot
|
||||
# (https://github.com/palantir/policy-bot) running at
|
||||
# https://policybot.corp.ts.net.
|
||||
#
|
||||
# This file replaces the role GitHub's CODEOWNERS played: when a pull
|
||||
# request touches a path covered by a rule below, policy-bot posts a
|
||||
# status check that blocks merging until the required reviewers approve.
|
||||
#
|
||||
# Policy and rule syntax reference:
|
||||
# https://github.com/palantir/policy-bot/blob/develop/README.md
|
||||
# Example policy files (team-approval, disapproval, remote, etc.):
|
||||
# https://github.com/palantir/policy-bot/tree/develop/config/policy-examples
|
||||
#
|
||||
# Do not add to this policy without wide discussion.
|
||||
# See https://github.com/tailscale/corp/issues/13972.
|
||||
|
||||
policy:
|
||||
approval:
|
||||
# tailcfg/ has an extra gate: either a control-protocol-owners
|
||||
# review or an explicit policybot-override: comment from a dev.
|
||||
# The block is skipped on PRs that don't touch tailcfg/.
|
||||
- or:
|
||||
- tailcfg changes approved by control-protocol-owners
|
||||
- tailcfg changes overridden by another tailscale/dev
|
||||
# And every PR, regardless of files, needs a +1 from a dev.
|
||||
# This rule also keeps the policy from collapsing into "all rules
|
||||
# skipped" (which policy-bot treats as a failure) on non-tailcfg
|
||||
# PRs.
|
||||
- any tailscale/dev review
|
||||
|
||||
approval_rules:
|
||||
- name: tailcfg changes approved by control-protocol-owners
|
||||
if:
|
||||
changed_files:
|
||||
paths:
|
||||
- "^tailcfg/"
|
||||
requires:
|
||||
count: 1
|
||||
teams:
|
||||
- "tailscale/control-protocol-owners"
|
||||
|
||||
- name: tailcfg changes overridden by another tailscale/dev
|
||||
description: |
|
||||
Any member of @tailscale/dev (other than the PR author) can
|
||||
override the control-protocol-owners requirement by leaving a
|
||||
comment of the form
|
||||
|
||||
policybot-override: <reason>
|
||||
|
||||
on the pull request. The reason can be anything but should
|
||||
explain why the override is appropriate; it stays in the PR
|
||||
conversation as a record. The override comment also counts as
|
||||
that developer's approval.
|
||||
if:
|
||||
changed_files:
|
||||
paths:
|
||||
- "^tailcfg/"
|
||||
requires:
|
||||
count: 1
|
||||
teams:
|
||||
- "tailscale/dev"
|
||||
options:
|
||||
methods:
|
||||
# Explicitly turn off the defaults (github_review: true,
|
||||
# comments: [":+1:", "👍"]) so the ONLY way to satisfy this
|
||||
# rule is a "policybot-override:" comment. Otherwise a normal
|
||||
# review approval or thumbs-up from any tailscale/dev member
|
||||
# would silently pass the rule.
|
||||
github_review: false
|
||||
comments: []
|
||||
comment_patterns:
|
||||
- '^policybot-override: \S.*'
|
||||
|
||||
- name: any tailscale/dev review
|
||||
description: |
|
||||
Every PR needs at least one approval from a member of
|
||||
@tailscale/dev. policy-bot's default approval methods count
|
||||
a GitHub review approval, a ":+1:" comment, or a "👍"
|
||||
comment as approval. The PR author cannot approve their
|
||||
own PR.
|
||||
requires:
|
||||
count: 1
|
||||
teams:
|
||||
- "tailscale/dev"
|
||||
+7
-1
@@ -1 +1,7 @@
|
||||
/tailcfg/ @tailscale/control-protocol-owners
|
||||
# This repository does NOT use GitHub's CODEOWNERS for review enforcement.
|
||||
# Approval policies live in .policy.yml at the repository root and are
|
||||
# enforced by policy-bot (https://github.com/palantir/policy-bot).
|
||||
#
|
||||
# To change required reviewers for a path, edit .policy.yml.
|
||||
#
|
||||
# See https://github.com/tailscale/corp/issues/13972.
|
||||
|
||||
@@ -10,7 +10,7 @@ vet: ## Run go vet
|
||||
|
||||
tidy: ## Run go mod tidy and update nix flake hashes
|
||||
./tool/go mod tidy
|
||||
./update-flake.sh
|
||||
./tool/go run ./tool/updateflakes
|
||||
|
||||
lint: ## Run golangci-lint
|
||||
./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run
|
||||
@@ -137,15 +137,66 @@ publishdevproxy: check-image-repo ## Build and publish k8s-proxy image to locati
|
||||
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
|
||||
@GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./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: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
|
||||
echo "Testing on ubuntu:focal, ubuntu:jammy, ubuntu:noble, alpine:latest (in parallel)" && \
|
||||
docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers & \
|
||||
docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers & \
|
||||
docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers & \
|
||||
docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers & \
|
||||
wait
|
||||
|
||||
.PHONY: generate
|
||||
generate: ## Generate code
|
||||
./tool/go generate ./...
|
||||
|
||||
.PHONY: tsapp-build-and-flash-pi
|
||||
tsapp-build-and-flash-pi: ## Build a tsapp-pi.arm64 GAF from HEAD and flash a local SD card (macOS auto-detects the disk; pass DISK=/dev/sdX on Linux)
|
||||
cd gokrazy && ../tool/go run build.go --gaf --app=tsapp-pi.arm64
|
||||
./tool/go run --exec=sudo ./cmd/tailscale configure flash-appliance \
|
||||
--variant=pi-arm64 \
|
||||
--gaf=gokrazy/tsapp-pi.arm64.gaf \
|
||||
$(if $(DISK),--disk=$(DISK)) \
|
||||
$(if $(wildcard $(HOME)/.ssh/id_ed25519.pub),--add-ssh-authorized-keys=$(HOME)/.ssh/id_ed25519.pub)
|
||||
|
||||
.PHONY: tsapp-qemu-pi
|
||||
tsapp-qemu-pi: ## Build tsapp-pi.arm64 and boot it under qemu-system-aarch64 with a framebuffer GUI window and working network (requires mtools, dtc, qemu-efi-aarch64)
|
||||
cd gokrazy && ../tool/go run build.go --build --app=tsapp-pi.arm64
|
||||
# Extract the kernel from the FAT boot partition for direct -kernel boot.
|
||||
rm -f gokrazy/tsapp-pi.arm64.vmlinuz
|
||||
mcopy -i gokrazy/tsapp-pi.arm64.img@@4194304 ::vmlinuz gokrazy/tsapp-pi.arm64.vmlinuz
|
||||
# Use the "virt" machine (not raspi3b) because it provides working
|
||||
# PCI e1000 networking and, with UEFI firmware, an EFI framebuffer
|
||||
# via the ramfb device. The raspi3b machine's USB NIC emulation is
|
||||
# too broken for DHCP and its SoC watchdog reboots the guest.
|
||||
#
|
||||
# Find the UEFI firmware. Common paths:
|
||||
# Debian/Ubuntu: /usr/share/qemu-efi-aarch64/QEMU_EFI.fd
|
||||
# Homebrew: /opt/homebrew/share/qemu/edk2-aarch64-code.fd
|
||||
# Fedora: /usr/share/edk2/aarch64/QEMU_EFI.fd
|
||||
QEMU_EFI=$$(for f in \
|
||||
/usr/share/qemu-efi-aarch64/QEMU_EFI.fd \
|
||||
/opt/homebrew/share/qemu/edk2-aarch64-code.fd \
|
||||
/usr/share/edk2/aarch64/QEMU_EFI.fd \
|
||||
$$(dirname $$(which qemu-system-aarch64))/../share/qemu/edk2-aarch64-code.fd; do \
|
||||
[ -f "$$f" ] && echo "$$f" && break; \
|
||||
done) && \
|
||||
[ -n "$$QEMU_EFI" ] || { echo "error: cannot find QEMU EFI firmware (install qemu-efi-aarch64)"; exit 1; } && \
|
||||
qemu-system-aarch64 \
|
||||
-M virt -cpu cortex-a53 -m 1G \
|
||||
-bios "$$QEMU_EFI" \
|
||||
-device ramfb \
|
||||
-device e1000,netdev=net0 -netdev user,id=net0 \
|
||||
-kernel gokrazy/tsapp-pi.arm64.vmlinuz \
|
||||
-append "console=ttyAMA0,115200 nowatchdog gokrazy.log_to_serial=1 root=PARTUUID=60c24cc1-f3f9-427a-8199-dd02023b0001/PARTNROFF=1 ro init=/gokrazy/init rootwait" \
|
||||
-drive file=gokrazy/tsapp-pi.arm64.img,format=raw,if=none,id=disk0 \
|
||||
-device virtio-blk-device,drive=disk0 \
|
||||
-serial mon:stdio
|
||||
|
||||
.PHONY: tsapp-push-pi
|
||||
tsapp-push-pi: ## Build a tsapp-pi.arm64 GAF from HEAD and push it to a running Pi over the network (pass PI=<ip>)
|
||||
@[ -n "$(PI)" ] || { echo "usage: make tsapp-push-pi PI=<ip-address>"; exit 1; }
|
||||
cd gokrazy && ../tool/go run build.go --gaf --app=tsapp-pi.arm64
|
||||
./tool/go run ./gokrazy/gafpush --gaf=gokrazy/tsapp-pi.arm64.gaf --pi=$(PI)
|
||||
|
||||
.PHONY: pin-github-actions
|
||||
pin-github-actions:
|
||||
./tool/go tool github.com/stacklok/frizbee actions .github/workflows
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.97.0
|
||||
1.103.0
|
||||
|
||||
@@ -736,6 +736,7 @@ func TestRateLogger(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouteStoreMetrics(t *testing.T) {
|
||||
clientmetric.ResetForTest(t)
|
||||
metricStoreRoutes(1, 1)
|
||||
metricStoreRoutes(1, 1) // the 1 buckets value should be 2
|
||||
metricStoreRoutes(5, 5) // the 5 buckets value should be 1
|
||||
|
||||
+23
-39
@@ -5,18 +5,20 @@ package appc
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/ipn/ipnext"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/appctype"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
|
||||
|
||||
func isEligibleConnector(peer tailcfg.NodeView) bool {
|
||||
func isPeerEligibleConnector(peer tailcfg.NodeView) bool {
|
||||
if !peer.Valid() || !peer.Hostinfo().Valid() {
|
||||
return false
|
||||
}
|
||||
@@ -39,7 +41,7 @@ func sortByPreference(ns []tailcfg.NodeView) {
|
||||
func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.NodeView {
|
||||
appTagsSet := set.SetOf(app.Connectors)
|
||||
matches := nb.AppendMatchingPeers(nil, func(n tailcfg.NodeView) bool {
|
||||
if !isEligibleConnector(n) {
|
||||
if !isPeerEligibleConnector(n) {
|
||||
return false
|
||||
}
|
||||
for _, t := range n.Tags().All() {
|
||||
@@ -53,50 +55,32 @@ func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.Nod
|
||||
return matches
|
||||
}
|
||||
|
||||
// PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers
|
||||
// want to be connectors for which domains.
|
||||
func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView, peers map[tailcfg.NodeID]tailcfg.NodeView) map[string][]tailcfg.NodeView {
|
||||
var m map[string][]tailcfg.NodeView
|
||||
// DNSAddrScheme is the custom URI scheme used for conn25-managed split DNS
|
||||
// entries to determine the destination at query time rather than configuration
|
||||
// time.
|
||||
const DNSAddrScheme = "tailscale-app"
|
||||
|
||||
func AppDNSRoutes(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView) map[string][]*dnstype.Resolver {
|
||||
if !hasCap(AppConnectorsExperimentalAttrName) {
|
||||
return m
|
||||
return nil
|
||||
}
|
||||
apps, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorAttr](self.CapMap(), AppConnectorsExperimentalAttrName)
|
||||
if err != nil {
|
||||
return m
|
||||
return nil
|
||||
}
|
||||
tagToDomain := make(map[string][]string)
|
||||
appNamesByDomain := map[string]string{}
|
||||
for _, app := range apps {
|
||||
for _, tag := range app.Connectors {
|
||||
tagToDomain[tag] = append(tagToDomain[tag], app.Domains...)
|
||||
for _, domain := range app.Domains {
|
||||
domain, _ = strings.CutPrefix(domain, "*.")
|
||||
domain = strings.ToLower(domain)
|
||||
// in the case of multiple apps specifying the same domain (which is misconfiguration
|
||||
// that should be validated at point of input) last write wins.
|
||||
appNamesByDomain[domain] = app.Name
|
||||
}
|
||||
}
|
||||
// NodeIDs are Comparable, and we have a map of NodeID to NodeView anyway, so
|
||||
// use a Set of NodeIDs to deduplicate, and populate into a []NodeView later.
|
||||
var work map[string]set.Set[tailcfg.NodeID]
|
||||
for _, peer := range peers {
|
||||
if !isEligibleConnector(peer) {
|
||||
continue
|
||||
}
|
||||
for _, t := range peer.Tags().All() {
|
||||
domains := tagToDomain[t]
|
||||
for _, domain := range domains {
|
||||
if work[domain] == nil {
|
||||
mak.Set(&work, domain, set.Set[tailcfg.NodeID]{})
|
||||
}
|
||||
work[domain].Add(peer.ID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate m. Make a []tailcfg.NodeView from []tailcfg.NodeID using the peers map.
|
||||
// And sort it to our preference.
|
||||
for domain, ids := range work {
|
||||
nodes := make([]tailcfg.NodeView, 0, ids.Len())
|
||||
for id := range ids {
|
||||
nodes = append(nodes, peers[id])
|
||||
}
|
||||
sortByPreference(nodes)
|
||||
mak.Set(&m, domain, nodes)
|
||||
m := make(map[string][]*dnstype.Resolver, len(appNamesByDomain))
|
||||
for domain, appName := range appNamesByDomain {
|
||||
m[domain] = []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName), UseWithExitNode: true}}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
+82
-63
@@ -5,17 +5,18 @@ package appc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/ipn/ipnext"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/appctype"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/types/opt"
|
||||
)
|
||||
|
||||
func TestPickSplitDNSPeers(t *testing.T) {
|
||||
func TestAppDNSRoutes(t *testing.T) {
|
||||
getBytesForAttr := func(name string, domains []string, tags []string) []byte {
|
||||
attr := appctype.AppConnectorAttr{
|
||||
Name: name,
|
||||
@@ -32,83 +33,105 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
||||
appTwoBytes := getBytesForAttr("app2", []string{"a.example.com"}, []string{"tag:two"})
|
||||
appThreeBytes := getBytesForAttr("app3", []string{"woo.b.example.com", "hoo.b.example.com"}, []string{"tag:three1", "tag:three2"})
|
||||
appFourBytes := getBytesForAttr("app4", []string{"woo.b.example.com", "c.example.com"}, []string{"tag:four1", "tag:four2"})
|
||||
appFiveBytes := getBytesForAttr("app5", []string{"*.example.com", "example.com"}, []string{"tag:one"})
|
||||
appSixBytes := getBytesForAttr("app6", []string{"*.Example.com", "EXAMPLE.com", "EXAMPLE.COM"}, []string{"tag:one"})
|
||||
|
||||
makeNodeView := func(id tailcfg.NodeID, name string, tags []string) tailcfg.NodeView {
|
||||
return (&tailcfg.Node{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Tags: tags,
|
||||
Hostinfo: (&tailcfg.Hostinfo{AppConnector: opt.NewBool(true)}).View(),
|
||||
}).View()
|
||||
resolver := func(appName string) []*dnstype.Resolver {
|
||||
return []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName), UseWithExitNode: true}}
|
||||
}
|
||||
nvp1 := makeNodeView(1, "p1", []string{"tag:one"})
|
||||
nvp2 := makeNodeView(2, "p2", []string{"tag:four1", "tag:four2"})
|
||||
nvp3 := makeNodeView(3, "p3", []string{"tag:two", "tag:three1"})
|
||||
nvp4 := makeNodeView(4, "p4", []string{"tag:two", "tag:three2", "tag:four2"})
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
want map[string][]tailcfg.NodeView
|
||||
peers []tailcfg.NodeView
|
||||
hasCap bool
|
||||
config []tailcfg.RawMessage
|
||||
want map[string][]*dnstype.Resolver
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
name: "no-capability", // hasCap false should return nil regardless of config.
|
||||
hasCap: false,
|
||||
},
|
||||
{
|
||||
name: "bad-config", // bad config should return a nil map rather than error.
|
||||
name: "no-apps", // hasCap true but no configured apps returns an empty map.
|
||||
hasCap: true,
|
||||
want: map[string][]*dnstype.Resolver{},
|
||||
},
|
||||
{
|
||||
name: "bad-config", // bad config should return nil rather than error.
|
||||
hasCap: true,
|
||||
config: []tailcfg.RawMessage{tailcfg.RawMessage(`hey`)},
|
||||
},
|
||||
{
|
||||
name: "no-peers",
|
||||
name: "single-app",
|
||||
hasCap: true,
|
||||
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
|
||||
},
|
||||
{
|
||||
name: "peers-that-are-not-connectors",
|
||||
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
|
||||
peers: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
ID: 5,
|
||||
Name: "p5",
|
||||
Tags: []string{"tag:one"},
|
||||
}).View(),
|
||||
(&tailcfg.Node{
|
||||
ID: 6,
|
||||
Name: "p6",
|
||||
Tags: []string{"tag:one"},
|
||||
}).View(),
|
||||
want: map[string][]*dnstype.Resolver{
|
||||
"example.com": resolver("app1"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "peers-that-dont-match-tags",
|
||||
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
|
||||
peers: []tailcfg.NodeView{
|
||||
makeNodeView(5, "p5", []string{"tag:seven"}),
|
||||
makeNodeView(6, "p6", nil),
|
||||
name: "single-app-multi-domain",
|
||||
hasCap: true,
|
||||
config: []tailcfg.RawMessage{tailcfg.RawMessage(appThreeBytes)},
|
||||
want: map[string][]*dnstype.Resolver{
|
||||
"woo.b.example.com": resolver("app3"),
|
||||
"hoo.b.example.com": resolver("app3"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching-tagged-connector-peers",
|
||||
name: "multi-app-no-overlap",
|
||||
hasCap: true,
|
||||
config: []tailcfg.RawMessage{
|
||||
tailcfg.RawMessage(appOneBytes),
|
||||
tailcfg.RawMessage(appTwoBytes),
|
||||
tailcfg.RawMessage(appThreeBytes),
|
||||
tailcfg.RawMessage(appFourBytes),
|
||||
},
|
||||
peers: []tailcfg.NodeView{
|
||||
nvp1,
|
||||
nvp2,
|
||||
nvp3,
|
||||
nvp4,
|
||||
makeNodeView(5, "p5", nil),
|
||||
want: map[string][]*dnstype.Resolver{
|
||||
"example.com": resolver("app1"),
|
||||
"a.example.com": resolver("app2"),
|
||||
},
|
||||
want: map[string][]tailcfg.NodeView{
|
||||
// p5 has no matching tags and so doesn't appear
|
||||
"example.com": {nvp1},
|
||||
"a.example.com": {nvp3, nvp4},
|
||||
"woo.b.example.com": {nvp2, nvp3, nvp4},
|
||||
"hoo.b.example.com": {nvp3, nvp4},
|
||||
"c.example.com": {nvp2, nvp4},
|
||||
},
|
||||
{
|
||||
name: "domain-collision-last-write-wins",
|
||||
hasCap: true,
|
||||
config: []tailcfg.RawMessage{
|
||||
tailcfg.RawMessage(appThreeBytes), // app3: woo.b.example.com, hoo.b.example.com
|
||||
tailcfg.RawMessage(appFourBytes), // app4: woo.b.example.com, c.example.com
|
||||
},
|
||||
want: map[string][]*dnstype.Resolver{
|
||||
// app4 overwrites app3 for the shared domain
|
||||
"woo.b.example.com": resolver("app4"),
|
||||
"hoo.b.example.com": resolver("app3"),
|
||||
"c.example.com": resolver("app4"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcards-are-stripped-and-deduped",
|
||||
hasCap: true,
|
||||
config: []tailcfg.RawMessage{tailcfg.RawMessage(appFiveBytes)},
|
||||
want: map[string][]*dnstype.Resolver{
|
||||
// *.example.com and example.com should both normalize to example.com.
|
||||
"example.com": resolver("app5"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "domains-are-normalized-and-deduped",
|
||||
hasCap: true,
|
||||
config: []tailcfg.RawMessage{tailcfg.RawMessage(appSixBytes)},
|
||||
want: map[string][]*dnstype.Resolver{
|
||||
// *.Example.com, EXAMPLE.com, EXAMPLE.COM should all normalize to example.com.
|
||||
"example.com": resolver("app6"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sub-domains-and-top-domains-do-not-collide",
|
||||
hasCap: true,
|
||||
config: []tailcfg.RawMessage{
|
||||
tailcfg.RawMessage(appTwoBytes),
|
||||
tailcfg.RawMessage(appFiveBytes),
|
||||
},
|
||||
want: map[string][]*dnstype.Resolver{
|
||||
// *.example.com normalizes to example.com; a.example.com remains distinct.
|
||||
"a.example.com": resolver("app2"),
|
||||
"example.com": resolver("app5"),
|
||||
},
|
||||
},
|
||||
} {
|
||||
@@ -120,15 +143,11 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
selfView := selfNode.View()
|
||||
peers := map[tailcfg.NodeID]tailcfg.NodeView{}
|
||||
for _, p := range tt.peers {
|
||||
peers[p.ID()] = p
|
||||
}
|
||||
got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool {
|
||||
return true
|
||||
}, selfView, peers)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
got := AppDNSRoutes(func(_ tailcfg.NodeCapability) bool {
|
||||
return tt.hasCap
|
||||
}, selfView)
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
t.Fatalf("AppDNSRoutes (-want, +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,6 +51,13 @@ while [ "$#" -gt 1 ]; do
|
||||
ldflags="$ldflags -w -s"
|
||||
tags="${tags:+$tags,},$(GOOS= GOARCH= $go run ./cmd/featuretags --min)"
|
||||
;;
|
||||
--strip)
|
||||
# --min overrides your flags, when you're using custom tags and want to
|
||||
# additionally strip symbols to help reduce the size, this is the easiest
|
||||
# way to do it.
|
||||
shift
|
||||
ldflags="$ldflags -w -s"
|
||||
;;
|
||||
--box)
|
||||
if [ ! -z "${TAGS:-}" ]; then
|
||||
echo "set either --box or \$TAGS, but not both"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailscaleroot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/util/cibuild"
|
||||
)
|
||||
|
||||
// TestTsgoRevInCacheKey verifies that the Tailscale Go toolchain's git
|
||||
// revision (from go.toolchain.rev) is blended into Go build cache keys.
|
||||
// Without this, bumping the toolchain to a new commit that doesn't change
|
||||
// the Go version number would silently reuse stale cached build artifacts.
|
||||
//
|
||||
// See https://github.com/tailscale/tailscale/issues/36589.
|
||||
func TestTsgoRevInCacheKey(t *testing.T) {
|
||||
goRoot := goEnv(t, "GOROOT")
|
||||
isTsgo := strings.Contains(goRoot, "/.cache/tsgo/")
|
||||
if !cibuild.OnTailscaleCI() && !isTsgo {
|
||||
t.Skip("skipping; not in Tailscale CI and not using the Tailscale Go toolchain")
|
||||
}
|
||||
|
||||
rev := strings.TrimSpace(GoToolchainRev)
|
||||
if rev == "" {
|
||||
t.Fatal("go.toolchain.rev is empty")
|
||||
}
|
||||
|
||||
// Build the small stdlib "errors" package with GODEBUG=gocachehash=1,
|
||||
// which causes cmd/go to log its cache key computations to stderr.
|
||||
cmd := exec.Command("go", "build", "errors")
|
||||
cmd.Env = append(os.Environ(), "GODEBUG=gocachehash=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("go build errors failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// The cache key output should contain the toolchain rev alongside the
|
||||
// Go version, e.g.:
|
||||
// HASH[moduleIndex]: "go1.26.2 dfe2a5fd8ee2e68b08ce5ff259269f50ecadf2f4"
|
||||
if !strings.Contains(string(out), rev) {
|
||||
t.Errorf("go.toolchain.rev %q not found in GODEBUG=gocachehash=1 output:\n%s", rev, out)
|
||||
}
|
||||
}
|
||||
|
||||
func goEnv(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("go", "env", key).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go env %s: %v", key, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
@@ -10,13 +10,55 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
)
|
||||
|
||||
// rateLimitedError is returned from cert-fetching methods when the
|
||||
// upstream ACME CA reported a rate limit. Callers should unpack it via
|
||||
// [RateLimitRetryAfter].
|
||||
type rateLimitedError struct {
|
||||
retryAfter time.Duration
|
||||
underlying error
|
||||
}
|
||||
|
||||
func (e rateLimitedError) Error() string { return e.underlying.Error() }
|
||||
func (e rateLimitedError) Unwrap() error { return e.underlying }
|
||||
|
||||
// RateLimitRetryAfter reports whether err was a rate-limit failure from
|
||||
// the upstream ACME CA and, if so, returns the CA's suggested wait
|
||||
// (zero if none was provided).
|
||||
func RateLimitRetryAfter(err error) (retryAfter time.Duration, ok bool) {
|
||||
var rl rateLimitedError
|
||||
if errors.As(err, &rl) {
|
||||
return rl.retryAfter, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// retryAfterFromHeader parses a Retry-After header, matching the
|
||||
// delta-seconds + HTTP-date pattern in tempfork/acme/http.go.
|
||||
func retryAfterFromHeader(h http.Header) time.Duration {
|
||||
v := h.Get("Retry-After")
|
||||
if i, err := strconv.Atoi(v); err == nil {
|
||||
return time.Duration(i) * time.Second
|
||||
}
|
||||
t, err := http.ParseTime(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
d := time.Until(t)
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// SetDNS adds a DNS TXT record for the given domain name, containing
|
||||
// the provided TXT value. The intended use case is answering
|
||||
// LetsEncrypt/ACME dns-01 challenges.
|
||||
@@ -43,6 +85,8 @@ func (lc *Client) SetDNS(ctx context.Context, name, value string) error {
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// Rate-limit failures can be identified via [RateLimitRetryAfter].
|
||||
//
|
||||
// Deprecated: use [Client.CertPair].
|
||||
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
return defaultClient.CertPair(ctx, domain)
|
||||
@@ -52,6 +96,8 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// Rate-limit failures can be identified via [RateLimitRetryAfter].
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
return lc.CertPairWithValidity(ctx, domain, 0)
|
||||
@@ -65,10 +111,18 @@ func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM
|
||||
// least the given duration, if permitted by the CA. If the certificate is
|
||||
// valid, but for less than minValidity, it will be synchronously renewed.
|
||||
//
|
||||
// Rate-limit failures can be identified via [RateLimitRetryAfter].
|
||||
//
|
||||
// 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) {
|
||||
res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
|
||||
if err != nil {
|
||||
if hse, ok := errors.AsType[httpStatusError](err); ok && hse.HTTPStatus == http.StatusTooManyRequests {
|
||||
return nil, nil, rateLimitedError{
|
||||
retryAfter: retryAfterFromHeader(hse.Header),
|
||||
underlying: err,
|
||||
}
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
// with ?type=pair, the response PEM is first the one private
|
||||
|
||||
@@ -50,6 +50,9 @@ type DebugPortmapOpts struct {
|
||||
// process.
|
||||
//
|
||||
// opts can be nil; if so, default values will be used.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts) (io.ReadCloser, error) {
|
||||
vals := make(url.Values)
|
||||
if opts == nil {
|
||||
|
||||
+309
-8
@@ -2,6 +2,12 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package local contains a Go client for the Tailscale LocalAPI.
|
||||
//
|
||||
// The APIs in this package vary in maturity: some methods are considered
|
||||
// stable APIs and are documented as such, while others are not necessarily
|
||||
// stable and are subject to change between releases. Methods without an
|
||||
// explicit "API maturity" note in their documentation should be assumed
|
||||
// to be unstable.
|
||||
package local
|
||||
|
||||
import (
|
||||
@@ -135,6 +141,9 @@ 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) DoLocalRequest(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("Tailscale-Cap", strconv.Itoa(int(tailcfg.CurrentCapabilityVersion)))
|
||||
lc.tsClientOnce.Do(func() {
|
||||
@@ -280,7 +289,7 @@ func (lc *Client) sendWithHeaders(
|
||||
}
|
||||
if res.StatusCode != wantStatus {
|
||||
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
|
||||
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
|
||||
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode, res.Header}
|
||||
}
|
||||
return slurp, res.Header, nil
|
||||
}
|
||||
@@ -288,6 +297,7 @@ func (lc *Client) sendWithHeaders(
|
||||
type httpStatusError struct {
|
||||
error
|
||||
HTTPStatus int
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
func (lc *Client) get200(ctx context.Context, path string) ([]byte, error) {
|
||||
@@ -316,6 +326,8 @@ 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.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
@@ -327,6 +339,39 @@ func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsR
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
}
|
||||
|
||||
// WhoIsForService is like [Client.WhoIs] but scopes the returned CapMap to
|
||||
// capabilities that apply to the named VIP service. This enables per-service
|
||||
// capability resolution on hosts that advertise multiple VIP services.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) WhoIsForService(ctx context.Context, remoteAddr string, svcName tailcfg.ServiceName) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr)+"&svc_name="+url.QueryEscape(string(svcName)))
|
||||
if err != nil {
|
||||
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
|
||||
return nil, ErrPeerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
}
|
||||
|
||||
// WhoIsForIP is like [Client.WhoIs] but scopes the returned CapMap to
|
||||
// capabilities that apply to the given destination IP. The IP may be a
|
||||
// VIP service address, the node's own tailnet address, or any other
|
||||
// routable IP the node handles.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) WhoIsForIP(ctx context.Context, remoteAddr string, dst netip.Addr) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr)+"&dst_ip="+url.QueryEscape(dst.String()))
|
||||
if err != nil {
|
||||
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
|
||||
return nil, ErrPeerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
}
|
||||
|
||||
// ErrPeerNotFound is returned by [Client.WhoIs], [Client.WhoIsNodeKey] and
|
||||
// [Client.WhoIsProto] when a peer is not found.
|
||||
var ErrPeerNotFound = errors.New("peer not found")
|
||||
@@ -334,6 +379,8 @@ var ErrPeerNotFound = errors.New("peer not found")
|
||||
// WhoIsNodeKey returns the owner of the given wireguard public key.
|
||||
//
|
||||
// If not found, the error is ErrPeerNotFound.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) 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 {
|
||||
@@ -349,6 +396,8 @@ 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].
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) 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 {
|
||||
@@ -425,6 +474,9 @@ func (lc *Client) SetGauge(ctx context.Context, name string, value int) error {
|
||||
|
||||
// TailDaemonLogs returns a stream the Tailscale daemon's logs as they arrive.
|
||||
// Close the context to stop the stream.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/logtap", nil)
|
||||
if err != nil {
|
||||
@@ -441,12 +493,18 @@ func (lc *Client) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
|
||||
}
|
||||
|
||||
// EventBusGraph returns a graph of active publishers and subscribers in the eventbus
|
||||
// as a [eventbus.DebugTopics]
|
||||
// as a [eventbus.DebugTopics].
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) EventBusGraph(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/debug-bus-graph")
|
||||
}
|
||||
|
||||
// EventBusQueues returns a JSON snapshot of event bus queue depths per client.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) EventBusQueues(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/debug-bus-queues")
|
||||
}
|
||||
@@ -455,6 +513,9 @@ func (lc *Client) EventBusQueues(ctx context.Context) ([]byte, error) {
|
||||
// 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
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",
|
||||
@@ -523,6 +584,8 @@ type BugReportOpts struct {
|
||||
//
|
||||
// The opts type specifies options to pass to the Tailscale daemon when
|
||||
// generating this bug report.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (string, error) {
|
||||
qparams := make(url.Values)
|
||||
if opts.Note != "" {
|
||||
@@ -568,12 +631,17 @@ func (lc *Client) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (st
|
||||
//
|
||||
// This is the same as calling [Client.BugReportWithOpts] and only specifying the Note
|
||||
// field.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) 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.
|
||||
// These are development tools.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change or removal between releases.
|
||||
func (lc *Client) 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 {
|
||||
@@ -584,7 +652,10 @@ func (lc *Client) DebugAction(ctx context.Context, action string) error {
|
||||
|
||||
// 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.
|
||||
// These are development tools.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change or removal between releases.
|
||||
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 {
|
||||
@@ -594,7 +665,10 @@ func (lc *Client) DebugActionBody(ctx context.Context, action string, rbody io.R
|
||||
}
|
||||
|
||||
// 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.
|
||||
// These are development tools.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change or removal between releases.
|
||||
func (lc *Client) 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 {
|
||||
@@ -607,6 +681,27 @@ func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, erro
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// GetDebugResultJSON invokes a debug action and decodes the JSON response
|
||||
// into a value of type T. It avoids the marshal/unmarshal roundtrip that
|
||||
// callers of [Client.DebugResultJSON] otherwise need to do to get a typed
|
||||
// value.
|
||||
//
|
||||
// These are development tools.
|
||||
//
|
||||
// API maturity: this function is not considered a stable API and is
|
||||
// subject to change or removal between releases.
|
||||
func GetDebugResultJSON[T any](ctx context.Context, lc *Client, action string) (T, error) {
|
||||
var v T
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
if err := json.Unmarshal(body, &v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// QueryOptionalFeatures queries the optional features supported by the Tailscale daemon.
|
||||
func (lc *Client) QueryOptionalFeatures(ctx context.Context) (*apitype.OptionalFeatures, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-optional-features", 200, nil)
|
||||
@@ -636,6 +731,9 @@ 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) SetComponentDebugLogging(ctx context.Context, component string, d time.Duration) error {
|
||||
if !buildfeatures.HasDebug {
|
||||
return feature.ErrUnavailable
|
||||
@@ -664,6 +762,8 @@ func Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
}
|
||||
|
||||
// Status returns the Tailscale daemon's status.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return lc.status(ctx, "")
|
||||
}
|
||||
@@ -674,6 +774,8 @@ func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
}
|
||||
|
||||
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return lc.status(ctx, "?peers=false")
|
||||
}
|
||||
@@ -778,6 +880,9 @@ 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) CheckIPForwarding(ctx context.Context) error {
|
||||
if !buildfeatures.HasAdvertiseRoutes {
|
||||
return nil
|
||||
@@ -801,6 +906,9 @@ 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) CheckUDPGROForwarding(ctx context.Context) error {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/check-udp-gro-forwarding")
|
||||
if err != nil {
|
||||
@@ -850,6 +958,9 @@ func (lc *Client) CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPrefs returns the [ipn.Prefs] of the current Tailscale profile.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/prefs")
|
||||
if err != nil {
|
||||
@@ -867,6 +978,8 @@ func (lc *Client) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
|
||||
// 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.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) 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 {
|
||||
@@ -877,6 +990,9 @@ func (lc *Client) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Pref
|
||||
|
||||
// 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {
|
||||
if !buildfeatures.HasDNS {
|
||||
return nil, feature.ErrUnavailable
|
||||
@@ -910,7 +1026,26 @@ func (lc *Client) QueryDNS(ctx context.Context, name string, queryType string) (
|
||||
return res.Bytes, res.Resolvers, nil
|
||||
}
|
||||
|
||||
// StartLoginInteractive starts an interactive login.
|
||||
// StartLoginInteractive starts an interactive login, requesting a new
|
||||
// auth URL from the control plane if a login flow is not already in
|
||||
// progress. If one is, the existing auth URL is re-sent.
|
||||
//
|
||||
// The auth URL is not returned by this method; it is delivered
|
||||
// asynchronously to IPN bus watchers (see [Client.WatchIPNBus]) as an
|
||||
// [ipn.Notify] with a non-empty BrowseToURL field. StartLoginInteractive
|
||||
// returns as soon as the login has been requested; it does not wait for
|
||||
// the login to complete.
|
||||
//
|
||||
// Calling StartLoginInteractive does not itself change the node's
|
||||
// desired run state, but successfully completing the login does: the
|
||||
// node's WantRunning pref is set to true, so a stopped node
|
||||
// ("tailscale down") starts once the login finishes. If the login is
|
||||
// completed as a different user or node identity than the current
|
||||
// profile's, the node switches to an existing profile matching the new
|
||||
// identity if one exists, or else updates the current profile to the
|
||||
// new identity.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) StartLoginInteractive(ctx context.Context) error {
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil)
|
||||
return err
|
||||
@@ -935,6 +1070,8 @@ func (lc *Client) Logout(ctx context.Context) 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].
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
|
||||
return lc.UserDial(ctx, "tcp", host, port)
|
||||
}
|
||||
@@ -946,6 +1083,8 @@ 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].
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) UserDial(ctx context.Context, network, host string, port uint16) (net.Conn, error) {
|
||||
connCh := make(chan net.Conn, 1)
|
||||
trace := httptrace.ClientTrace{
|
||||
@@ -972,6 +1111,19 @@ func (lc *Client) UserDial(ctx context.Context, network, host string, port uint1
|
||||
if res.StatusCode != http.StatusSwitchingProtocols {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if res.StatusCode == http.StatusOK && res.Header.Get("Dial-Self") == "true" {
|
||||
// Server told us to dial the address ourselves rather than
|
||||
// proxying through the daemon. This happens for non-Tailscale
|
||||
// addresses where the daemon shouldn't dial as root on the
|
||||
// client's behalf. The server provides the resolved address
|
||||
// to avoid a TOCTOU race with DNS re-resolution.
|
||||
addr := res.Header.Get("Dial-Addr")
|
||||
if addr == "" {
|
||||
return nil, errors.New("server returned Dial-Self without Dial-Addr")
|
||||
}
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body)
|
||||
}
|
||||
// From here on, the underlying net.Conn is ours to use, but there
|
||||
@@ -997,6 +1149,10 @@ 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.
|
||||
//
|
||||
// API maturity: this is considered a stable API, though the returned
|
||||
// [tailcfg.DERPMap] type is subject to minor changes over time; its
|
||||
// general shape is stable.
|
||||
func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
|
||||
var derpMap tailcfg.DERPMap
|
||||
res, err := lc.send(ctx, "GET", "/localapi/v0/derpmap", 200, nil)
|
||||
@@ -1009,6 +1165,66 @@ func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error)
|
||||
return &derpMap, nil
|
||||
}
|
||||
|
||||
// CertDomains returns the list of domains for which the local tailscaled can
|
||||
// fetch TLS certificates, equivalent to the DNS.CertDomains field of the
|
||||
// current netmap. The returned list is sorted in ascending order, and is
|
||||
// empty if no netmap has been received yet.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) CertDomains(ctx context.Context) ([]string, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/cert-domains")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[[]string](body)
|
||||
}
|
||||
|
||||
// DNSConfig returns the [tailcfg.DNSConfig] from the current netmap.
|
||||
// It returns an error if no netmap has been received yet.
|
||||
// It is intended for callers that need fields like ExtraRecords or CertDomains
|
||||
// without pulling the rest of the netmap.
|
||||
func (lc *Client) DNSConfig(ctx context.Context) (*tailcfg.DNSConfig, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/dns-config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*tailcfg.DNSConfig](body)
|
||||
}
|
||||
|
||||
// PeerByID returns a peer's current full [tailcfg.Node] looked up by its
|
||||
// [tailcfg.NodeID]. It returns an error if no peer with that NodeID is in the
|
||||
// current netmap.
|
||||
//
|
||||
// It is intended for callers that observed a peer-mutation signal (e.g.
|
||||
// [ipn.Notify.PeerChangedPatch] or [ipn.Notify.PeersChanged]) and want the
|
||||
// latest state of the affected node without having to apply the patch
|
||||
// themselves.
|
||||
func (lc *Client) PeerByID(ctx context.Context, id tailcfg.NodeID) (*tailcfg.Node, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/peer-by-id?id="+strconv.FormatInt(int64(id), 10))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*tailcfg.Node](body)
|
||||
}
|
||||
|
||||
// UserProfile returns the current [tailcfg.UserProfile] for the given
|
||||
// [tailcfg.UserID]. It returns an error if no user with that UserID is in the
|
||||
// current netmap.
|
||||
//
|
||||
// It is the LocalAPI fallback for IPN-bus consumers that see a UserID
|
||||
// referenced by a peer Node and want to resolve it to a UserProfile. Sessions
|
||||
// opted in to [ipn.NotifyPeerChanges] / [ipn.NotifyPeerPatches] also receive
|
||||
// UserProfiles automatically via [ipn.Notify.UserProfiles].
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) UserProfile(ctx context.Context, id tailcfg.UserID) (*tailcfg.UserProfile, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/user-profile?id="+strconv.FormatInt(int64(id), 10))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*tailcfg.UserProfile](body)
|
||||
}
|
||||
|
||||
// PingOpts contains options for the ping request.
|
||||
//
|
||||
// The zero value is valid, which means to use defaults.
|
||||
@@ -1045,6 +1261,8 @@ func (lc *Client) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.Ping
|
||||
// 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.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) DisconnectControl(ctx context.Context) error {
|
||||
_, _, err := lc.sendWithHeaders(ctx, "POST", "/localapi/v0/disconnect-control", 200, nil, nil)
|
||||
if err != nil {
|
||||
@@ -1140,13 +1358,18 @@ 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.
|
||||
// In order to login to the new profile, the user must call
|
||||
// [Client.StartLoginInteractive].
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) SwitchToEmptyProfile(ctx context.Context) error {
|
||||
_, err := lc.send(ctx, "PUT", "/localapi/v0/profiles/", http.StatusCreated, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// SwitchProfile switches to the given profile.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) SwitchProfile(ctx context.Context, profile ipn.ProfileID) error {
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/profiles/"+url.PathEscape(string(profile)), 204, nil)
|
||||
return err
|
||||
@@ -1181,6 +1404,11 @@ func (lc *Client) QueryFeature(ctx context.Context, feature string) (*tailcfg.Qu
|
||||
return decodeJSON[*tailcfg.QueryFeatureResponse](body)
|
||||
}
|
||||
|
||||
// DebugDERPRegion reports diagnostic information about the DERP region with
|
||||
// the given ID or code.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) 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)
|
||||
@@ -1191,6 +1419,9 @@ func (lc *Client) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*
|
||||
}
|
||||
|
||||
// DebugPacketFilterRules returns the packet filter rules for the current device.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterRule, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-packet-filter-rules", 200, nil)
|
||||
if err != nil {
|
||||
@@ -1202,6 +1433,9 @@ 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) 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)
|
||||
@@ -1210,6 +1444,9 @@ func (lc *Client) DebugSetExpireIn(ctx context.Context, d time.Duration) error {
|
||||
|
||||
// DebugPeerRelaySessions returns debug information about the current peer
|
||||
// relay sessions running through this node.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) DebugPeerRelaySessions(ctx context.Context) (*status.ServerStatus, error) {
|
||||
body, err := lc.send(ctx, "GET", "/localapi/v0/debug-peer-relay-sessions", 200, nil)
|
||||
if err != nil {
|
||||
@@ -1222,6 +1459,9 @@ func (lc *Client) DebugPeerRelaySessions(ctx context.Context) (*status.ServerSta
|
||||
//
|
||||
// The provided context does not determine the lifetime of the
|
||||
// returned [io.ReadCloser].
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-capture", nil)
|
||||
if err != nil {
|
||||
@@ -1248,9 +1488,16 @@ 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*IPNBusWatcher, error) {
|
||||
m, err := mask.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET",
|
||||
"http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+fmt.Sprint(mask),
|
||||
"http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+string(m),
|
||||
nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1274,6 +1521,8 @@ 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.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/update/check")
|
||||
if err != nil {
|
||||
@@ -1290,6 +1539,8 @@ 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.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *Client) 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
|
||||
@@ -1298,6 +1549,9 @@ 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) DriveSetServerAddr(ctx context.Context, addr string) error {
|
||||
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/fileserver-address", http.StatusCreated, strings.NewReader(addr))
|
||||
return err
|
||||
@@ -1306,6 +1560,9 @@ 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) DriveShareSet(ctx context.Context, share *drive.Share) error {
|
||||
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/shares", http.StatusCreated, jsonBody(share))
|
||||
return err
|
||||
@@ -1313,6 +1570,9 @@ func (lc *Client) DriveShareSet(ctx context.Context, share *drive.Share) error {
|
||||
|
||||
// DriveShareRemove removes the share with the given name from the list of
|
||||
// shares that Taildrive will serve to remote nodes.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) DriveShareRemove(ctx context.Context, name string) error {
|
||||
_, err := lc.send(
|
||||
ctx,
|
||||
@@ -1324,6 +1584,9 @@ func (lc *Client) DriveShareRemove(ctx context.Context, name string) error {
|
||||
}
|
||||
|
||||
// DriveShareRename renames the share from old to new name.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) DriveShareRename(ctx context.Context, oldName, newName string) error {
|
||||
_, err := lc.send(
|
||||
ctx,
|
||||
@@ -1336,6 +1599,9 @@ func (lc *Client) DriveShareRename(ctx context.Context, oldName, newName string)
|
||||
|
||||
// DriveShareList returns the list of shares that drive is currently serving
|
||||
// to remote nodes.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) DriveShareList(ctx context.Context) ([]*drive.Share, error) {
|
||||
result, err := lc.get200(ctx, "/localapi/v0/drive/shares")
|
||||
if err != nil {
|
||||
@@ -1392,8 +1658,25 @@ func (lc *Client) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggesti
|
||||
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
|
||||
}
|
||||
|
||||
// SuggestExitNodeWithProbe requests an exit node suggestion based on an immediate routecheck probe,
|
||||
// waits for the probe to finish, and returns the exit node's details.
|
||||
func (lc *Client) SuggestExitNodeWithProbe(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
|
||||
if !buildfeatures.HasRouteCheck {
|
||||
return apitype.ExitNodeSuggestionResponse{}, feature.ErrUnavailable
|
||||
}
|
||||
v := url.Values{"probe": {"true"}}
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/suggest-exit-node?"+v.Encode(), 200, nil)
|
||||
if err != nil {
|
||||
return apitype.ExitNodeSuggestionResponse{}, err
|
||||
}
|
||||
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
|
||||
}
|
||||
|
||||
// CheckSOMarkInUse reports whether the socket mark option is in use. This will only
|
||||
// be true if tailscale is running on Linux and tailscaled uses SO_MARK.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/check-so-mark-in-use")
|
||||
if err != nil {
|
||||
@@ -1410,11 +1693,19 @@ func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {
|
||||
}
|
||||
|
||||
// ShutdownTailscaled requests a graceful shutdown of tailscaled.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) ShutdownTailscaled(ctx context.Context) error {
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/shutdown", 200, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAppConnectorRouteInfo returns the current [appctype.RouteInfo] for this
|
||||
// node's app connector.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) GetAppConnectorRouteInfo(ctx context.Context) (appctype.RouteInfo, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/appc-route-info")
|
||||
if err != nil {
|
||||
@@ -1422,3 +1713,13 @@ func (lc *Client) GetAppConnectorRouteInfo(ctx context.Context) (appctype.RouteI
|
||||
}
|
||||
return decodeJSON[appctype.RouteInfo](body)
|
||||
}
|
||||
|
||||
// GetServices returns the Services visible to this node,
|
||||
// including their names, IP addresses, and ports, keyed by service name.
|
||||
func (lc *Client) GetServices(ctx context.Context) (map[tailcfg.ServiceName]tailcfg.ServiceDetails, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/services")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[map[tailcfg.ServiceName]tailcfg.ServiceDetails](body)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,57 @@ func TestWhoIsPeerNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDialSelf(t *testing.T) {
|
||||
// Start a real TCP listener that the client should dial directly
|
||||
// when the server tells it to dial-self.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Write([]byte("hello"))
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
targetAddr := ln.Addr().(*net.TCPAddr)
|
||||
|
||||
// Mock LocalAPI server that returns Dial-Self response.
|
||||
nw := nettest.GetNetwork(t)
|
||||
ts := nettest.NewHTTPServer(nw, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Dial-Self", "true")
|
||||
w.Header().Set("Dial-Addr", targetAddr.String())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
lc := &Client{
|
||||
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return nw.Dial(ctx, network, ts.Listener.Addr().String())
|
||||
},
|
||||
}
|
||||
|
||||
conn, err := lc.UserDial(context.Background(), "tcp", targetAddr.IP.String(), uint16(targetAddr.Port))
|
||||
if err != nil {
|
||||
t.Fatalf("UserDial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
buf := make([]byte, 5)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if got := string(buf[:n]); got != "hello" {
|
||||
t.Errorf("got %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
BadDeps: map[string]string{
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_routecheck
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/net/routecheck"
|
||||
)
|
||||
|
||||
// ErrReportPending is returned by [Client.RouteCheck] and [Client.RouteCheckProbe]
|
||||
// when the report is pending.
|
||||
var ErrRouteCheckReportUnavailable = errors.New("report pending")
|
||||
|
||||
// RouteCheckProbe performs a routecheck probe and waits for its report.
|
||||
func (lc *Client) RouteCheckProbe(ctx context.Context) (*routecheck.Report, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/routecheck?probe=true", http.StatusOK, nil)
|
||||
if err != nil {
|
||||
if hs, ok := errors.AsType[httpStatusError](err); ok && hs.HTTPStatus == http.StatusNoContent {
|
||||
return nil, ErrRouteCheckReportUnavailable
|
||||
}
|
||||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
return decodeJSON[*routecheck.Report](body)
|
||||
}
|
||||
|
||||
// RouteCheck requests the report compiled by the latest routecheck probe.
|
||||
func (lc *Client) RouteCheck(ctx context.Context) (*routecheck.Report, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/routecheck", http.StatusOK, nil)
|
||||
if err != nil {
|
||||
if hs, ok := errors.AsType[httpStatusError](err); ok && hs.HTTPStatus == http.StatusNoContent {
|
||||
return nil, ErrRouteCheckReportUnavailable
|
||||
}
|
||||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
return decodeJSON[*routecheck.Report](body)
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import (
|
||||
// GetServeConfig return the current serve config.
|
||||
//
|
||||
// If the serve config is empty, it returns (nil, nil).
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) {
|
||||
body, h, err := lc.sendWithHeaders(ctx, "GET", "/localapi/v0/serve-config", 200, nil, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !ts_omit_serviceclientprefs
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/feature/serviceclientprefs/serviceclient"
|
||||
)
|
||||
|
||||
// GetServiceClientPrefs returns all of the current profile's [serviceclient.Prefs].
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is subject to change between releases.
|
||||
func (lc *Client) GetServiceClientPrefs(ctx context.Context) (serviceclient.Prefs, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/prefs/service-clients")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[serviceclient.Prefs](body)
|
||||
}
|
||||
|
||||
// SetServiceClientPref merges the non-empty fields from an [apitype.ServiceClientPrefRequest] into the
|
||||
// saved service client prefs for the current profile and returns the full updated set.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is subject to change between releases.
|
||||
func (lc *Client) SetServiceClientPref(ctx context.Context, req apitype.ServiceClientPrefRequest) (serviceclient.Prefs, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/prefs/service-clients", http.StatusOK, jsonBody(req))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[serviceclient.Prefs](body)
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
)
|
||||
|
||||
// GetEffectivePolicy returns the effective policy for the specified scope.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
func (lc *Client) GetEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
|
||||
scopeID, err := scope.MarshalText()
|
||||
if err != nil {
|
||||
|
||||
+94
-29
@@ -18,17 +18,22 @@ import (
|
||||
"tailscale.com/types/tkatype"
|
||||
)
|
||||
|
||||
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
|
||||
func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
|
||||
// TailnetLockStatus fetches information about the tailnet key authority, if one is configured.
|
||||
func (lc *Client) TailnetLockStatus(ctx context.Context) (*ipnstate.TailnetLockStatus, error) {
|
||||
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: %w", err)
|
||||
}
|
||||
return decodeJSON[*ipnstate.NetworkLockStatus](body)
|
||||
return decodeJSON[*ipnstate.TailnetLockStatus](body)
|
||||
}
|
||||
|
||||
// NetworkLockInit initializes the tailnet key authority.
|
||||
func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.NetworkLockStatus, error) {
|
||||
// Deprecated: use [Client.TailnetLockStatus] instead.
|
||||
func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.TailnetLockStatus, error) {
|
||||
return lc.TailnetLockStatus(ctx)
|
||||
}
|
||||
|
||||
// TailnetLockInit initializes the tailnet key authority.
|
||||
func (lc *Client) TailnetLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.TailnetLockStatus, error) {
|
||||
var b bytes.Buffer
|
||||
type initRequest struct {
|
||||
Keys []tka.Key
|
||||
@@ -44,12 +49,17 @@ func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disableme
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: %w", err)
|
||||
}
|
||||
return decodeJSON[*ipnstate.NetworkLockStatus](body)
|
||||
return decodeJSON[*ipnstate.TailnetLockStatus](body)
|
||||
}
|
||||
|
||||
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
|
||||
// Deprecated: use [Client.TailnetLockInit] instead.
|
||||
func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.TailnetLockStatus, error) {
|
||||
return lc.TailnetLockInit(ctx, keys, disablementValues, supportDisablement)
|
||||
}
|
||||
|
||||
// TailnetLockWrapPreauthKey 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 *Client) TailnetLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
|
||||
encodedPrivate, err := tkaKey.MarshalText()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -71,8 +81,13 @@ func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey stri
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// NetworkLockModify adds and/or removes key(s) to the tailnet key authority.
|
||||
func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
|
||||
// Deprecated: use [Client.TailnetLockWrapPreauthKey] instead.
|
||||
func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
|
||||
return lc.TailnetLockWrapPreauthKey(ctx, preauthKey, tkaKey)
|
||||
}
|
||||
|
||||
// TailnetLockModify adds and/or removes key(s) to the tailnet key authority.
|
||||
func (lc *Client) TailnetLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
|
||||
var b bytes.Buffer
|
||||
type modifyRequest struct {
|
||||
AddKeys []tka.Key
|
||||
@@ -89,9 +104,14 @@ func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []t
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetworkLockSign signs the specified node-key and transmits that signature to the control plane.
|
||||
// Deprecated: use [Client.TailnetLockModify] instead.
|
||||
func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
|
||||
return lc.TailnetLockModify(ctx, addKeys, removeKeys)
|
||||
}
|
||||
|
||||
// TailnetLockSign 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 *Client) TailnetLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
|
||||
var b bytes.Buffer
|
||||
type signRequest struct {
|
||||
NodeKey key.NodePublic
|
||||
@@ -108,8 +128,13 @@ func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, r
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
|
||||
func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
|
||||
// Deprecated: use [Client.TailnetLockSign] instead.
|
||||
func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
|
||||
return lc.TailnetLockSign(ctx, nodeKey, rotationPublic)
|
||||
}
|
||||
|
||||
// TailnetLockAffectedSigs returns all signatures signed by the specified keyID.
|
||||
func (lc *Client) TailnetLockAffectedSigs(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)
|
||||
@@ -117,19 +142,29 @@ func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.Key
|
||||
return decodeJSON[[]tkatype.MarshaledSignature](body)
|
||||
}
|
||||
|
||||
// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
|
||||
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
|
||||
// Deprecated: use [Client.TailnetLockAffectedSigs] instead.
|
||||
func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
|
||||
return lc.TailnetLockAffectedSigs(ctx, keyID)
|
||||
}
|
||||
|
||||
// TailnetLockLog returns up to maxEntries number of changes to tailnet-lock state.
|
||||
func (lc *Client) TailnetLockLog(ctx context.Context, maxEntries int) ([]ipnstate.TailnetLockUpdate, error) {
|
||||
v := url.Values{}
|
||||
v.Set("limit", fmt.Sprint(maxEntries))
|
||||
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/log?"+v.Encode(), 200, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
|
||||
return decodeJSON[[]ipnstate.TailnetLockUpdate](body)
|
||||
}
|
||||
|
||||
// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
|
||||
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
|
||||
// Deprecated: use [Client.TailnetLockLog] instead.
|
||||
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.TailnetLockUpdate, error) {
|
||||
return lc.TailnetLockLog(ctx, maxEntries)
|
||||
}
|
||||
|
||||
// TailnetLockForceLocalDisable forcibly shuts down tailnet lock on this node.
|
||||
func (lc *Client) TailnetLockForceLocalDisable(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 {
|
||||
@@ -142,9 +177,14 @@ func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
|
||||
// Deprecated: use [Client.TailnetLockForceLocalDisable] instead.
|
||||
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
|
||||
return lc.TailnetLockForceLocalDisable(ctx)
|
||||
}
|
||||
|
||||
// TailnetLockVerifySigningDeeplink verifies the tailnet 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 *Client) TailnetLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
|
||||
vr := struct {
|
||||
URL string
|
||||
}{url}
|
||||
@@ -157,8 +197,13 @@ func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url stri
|
||||
return decodeJSON[*tka.DeeplinkValidationResult](body)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Deprecated: use [Client.TailnetLockVerifySigningDeeplink] instead.
|
||||
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
|
||||
return lc.TailnetLockVerifySigningDeeplink(ctx, url)
|
||||
}
|
||||
|
||||
// TailnetLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
|
||||
func (lc *Client) TailnetLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
|
||||
vr := struct {
|
||||
Keys []tkatype.KeyID
|
||||
ForkFrom string
|
||||
@@ -172,8 +217,13 @@ func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tk
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Deprecated: use [Client.TailnetLockGenRecoveryAUM] instead.
|
||||
func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
|
||||
return lc.TailnetLockGenRecoveryAUM(ctx, removeKeys, forkFrom)
|
||||
}
|
||||
|
||||
// TailnetLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
|
||||
func (lc *Client) TailnetLockCosignRecoveryAUM(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 {
|
||||
@@ -183,8 +233,13 @@ func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
|
||||
func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
|
||||
// Deprecated: use [Client.TailnetLockCosignRecoveryAUM] instead.
|
||||
func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
|
||||
return lc.TailnetLockCosignRecoveryAUM(ctx, aum)
|
||||
}
|
||||
|
||||
// TailnetLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
|
||||
func (lc *Client) TailnetLockSubmitRecoveryAUM(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 {
|
||||
@@ -193,10 +248,20 @@ func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetworkLockDisable shuts down network-lock across the tailnet.
|
||||
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
|
||||
// Deprecated: use [Client.TailnetLockSubmitRecoveryAUM] instead.
|
||||
func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
|
||||
return lc.TailnetLockSubmitRecoveryAUM(ctx, aum)
|
||||
}
|
||||
|
||||
// TailnetLockDisable shuts down tailnet-lock across the tailnet.
|
||||
func (lc *Client) TailnetLockDisable(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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: use [Client.TailnetLockDisable] instead.
|
||||
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
|
||||
return lc.TailnetLockDisable(ctx, secret)
|
||||
}
|
||||
|
||||
+42
-4
@@ -11,6 +11,7 @@ import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -204,12 +205,49 @@ var (
|
||||
)
|
||||
|
||||
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}
|
||||
black = color.NRGBA{0, 0, 0, 255}
|
||||
white = color.NRGBA{255, 255, 255, 255}
|
||||
darkGray = color.NRGBA{102, 102, 102, 255}
|
||||
lightGray = color.NRGBA{153, 153, 153, 255}
|
||||
red = color.NRGBA{229, 111, 74, 255}
|
||||
transparent = color.NRGBA{}
|
||||
|
||||
// default values to dark theme
|
||||
bg = black
|
||||
fg = white
|
||||
gray = darkGray
|
||||
)
|
||||
|
||||
// SetTheme sets the color theme of the systray icon.
|
||||
//
|
||||
// Supported themes are:
|
||||
// - dark - white and gray dots over black background
|
||||
// - dark:nobg - white and grey dots over transparent background
|
||||
// - light - black and gray dots over white background
|
||||
// - light:nobg - black and grey dots over transparent background
|
||||
func SetTheme(theme string) {
|
||||
switch theme {
|
||||
case "dark":
|
||||
bg = black
|
||||
fg = white
|
||||
gray = darkGray
|
||||
case "dark:nobg":
|
||||
bg = transparent
|
||||
fg = white
|
||||
gray = darkGray
|
||||
case "light":
|
||||
bg = white
|
||||
fg = black
|
||||
gray = lightGray
|
||||
case "light:nobg":
|
||||
bg = transparent
|
||||
fg = black
|
||||
gray = lightGray
|
||||
default:
|
||||
log.Printf("unknown theme: %q", theme)
|
||||
}
|
||||
}
|
||||
|
||||
// render returns a PNG image of the logo.
|
||||
func (logo tsLogo) render() *bytes.Buffer {
|
||||
const borderUnits = 1
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
//go:build cgo || !darwin
|
||||
|
||||
// Package systray provides a minimal Tailscale systray application.
|
||||
package systray
|
||||
|
||||
import (
|
||||
|
||||
+68
-34
@@ -69,6 +69,11 @@ func (menu *Menu) Run(client *local.Client) {
|
||||
go menu.lc.SetGauge(menu.bgCtx, "systray_running", 1)
|
||||
defer menu.lc.SetGauge(menu.bgCtx, "systray_running", 0)
|
||||
|
||||
// set initial title, which is used by the systray package as the ID of the StatusNotifierItem.
|
||||
// This value will get overwritten later as the client status changes.
|
||||
// This must be called before systray.Run.
|
||||
systray.SetTitle("tailscale")
|
||||
|
||||
systray.Run(menu.onReady, menu.onExit)
|
||||
}
|
||||
|
||||
@@ -172,10 +177,6 @@ See https://tailscale.com/kb/1597/linux-systray for more information.`)
|
||||
}
|
||||
setAppIcon(disconnected)
|
||||
|
||||
// set initial title, which is used by the systray package as the ID of the StatusNotifierItem.
|
||||
// This value will get overwritten later as the client status changes.
|
||||
systray.SetTitle("tailscale")
|
||||
|
||||
menu.rebuild()
|
||||
|
||||
menu.mu.Lock()
|
||||
@@ -292,21 +293,23 @@ func (menu *Menu) rebuild() {
|
||||
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 len(menu.allProfiles) > 1 {
|
||||
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:
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,16 +355,27 @@ func (menu *Menu) rebuild() {
|
||||
|
||||
// profileTitle returns the title string for a profile menu item.
|
||||
func profileTitle(profile ipn.LoginProfile) string {
|
||||
title := profile.Name
|
||||
tailnet := ""
|
||||
if profile.NetworkProfile.DomainName != "" {
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||
// windows and mac don't support multi-line menu
|
||||
title += " (" + profile.NetworkProfile.DisplayNameOrDefault() + ")"
|
||||
} else {
|
||||
title += "\n" + profile.NetworkProfile.DisplayNameOrDefault()
|
||||
}
|
||||
tailnet = profile.NetworkProfile.DisplayNameOrDefault()
|
||||
}
|
||||
return title
|
||||
// windows and mac don't support multi-line menu items.
|
||||
multiline := runtime.GOOS != "windows" && runtime.GOOS != "darwin"
|
||||
|
||||
return formatProfileTitle(profile.Name, tailnet, multiline)
|
||||
}
|
||||
|
||||
// formatProfileTitle builds a profile menu label from a login name and an
|
||||
// optional tailnet name. The tailnet portion is omitted when it matches the
|
||||
// login name, so single-user tailnets don't show the same string twice.
|
||||
func formatProfileTitle(name, tailnet string, multiline bool) string {
|
||||
if tailnet == "" || strings.EqualFold(name, tailnet) {
|
||||
return name
|
||||
}
|
||||
if multiline {
|
||||
return name + "\n" + tailnet
|
||||
}
|
||||
return name + " (" + tailnet + ")"
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -621,11 +635,9 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
||||
title += strings.Split(sugg.Name, ".")[0]
|
||||
}
|
||||
menu.exitNodes.AddSeparator()
|
||||
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
|
||||
active := recommendedIsActive(status, sugg.ID, sugg.Location.CountryCode(), sugg.Location.City())
|
||||
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", active)
|
||||
setExitNodeOnClick(rm, sugg.ID)
|
||||
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
|
||||
rm.Check()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,13 +659,11 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
||||
if !ps.Online {
|
||||
name += " (offline)"
|
||||
}
|
||||
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", false)
|
||||
active := status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID
|
||||
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", active)
|
||||
if !ps.Online {
|
||||
sm.Disable()
|
||||
}
|
||||
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
|
||||
sm.Check()
|
||||
}
|
||||
setExitNodeOnClick(sm, ps.ID)
|
||||
}
|
||||
}
|
||||
@@ -743,6 +753,30 @@ func (mc *mvCountry) sortedCities() []*mvCity {
|
||||
return cities
|
||||
}
|
||||
|
||||
// recommendedIsActive reports whether the suggested exit node corresponds to
|
||||
// the currently active exit node in status.
|
||||
func recommendedIsActive(status *ipnstate.Status, suggID tailcfg.StableNodeID, suggCountry, suggCity string) bool {
|
||||
if status == nil || status.ExitNodeStatus == nil || status.ExitNodeStatus.ID.IsZero() {
|
||||
return false
|
||||
}
|
||||
if suggID == status.ExitNodeStatus.ID {
|
||||
return true
|
||||
}
|
||||
if suggCountry == "" || suggCity == "" {
|
||||
return false
|
||||
}
|
||||
for _, p := range status.Peer {
|
||||
if p.ID != status.ExitNodeStatus.ID {
|
||||
continue
|
||||
}
|
||||
if loc := p.Location; loc != nil && loc.CountryCode == suggCountry && loc.City == suggCity {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build cgo || !darwin
|
||||
|
||||
package systray
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func TestProfileTitleMultiline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
login string
|
||||
tailnet string
|
||||
multiline bool
|
||||
want string
|
||||
}{
|
||||
{"no_tailnet", "alice@example.com", "", true, "alice@example.com"},
|
||||
{"dup_exact", "example.com", "example.com", true, "example.com"},
|
||||
{"dup_casefold", "Example.com", "example.com", false, "Example.com"},
|
||||
{"distinct_multiline", "alice@example.com", "example.com", true, "alice@example.com\nexample.com"},
|
||||
{"distinct_singleline", "alice@example.com", "example.com", false, "alice@example.com (example.com)"},
|
||||
{"empty", "", "", true, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := formatProfileTitle(tt.login, tt.tailnet, tt.multiline); got != tt.want {
|
||||
t.Errorf("profileTitleMultiline; got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendedIsActive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
activeID = tailcfg.StableNodeID("active")
|
||||
suggID = tailcfg.StableNodeID("suggestion")
|
||||
)
|
||||
usNYC := &tailcfg.Location{CountryCode: "US", City: "New York"}
|
||||
usCHI := &tailcfg.Location{CountryCode: "US", City: "Chicago"}
|
||||
seSTO := &tailcfg.Location{CountryCode: "SE", City: "Stockholm"}
|
||||
|
||||
statusWith := func(activePeer *ipnstate.PeerStatus) *ipnstate.Status {
|
||||
s := &ipnstate.Status{
|
||||
ExitNodeStatus: &ipnstate.ExitNodeStatus{ID: activeID},
|
||||
}
|
||||
if activePeer != nil {
|
||||
s.Peer = map[key.NodePublic]*ipnstate.PeerStatus{{}: activePeer}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status *ipnstate.Status
|
||||
suggID tailcfg.StableNodeID
|
||||
suggCountry string
|
||||
suggCity string
|
||||
isActive bool
|
||||
}{
|
||||
{
|
||||
name: "nil_status",
|
||||
status: nil,
|
||||
suggID: suggID,
|
||||
},
|
||||
{
|
||||
name: "no_exit_node",
|
||||
status: &ipnstate.Status{},
|
||||
suggID: suggID,
|
||||
},
|
||||
{
|
||||
name: "exit_node_id_is_zero",
|
||||
status: &ipnstate.Status{ExitNodeStatus: &ipnstate.ExitNodeStatus{}},
|
||||
suggID: suggID,
|
||||
},
|
||||
{
|
||||
name: "exact_id_match_short-circuits",
|
||||
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usCHI}),
|
||||
suggID: activeID,
|
||||
suggCountry: "US",
|
||||
suggCity: "New York",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
name: "id_mismatch_but_same_city",
|
||||
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usNYC}),
|
||||
suggID: suggID,
|
||||
suggCountry: "US",
|
||||
suggCity: "New York",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
name: "different_city",
|
||||
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usCHI}),
|
||||
suggID: suggID,
|
||||
suggCountry: "US",
|
||||
suggCity: "New York",
|
||||
},
|
||||
{
|
||||
name: "different_country",
|
||||
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: seSTO}),
|
||||
suggID: suggID,
|
||||
suggCountry: "US",
|
||||
suggCity: "New York",
|
||||
},
|
||||
{
|
||||
name: "id_mismatch_suggestion_has_no_location",
|
||||
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usNYC}),
|
||||
suggID: suggID,
|
||||
},
|
||||
{
|
||||
name: "id_mismatch_active_peer_has_no_location",
|
||||
status: statusWith(&ipnstate.PeerStatus{ID: activeID}),
|
||||
suggID: suggID,
|
||||
suggCountry: "US",
|
||||
suggCity: "New York",
|
||||
},
|
||||
{
|
||||
name: "active_peer_not_in_status",
|
||||
status: statusWith(nil),
|
||||
suggID: suggID,
|
||||
suggCountry: "US",
|
||||
suggCity: "New York",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
isExitNodeActive := recommendedIsActive(tt.status, tt.suggID, tt.suggCountry, tt.suggCity)
|
||||
if isExitNodeActive != tt.isActive {
|
||||
t.Errorf("recommendedIsActive; got %v, want %v", isExitNodeActive, tt.isActive)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ type ReloadConfigResponse struct {
|
||||
type ExitNodeSuggestionResponse struct {
|
||||
ID tailcfg.StableNodeID
|
||||
Name string
|
||||
Location tailcfg.LocationView `json:",omitempty"`
|
||||
Location tailcfg.LocationView `json:",omitzero"`
|
||||
}
|
||||
|
||||
// DNSOSConfig mimics dns.OSConfig without forcing us to import the entire dns package
|
||||
@@ -104,3 +104,20 @@ type OptionalFeatures struct {
|
||||
// are not guaranteed to be present.)
|
||||
Features map[string]bool
|
||||
}
|
||||
|
||||
// ServiceClientPrefRequest is the body POSTed to the LocalAPI endpoint /localapi/v0/prefs/service-clients.
|
||||
// Empty values for Client, Username, and DatabaseName mean "don't change this value".
|
||||
type ServiceClientPrefRequest struct {
|
||||
// Key is the identifier for the service client pref. Required. Format is "<serviceName>:<port>"
|
||||
// where serviceName is a [tailcfg.ServiceName], e.g. "svc:my-db:5432".
|
||||
Key string
|
||||
|
||||
// Client is the name of the client that the user picked in the service launch. Optional.
|
||||
Client string `json:",omitzero"`
|
||||
|
||||
// Username is the username that the user entered in the service launch. Optional.
|
||||
Username string `json:",omitzero"`
|
||||
|
||||
// DatabaseName is the database name that the user entered in the service launch. Optional.
|
||||
DatabaseName string `json:",omitzero"`
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ type Key struct {
|
||||
|
||||
// KeyCapabilities are the capabilities of a Key.
|
||||
type KeyCapabilities struct {
|
||||
Devices KeyDeviceCapabilities `json:"devices,omitempty"`
|
||||
Devices KeyDeviceCapabilities `json:"devices"`
|
||||
}
|
||||
|
||||
// KeyDeviceCapabilities are the device-related capabilities of a Key.
|
||||
|
||||
+2
-1
@@ -199,7 +199,8 @@ func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(controlURL.Host, ".tailscale.com")
|
||||
return strings.HasSuffix(controlURL.Host, ".tailscale.com") ||
|
||||
controlURL.Host == "control.tailscale" // for natlab tests
|
||||
}
|
||||
|
||||
// awaitUserAuth blocks until the given session auth has been completed
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function ExitNodeSelector({
|
||||
none, // not using exit nodes
|
||||
advertising, // advertising as exit node
|
||||
using, // using another exit node
|
||||
offline, // selected exit node node is offline
|
||||
offline, // selected exit node is offline
|
||||
] = useMemo(
|
||||
() => [
|
||||
selected.ID === noExitNode.ID,
|
||||
|
||||
+79
-120
@@ -35,8 +35,10 @@ import (
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/ctxkey"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/syspolicy/policyclient"
|
||||
"tailscale.com/version"
|
||||
@@ -527,45 +529,40 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
type apiHandler[data any] struct {
|
||||
s *Server
|
||||
w http.ResponseWriter
|
||||
r *http.Request
|
||||
|
||||
// permissionCheck allows for defining whether a requesting peer's
|
||||
// capabilities grant them access to make the given data update.
|
||||
// If permissionCheck reports false, the request fails as unauthorized.
|
||||
permissionCheck func(data data, peer peerCapabilities) bool
|
||||
}
|
||||
|
||||
// newHandler constructs a new api handler which restricts the given request
|
||||
// to the specified permission check. If the permission check fails for
|
||||
// the peer associated with the request, an unauthorized error is returned
|
||||
// to the client.
|
||||
func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
|
||||
return &apiHandler[data]{
|
||||
s: s,
|
||||
w: w,
|
||||
r: r,
|
||||
permissionCheck: permissionCheck,
|
||||
// handleJSON manages decoding the request's body JSON as data and passing it
|
||||
// on to the provided handler function.
|
||||
func handleJSON[data any](h func(ctx context.Context, data data) error) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
var body data
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h(r.Context(), body); err != nil {
|
||||
if httpErr, ok := errors.AsType[tsweb.HTTPError](err); ok {
|
||||
tsweb.WriteHTTPError(w, r, httpErr)
|
||||
} else {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// alwaysAllowed can be passed as the permissionCheck argument to newHandler
|
||||
// for requests that are always allowed to complete regardless of a peer's
|
||||
// capabilities.
|
||||
func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
|
||||
var contextKeyPeer = ctxkey.New("peer-capabilities", peerCapabilities{})
|
||||
|
||||
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
|
||||
func (s *Server) setPeer(r *http.Request) (*http.Request, error) {
|
||||
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
|
||||
// WhoIs when originally checking for a session from authorizeRequest.
|
||||
// Would be nice if we could pipe those through to here so we don't end
|
||||
// up having to re-call them to grab the peer capabilities.
|
||||
status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
|
||||
status, err := s.lc.StatusWithoutPeers(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
|
||||
whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -573,56 +570,11 @@ func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return peer, nil
|
||||
return r.WithContext(contextKeyPeer.WithValue(r.Context(), peer)), nil
|
||||
}
|
||||
|
||||
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
|
||||
|
||||
// handle runs the given handler if the source peer satisfies the
|
||||
// constraints for running this request.
|
||||
//
|
||||
// handle is expected for use when `data` type is empty, or set to
|
||||
// `noBodyData` in practice. For requests that expect JSON body data
|
||||
// to be attached, use handleJSON instead.
|
||||
func (a *apiHandler[data]) handle(h http.HandlerFunc) {
|
||||
peer, err := a.getPeer()
|
||||
if err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var body data // not used
|
||||
if !a.permissionCheck(body, peer) {
|
||||
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h(a.w, a.r)
|
||||
}
|
||||
|
||||
// handleJSON manages decoding the request's body JSON and passing
|
||||
// it on to the provided function if the source peer satisfies the
|
||||
// constraints for running this request.
|
||||
func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
|
||||
defer a.r.Body.Close()
|
||||
var body data
|
||||
if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
peer, err := a.getPeer()
|
||||
if err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !a.permissionCheck(body, peer) {
|
||||
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h(a.r.Context(), body); err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.w.WriteHeader(http.StatusOK)
|
||||
func (s *Server) getPeer(ctx context.Context) peerCapabilities {
|
||||
return contextKeyPeer.Value(ctx)
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
@@ -637,67 +589,44 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
r, err = s.setPeer(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case path == "/data" && r.Method == httpm.GET:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveGetNodeData)
|
||||
s.serveGetNodeData(w, r)
|
||||
return
|
||||
case path == "/exit-nodes" && r.Method == httpm.GET:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveGetExitNodes)
|
||||
s.serveGetExitNodes(w, r)
|
||||
return
|
||||
case path == "/routes" && r.Method == httpm.POST:
|
||||
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
|
||||
if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
|
||||
return false
|
||||
} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
newHandler[postRoutesRequest](s, w, r, peerAllowed).
|
||||
handleJSON(s.servePostRoutes)
|
||||
handleJSON[postRoutesRequest](s.servePostRoutes)(w, r)
|
||||
return
|
||||
case path == "/device-details-click" && r.Method == httpm.POST:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveDeviceDetailsClick)
|
||||
s.serveDeviceDetailsClick(w, r)
|
||||
return
|
||||
case path == "/local/v0/logout" && r.Method == httpm.POST:
|
||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
||||
return peer.canEdit(capFeatureAccount)
|
||||
}
|
||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
|
||||
peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
|
||||
if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
newHandler[maskedPrefs](s, w, r, peerAllowed).
|
||||
handleJSON(s.serveUpdatePrefs)
|
||||
handleJSON[maskedPrefs](s.serveUpdatePrefs)(w, r)
|
||||
return
|
||||
case path == "/local/v0/update/check" && r.Method == httpm.GET:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
case path == "/local/v0/update/check" && r.Method == httpm.POST:
|
||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
||||
return peer.canEdit(capFeatureAccount)
|
||||
}
|
||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
@@ -1122,6 +1051,11 @@ type maskedPrefs struct {
|
||||
}
|
||||
|
||||
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
|
||||
peer := s.getPeer(ctx)
|
||||
if prefs.RunSSHSet && !peer.canEdit(capFeatureSSH) {
|
||||
return tsweb.Error(http.StatusUnauthorized, "RunSSHSet not allowed", nil)
|
||||
}
|
||||
|
||||
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
RunSSHSet: prefs.RunSSHSet,
|
||||
Prefs: ipn.Prefs{
|
||||
@@ -1140,6 +1074,17 @@ type postRoutesRequest struct {
|
||||
}
|
||||
|
||||
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
|
||||
if !data.SetExitNode && !data.SetRoutes {
|
||||
return tsweb.Error(http.StatusBadRequest, "must specify SetExitNode or SetRoutes", nil)
|
||||
}
|
||||
peer := s.getPeer(ctx)
|
||||
if data.SetExitNode && !peer.canEdit(capFeatureExitNodes) {
|
||||
return tsweb.Error(http.StatusUnauthorized, "SetExitNode not allowed", nil)
|
||||
}
|
||||
if data.SetRoutes && !peer.canEdit(capFeatureSubnets) {
|
||||
return tsweb.Error(http.StatusUnauthorized, "SetRoutes not allowed", nil)
|
||||
}
|
||||
|
||||
prefs, err := s.lc.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1153,13 +1098,14 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
|
||||
}
|
||||
currNonExitRoutes = append(currNonExitRoutes, r.String())
|
||||
}
|
||||
// Set non-edited fields to their current values.
|
||||
if data.SetExitNode {
|
||||
data.AdvertiseRoutes = currNonExitRoutes
|
||||
} else if data.SetRoutes {
|
||||
// For each group of fields not being set, preserve the current prefs.
|
||||
if !data.SetExitNode {
|
||||
data.AdvertiseExitNode = currAdvertisingExitNode
|
||||
data.UseExitNode = prefs.ExitNodeID
|
||||
}
|
||||
if !data.SetRoutes {
|
||||
data.AdvertiseRoutes = currNonExitRoutes
|
||||
}
|
||||
|
||||
// Calculate routes.
|
||||
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
||||
@@ -1336,6 +1282,19 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
switch path {
|
||||
case "/v0/logout":
|
||||
if !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
|
||||
http.Error(w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
case "/v0/update/check":
|
||||
if r.Method == httpm.POST && !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
|
||||
http.Error(w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
|
||||
if err != nil {
|
||||
|
||||
+148
-2
@@ -191,7 +191,7 @@ func TestServeAPI(t *testing.T) {
|
||||
reqBody: "{\"setExitNode\":true}",
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "not allowed",
|
||||
wantResponse: "SetExitNode not allowed",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
@@ -204,7 +204,7 @@ func TestServeAPI(t *testing.T) {
|
||||
reqContentType: "application/json",
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "not allowed",
|
||||
wantResponse: "RunSSHSet not allowed",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
@@ -1604,3 +1604,149 @@ func TestCSRFProtect(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServePostRoutes(t *testing.T) {
|
||||
existingExitNodeID := tailcfg.StableNodeID("existing-exit-node")
|
||||
existingRoute := netip.MustParsePrefix("192.168.1.0/24")
|
||||
|
||||
existingPrefs := &ipn.Prefs{
|
||||
ExitNodeID: existingExitNodeID,
|
||||
AdvertiseRoutes: []netip.Prefix{existingRoute},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data postRoutesRequest
|
||||
peerCaps peerCapabilities
|
||||
wantErr bool
|
||||
wantEditPrefs bool // whether EditPrefs (PATCH /prefs) should be called
|
||||
wantExitNodeID tailcfg.StableNodeID
|
||||
wantRoutes []netip.Prefix
|
||||
}{
|
||||
{
|
||||
name: "empty-request",
|
||||
data: postRoutesRequest{},
|
||||
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||
wantErr: true,
|
||||
wantEditPrefs: false,
|
||||
},
|
||||
{
|
||||
name: "SetExitNode-only",
|
||||
data: postRoutesRequest{
|
||||
SetExitNode: true,
|
||||
UseExitNode: "new-exit-node",
|
||||
},
|
||||
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||
wantEditPrefs: true,
|
||||
wantExitNodeID: "new-exit-node",
|
||||
wantRoutes: []netip.Prefix{existingRoute},
|
||||
},
|
||||
{
|
||||
name: "SetExitNode-not-allowed",
|
||||
data: postRoutesRequest{
|
||||
SetExitNode: true,
|
||||
UseExitNode: "new-exit-node",
|
||||
},
|
||||
peerCaps: peerCapabilities{capFeatureSubnets: true},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "SetRoutes-only",
|
||||
data: postRoutesRequest{
|
||||
SetRoutes: true,
|
||||
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||
},
|
||||
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||
wantEditPrefs: true,
|
||||
wantExitNodeID: existingExitNodeID,
|
||||
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
},
|
||||
{
|
||||
name: "SetRoutes-not-allowed",
|
||||
data: postRoutesRequest{
|
||||
SetRoutes: true,
|
||||
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||
},
|
||||
peerCaps: peerCapabilities{capFeatureExitNodes: true},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "SetExitNode-and-SetRoutes",
|
||||
data: postRoutesRequest{
|
||||
SetExitNode: true,
|
||||
SetRoutes: true,
|
||||
UseExitNode: "new-exit-node",
|
||||
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||
},
|
||||
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||
wantEditPrefs: true,
|
||||
wantExitNodeID: "new-exit-node",
|
||||
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotPrefs *ipn.MaskedPrefs
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
|
||||
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/localapi/v0/prefs" {
|
||||
t.Errorf("unexpected localapi call to %q", r.URL.Path)
|
||||
http.Error(w, "unexpected localapi call", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
writeJSON(w, existingPrefs)
|
||||
case httpm.PATCH:
|
||||
var mp ipn.MaskedPrefs
|
||||
if err := json.NewDecoder(r.Body).Decode(&mp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gotPrefs = &mp
|
||||
writeJSON(w, gotPrefs.Prefs)
|
||||
default:
|
||||
t.Errorf("unexpected method %q on /prefs", r.Method)
|
||||
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})}
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &local.Client{Dial: lal.Dial},
|
||||
}
|
||||
|
||||
ctx := contextKeyPeer.WithValue(t.Context(), tt.peerCaps)
|
||||
err := s.servePostRoutes(ctx, tt.data)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("wanted error, got nil")
|
||||
}
|
||||
if gotPrefs != nil {
|
||||
t.Error("EditPrefs should not have been called on error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if gotPrefs == nil {
|
||||
t.Fatal("expected EditPrefs to be called")
|
||||
}
|
||||
if diff := cmp.Diff(tt.wantExitNodeID, gotPrefs.ExitNodeID); diff != "" {
|
||||
t.Errorf("ExitNodeID mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff(tt.wantRoutes, gotPrefs.AdvertiseRoutes, cmp.Comparer(func(a, b netip.Prefix) bool { return a.Compare(b) == 0 })); diff != "" {
|
||||
t.Errorf("AdvertiseRoutes mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -37,6 +38,25 @@ import (
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
// GokrazyUpdateArgs contains arguments for updating a Gokrazy appliance from a
|
||||
// GAF fetched from a URL.
|
||||
type GokrazyUpdateArgs struct {
|
||||
// URL is the GAF download URL.
|
||||
URL string
|
||||
|
||||
// AllowUnsigned permits installing a GAF without signature verification.
|
||||
// It is intended for tests that serve a GAF from a fileserver that does
|
||||
// not publish distsign.pub.
|
||||
AllowUnsigned bool
|
||||
|
||||
// Logf is optional; nil discards log messages.
|
||||
Logf logger.Logf
|
||||
}
|
||||
|
||||
// GokrazyUpdateFromURL updates a Gokrazy appliance from a GAF fetched from a
|
||||
// URL, if Gokrazy update support is linked into the binary.
|
||||
var GokrazyUpdateFromURL feature.Hook[func(context.Context, GokrazyUpdateArgs) error]
|
||||
|
||||
const (
|
||||
StableTrack = "stable"
|
||||
UnstableTrack = "unstable"
|
||||
@@ -197,6 +217,17 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
|
||||
// release cadence with Synology Package Center and use their
|
||||
// auto-update mechanism.
|
||||
return up.updateSynology, false
|
||||
case distro.Gokrazy:
|
||||
// Only the official Tailscale appliance image (built with the
|
||||
// ts_appliance build tag, which causes hostinfo to report
|
||||
// Package="tsapp") is auto-updatable. A user running a custom
|
||||
// Gokrazy build that happens to include tailscaled must not be
|
||||
// updated with our stock GAFs. TS_FORCE_ALLOW_TSAPP_UPDATE is an
|
||||
// escape hatch for callers who know what they're doing.
|
||||
if hi.Package != "tsapp" && !envknob.Bool("TS_FORCE_ALLOW_TSAPP_UPDATE") {
|
||||
return nil, false
|
||||
}
|
||||
return up.updateGokrazy, true
|
||||
case distro.Debian: // includes Ubuntu
|
||||
return up.updateDebLike, true
|
||||
case distro.Arch:
|
||||
@@ -330,7 +361,7 @@ func (up *Updater) updateSynology() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest, err := latestPackages(up.Track)
|
||||
latest, err := LatestPackages(up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -864,6 +895,56 @@ func (up *Updater) updateFreeBSD() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateGokrazy fetches the latest signed GAF for this gokrazy device variant
|
||||
// (vm-amd64, vm-arm64, or pi-arm64) from up.PkgsAddr and applies it via the
|
||||
// local gokrazy init update API.
|
||||
func (up *Updater) updateGokrazy() error {
|
||||
if !GokrazyUpdateFromURL.IsSet() {
|
||||
return errors.New("gokrazy update support is not linked into this binary")
|
||||
}
|
||||
variant, err := gokrazyDeviceVariant()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest, err := LatestPackages(up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gafName, ok := latest.GAFs[variant]
|
||||
if !ok {
|
||||
return fmt.Errorf("no GAF for device %q on %q track", variant, up.Track)
|
||||
}
|
||||
if latest.GAFsVersion == "" {
|
||||
return fmt.Errorf("no GAF version on %q track", up.Track)
|
||||
}
|
||||
if !up.confirm(latest.GAFsVersion) {
|
||||
return nil
|
||||
}
|
||||
gafURL := fmt.Sprintf("%s/%s/%s", strings.TrimRight(up.PkgsAddr, "/"), up.Track, gafName)
|
||||
up.Logf("Updating to %s (%s)", latest.GAFsVersion, gafURL)
|
||||
return GokrazyUpdateFromURL.Get()(context.Background(), GokrazyUpdateArgs{
|
||||
URL: gafURL,
|
||||
Logf: up.Logf,
|
||||
})
|
||||
}
|
||||
|
||||
// gokrazyDeviceVariant returns the GAFs JSON key for the current gokrazy
|
||||
// device, e.g. "vm-amd64", "vm-arm64", or "pi-arm64". On arm64, it reads the
|
||||
// device-tree model to tell a Raspberry Pi apart from a VM.
|
||||
func gokrazyDeviceVariant() (string, error) {
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return "vm-amd64", nil
|
||||
case "arm64":
|
||||
b, _ := os.ReadFile("/sys/firmware/devicetree/base/model")
|
||||
if strings.HasPrefix(strings.Trim(string(b), "\x00\r\n\t "), "Raspberry Pi") {
|
||||
return "pi-arm64", nil
|
||||
}
|
||||
return "vm-arm64", nil
|
||||
}
|
||||
return "", fmt.Errorf("unsupported gokrazy GOARCH %q", runtime.GOARCH)
|
||||
}
|
||||
|
||||
func (up *Updater) updateLinuxBinary() error {
|
||||
// Root is needed to overwrite binaries and restart systemd unit.
|
||||
if err := requireRoot(); err != nil {
|
||||
@@ -1224,7 +1305,7 @@ func LatestTailscaleVersion(track string) (string, error) {
|
||||
track = CurrentTrack
|
||||
}
|
||||
|
||||
latest, err := latestPackages(track)
|
||||
latest, err := LatestPackages(track)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1236,8 +1317,11 @@ func LatestTailscaleVersion(track string) (string, error) {
|
||||
ver = latest.MacZipsVersion
|
||||
case "linux":
|
||||
ver = latest.TarballsVersion
|
||||
if distro.Get() == distro.Synology {
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
ver = latest.SPKsVersion
|
||||
case distro.Gokrazy:
|
||||
ver = latest.GAFsVersion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1247,7 +1331,8 @@ func LatestTailscaleVersion(track string) (string, error) {
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
type trackPackages struct {
|
||||
// TrackPackages is the JSON shape served at <pkgs>/<track>/?mode=json.
|
||||
type TrackPackages struct {
|
||||
Version string
|
||||
Tarballs map[string]string
|
||||
TarballsVersion string
|
||||
@@ -1255,6 +1340,8 @@ type trackPackages struct {
|
||||
ExesVersion string
|
||||
MSIs map[string]string
|
||||
MSIsVersion string
|
||||
GAFs map[string]string
|
||||
GAFsVersion string
|
||||
MacZips map[string]string
|
||||
MacZipsVersion string
|
||||
SPKs map[string]map[string]string
|
||||
@@ -1263,14 +1350,16 @@ type trackPackages struct {
|
||||
|
||||
var tailscaleHTTPEndpoint = "https://pkgs.tailscale.com"
|
||||
|
||||
func latestPackages(track string) (*trackPackages, error) {
|
||||
// LatestPackages fetches the package manifest served at
|
||||
// <pkgs>/<track>/?mode=json for the current runtime.GOOS.
|
||||
func LatestPackages(track string) (*TrackPackages, error) {
|
||||
url := fmt.Sprintf("%s/%s/?mode=json&os=%s", tailscaleHTTPEndpoint, track, runtime.GOOS)
|
||||
res, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching latest tailscale version: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var latest trackPackages
|
||||
var latest TrackPackages
|
||||
if err := json.NewDecoder(res.Body).Decode(&latest); err != nil {
|
||||
return nil, fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/clientupdate/distsign"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/progresstracking"
|
||||
)
|
||||
|
||||
const (
|
||||
gokrazyUpdateSocket = "/run/gokrazy-http.sock"
|
||||
gokrazyUpdateBaseURL = "http://gokrazy-local-unixsock"
|
||||
)
|
||||
|
||||
// GokrazyUpdateFromURL downloads a Gokrazy archive format file from args.URL,
|
||||
// installs its partitions using the local gokrazy init update API, switches to
|
||||
// the new root partition, and asks gokrazy to reboot.
|
||||
//
|
||||
// The local gokrazy API is reached over gokrazyUpdateSocket. The
|
||||
// gokrazyUpdateBaseURL host is only a net/http URL sentinel; it is not resolved
|
||||
// with DNS.
|
||||
func init() {
|
||||
GokrazyUpdateFromURL.Set(gokrazyUpdateFromURL)
|
||||
}
|
||||
|
||||
func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error {
|
||||
logf := args.Logf
|
||||
if logf == nil {
|
||||
logf = logger.Discard
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "tailscale-gokrazy-*.gaf")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
tmp.Close()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
logf("downloading %s", args.URL)
|
||||
if args.AllowUnsigned {
|
||||
if err := downloadUnverified(ctx, logf, args.URL, tmpName); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := distsign.DownloadVerified(ctx, logf, args.URL, tmpName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
zr, err := zip.OpenReader(tmpName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
logf("download complete")
|
||||
|
||||
gokClient := gokrazyHTTPClient()
|
||||
for _, part := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"root.img", "/update/root"},
|
||||
{"boot.img", "/update/boot"},
|
||||
{"mbr.img", "/update/mbr"},
|
||||
} {
|
||||
logf("writing %s...", part.name)
|
||||
if err := putGokrazyGAFMember(ctx, gokClient, zr.File, part.name, part.path); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("wrote %s", part.name)
|
||||
}
|
||||
if err := postGokrazy(ctx, gokClient, "/update/switch"); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("switched boot target")
|
||||
if err := postGokrazy(ctx, gokClient, "/reboot?async=true&kexec_merge_cmdline=true"); err != nil {
|
||||
return err
|
||||
}
|
||||
logf("reboot requested")
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadUnverified saves the GAF at srcURL to dstPath without verifying
|
||||
// a signature. It is used only when args.AllowUnsigned is set, for tests
|
||||
// that serve the GAF from a fileserver that does not publish distsign.pub
|
||||
// and for the gafpush "sftp the GAF onto the appliance and update from a
|
||||
// local path" flow, which uses a "file://" URL.
|
||||
func downloadUnverified(ctx context.Context, logf logger.Logf, srcURL, dstPath string) error {
|
||||
if after, ok := strings.CutPrefix(srcURL, "file://"); ok {
|
||||
return copyLocalFile(after, dstPath, logf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", srcURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download GAF: %s", res.Status)
|
||||
}
|
||||
f, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total := res.ContentLength
|
||||
pw := progresstracking.NewWriter(io.Discard, total, time.Second, func(done int64) {
|
||||
if total > 0 {
|
||||
logf("downloading: %d / %d MB (%.0f%%)", done>>20, total>>20, float64(done)/float64(total)*100)
|
||||
}
|
||||
})
|
||||
if _, err := io.Copy(f, io.TeeReader(res.Body, pw)); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// copyLocalFile copies the GAF at src to dst. Used by the "file://" branch
|
||||
// of downloadUnverified. The source file is left in place; callers that
|
||||
// staged it (e.g. gafpush) clean up after the update completes.
|
||||
func copyLocalFile(src, dst string, logf logger.Logf) error {
|
||||
sf, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sf.Close()
|
||||
df, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := sf.Stat()
|
||||
if err != nil {
|
||||
df.Close()
|
||||
return err
|
||||
}
|
||||
total := fi.Size()
|
||||
logf("copying local GAF %s (%d MB)", src, total>>20)
|
||||
pw := progresstracking.NewWriter(io.Discard, total, time.Second, func(done int64) {
|
||||
if total > 0 {
|
||||
logf("copying: %d / %d MB (%.0f%%)", done>>20, total>>20, float64(done)/float64(total)*100)
|
||||
}
|
||||
})
|
||||
if _, err := io.Copy(df, io.TeeReader(sf, pw)); err != nil {
|
||||
df.Close()
|
||||
return err
|
||||
}
|
||||
return df.Close()
|
||||
}
|
||||
|
||||
func gokrazyHTTPClient() *http.Client {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", gokrazyUpdateSocket)
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: tr,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func putGokrazyGAFMember(ctx context.Context, hc *http.Client, files []*zip.File, name, path string) error {
|
||||
var zf *zip.File
|
||||
for _, f := range files {
|
||||
if f.Name == name {
|
||||
zf = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if zf == nil {
|
||||
return fmt.Errorf("GAF is missing %s", name)
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
h := crc32.NewIEEE()
|
||||
body := io.TeeReader(rc, h)
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", gokrazyUpdateBaseURL+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.ContentLength = int64(zf.UncompressedSize64)
|
||||
req.Header.Set("X-Gokrazy-Update-Hash", "crc32")
|
||||
res, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
resBody, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("PUT %s: %s: %s", path, res.Status, strings.TrimSpace(string(resBody)))
|
||||
}
|
||||
if got, want := strings.TrimSpace(string(resBody)), fmt.Sprintf("%08x", h.Sum32()); got != want {
|
||||
return fmt.Errorf("PUT %s: gokrazy checksum = %q; want %q", path, got, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postGokrazy(ctx context.Context, hc *http.Client, path string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", gokrazyUpdateBaseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
return fmt.Errorf("POST %s: %s: %s", path, res.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -373,7 +373,7 @@ func TestCheckOutdatedAlpineRepo(t *testing.T) {
|
||||
|
||||
testServ := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
version := trackPackages{
|
||||
version := TrackPackages{
|
||||
MSIsVersion: tt.latestHTTPVersion,
|
||||
MacZipsVersion: tt.latestHTTPVersion,
|
||||
TarballsVersion: tt.latestHTTPVersion,
|
||||
|
||||
@@ -38,12 +38,12 @@ const (
|
||||
updaterPrefix = "tailscale-updater"
|
||||
)
|
||||
|
||||
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
selfExe, err := os.Executable()
|
||||
func makeCmdTailscaleCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
srcExe, err := findCmdTailscale()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
f, err := os.Open(selfExe)
|
||||
f, err := os.Open(srcExe)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -59,7 +59,25 @@ func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
f2.Close()
|
||||
return "", "", err
|
||||
}
|
||||
return selfExe, f2.Name(), f2.Close()
|
||||
return srcExe, f2.Name(), f2.Close()
|
||||
}
|
||||
|
||||
// findCmdTailscale returns the path to the binary that should be copied for the update
|
||||
// re-execution. The copy is re-executed with "update" as a subcommand, so it must be
|
||||
// a binary that handles "update" (ie tailscale.exe, not tailscaled.exe)
|
||||
func findCmdTailscale() (string, error) {
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.EqualFold(filepath.Base(selfExe), "tailscale.exe") {
|
||||
return selfExe, nil
|
||||
}
|
||||
ts := filepath.Join(filepath.Dir(selfExe), "tailscale.exe")
|
||||
if _, err := os.Stat(ts); err != nil {
|
||||
return "", fmt.Errorf("cannot find tailscale.exe alongside %s: %w", selfExe, err)
|
||||
}
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
func markTempFileWindows(name string) error {
|
||||
@@ -159,14 +177,14 @@ you can run the command prompt as Administrator one of these ways:
|
||||
|
||||
up.Logf("making tailscale.exe copy to switch to...")
|
||||
up.cleanupOldDownloads(filepath.Join(os.TempDir(), updaterPrefix+"-*.exe"))
|
||||
_, selfCopy, err := makeSelfCopy()
|
||||
_, cmdTailscaleCopy, err := makeCmdTailscaleCopy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(selfCopy)
|
||||
defer os.Remove(cmdTailscaleCopy)
|
||||
up.Logf("running tailscale.exe copy for final install...")
|
||||
|
||||
cmd := exec.Command(selfCopy, "update")
|
||||
cmd := exec.Command(cmdTailscaleCopy, "update")
|
||||
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winVersionEnv+"="+ver)
|
||||
cmd.Stdout = up.Stderr
|
||||
cmd.Stderr = up.Stderr
|
||||
|
||||
@@ -56,9 +56,11 @@ import (
|
||||
"github.com/hdevalence/ed25519consensus"
|
||||
"golang.org/x/crypto/blake2s"
|
||||
"tailscale.com/feature"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/progresstracking"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -329,7 +331,7 @@ func fetch(url string, limit int64) ([]byte, error) {
|
||||
// download writes the response body of url into a local file at dst, up to
|
||||
// limit bytes. On success, the returned value is a BLAKE2s hash of the file.
|
||||
func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]byte, int64, error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr := netutil.NewDefaultTransport()
|
||||
tr.Proxy = feature.HookProxyFromEnvironment.GetOrNil()
|
||||
defer tr.CloseIdleConnections()
|
||||
hc := &http.Client{
|
||||
@@ -372,7 +374,10 @@ func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]
|
||||
return nil, 0, err
|
||||
}
|
||||
defer of.Close()
|
||||
pw := &progressWriter{total: res.ContentLength, logf: c.logf}
|
||||
total := res.ContentLength
|
||||
pw := progresstracking.NewWriter(io.Discard, total, 2*time.Second, func(done int64) {
|
||||
c.logf("Downloaded %v/%v (%.1f%%)", done, total, float64(done)/float64(total)*100)
|
||||
})
|
||||
h := NewPackageHash()
|
||||
n, err := io.Copy(io.MultiWriter(of, h, pw), io.LimitReader(dlRes.Body, limit))
|
||||
if err != nil {
|
||||
@@ -387,31 +392,10 @@ func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]
|
||||
if err := of.Close(); err != nil {
|
||||
return nil, n, err
|
||||
}
|
||||
pw.print()
|
||||
|
||||
return h.Sum(nil), h.Len(), nil
|
||||
}
|
||||
|
||||
type progressWriter struct {
|
||||
done int64
|
||||
total int64
|
||||
lastPrint time.Time
|
||||
logf logger.Logf
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (n int, err error) {
|
||||
pw.done += int64(len(p))
|
||||
if time.Since(pw.lastPrint) > 2*time.Second {
|
||||
pw.print()
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (pw *progressWriter) print() {
|
||||
pw.lastPrint = time.Now()
|
||||
pw.logf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
|
||||
}
|
||||
|
||||
func parsePrivateKey(data []byte, typeTag string) (ed25519.PrivateKey, error) {
|
||||
b, rest := pem.Decode(data)
|
||||
if b == nil {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package distsign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// DownloadVerified is a convenience wrapper around [Client.Download]
|
||||
// for callers that have a full URL (e.g.
|
||||
// https://pkgs.tailscale.com/unstable/foo.gaf) rather than a base URL
|
||||
// plus path. It splits srcURL into a base ("scheme://host") and a path,
|
||||
// constructs a [Client] for the base, and downloads with signature
|
||||
// verification to dstPath.
|
||||
func DownloadVerified(ctx context.Context, logf logger.Logf, srcURL, dstPath string) error {
|
||||
if logf == nil {
|
||||
logf = logger.Discard
|
||||
}
|
||||
u, err := url.Parse(srcURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing URL %q: %w", srcURL, err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("URL %q is missing scheme or host", srcURL)
|
||||
}
|
||||
base := &url.URL{Scheme: u.Scheme, User: u.User, Host: u.Host}
|
||||
path := strings.TrimPrefix(u.Path, "/")
|
||||
if path == "" {
|
||||
return fmt.Errorf("URL %q has no path component", srcURL)
|
||||
}
|
||||
c, err := NewClient(logf, base.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Download(ctx, path, dstPath)
|
||||
}
|
||||
@@ -143,19 +143,32 @@ func main() {
|
||||
log.Printf("Using cigocached at %s", *srvURL)
|
||||
}
|
||||
c.remote = &cachers.HTTPClient{
|
||||
BaseURL: *srvURL,
|
||||
Disk: c.disk,
|
||||
HTTPClient: httpClient(srvHost, *srvHostDial),
|
||||
AccessToken: *token,
|
||||
Verbose: *verbose,
|
||||
BestEffortHTTP: true,
|
||||
BaseURL: *srvURL,
|
||||
Disk: c.disk,
|
||||
HTTPClient: httpClient(srvHost, *srvHostDial),
|
||||
AccessToken: *token,
|
||||
Verbose: *verbose,
|
||||
BestEffortHTTP: true,
|
||||
AsyncPutTimeout: asyncPutTimeout,
|
||||
AsyncPutMaxConcurrent: 10,
|
||||
}
|
||||
}
|
||||
var p *cacheproc.Process
|
||||
p = &cacheproc.Process{
|
||||
Close: func() error {
|
||||
if c.remote != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if !c.remote.Shutdown(ctx) {
|
||||
log.Printf("cigocacher: timed out waiting for background PUTs to drain")
|
||||
}
|
||||
// Always surface dropped PUTs.
|
||||
if timedOut, canceled := c.remote.PutsTimedOut.Load(), c.remote.PutsCanceled.Load(); timedOut+canceled > 0 {
|
||||
log.Printf("cigocacher: %d background PUTs timed out, %d canceled", timedOut, canceled)
|
||||
}
|
||||
}
|
||||
if c.verbose {
|
||||
log.Printf("gocacheprog: closing; %d gets (%d hits, %d misses, %d errors); %d puts (%d errors)",
|
||||
log.Printf("cigocacher: closing; %d gets (%d hits, %d misses, %d errors); %d puts (%d errors)",
|
||||
p.Gets.Load(), p.GetHits.Load(), p.GetMisses.Load(), p.GetErrors.Load(), p.Puts.Load(), p.PutErrors.Load())
|
||||
}
|
||||
return c.close()
|
||||
@@ -338,3 +351,23 @@ func fetchStats(cl *http.Client, baseURL, accessToken string) (string, error) {
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
const (
|
||||
// minPutTimeout is the floor we clamp to for small objects where the time is
|
||||
// dominated by fixed overheads like connection establishment, waiting for a
|
||||
// busy server to service the request etc.
|
||||
minPutTimeout = 5 * time.Second
|
||||
// maxPutTimeout is the ceiling we clamp to for large objects.
|
||||
maxPutTimeout = 30 * time.Second
|
||||
// minAverageBandwidth is the minimum average bandwidth (2MiB/s) we require
|
||||
// for PUTs to complete within the timeout in its linear scaling region.
|
||||
minAverageBandwidth = 2 * 1 << 20 / float64(time.Second)
|
||||
)
|
||||
|
||||
// asyncPutTimeout returns a size-dependent timeout for async PUTs to the remote
|
||||
// gocached server. It returns 5s for size <= 10MiB, 30s for size >= 60MiB and
|
||||
// scales linearly in between.
|
||||
func asyncPutTimeout(size int64) time.Duration {
|
||||
timeout := time.Duration(float64(size) / minAverageBandwidth)
|
||||
return min(max(minPutTimeout, timeout), maxPutTimeout)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAsyncPutTimeout(t *testing.T) {
|
||||
for size, expected := range map[int64]time.Duration{
|
||||
0: 5 * time.Second,
|
||||
10: 5 * time.Second,
|
||||
10 * 1 << 20: 5 * time.Second,
|
||||
20 * 1 << 20: 10 * time.Second,
|
||||
40 * 1 << 20: 20 * time.Second,
|
||||
60 * 1 << 20: 30 * time.Second,
|
||||
10 * 1 << 30: 30 * time.Second,
|
||||
} {
|
||||
if actual := asyncPutTimeout(size); actual != expected {
|
||||
t.Errorf("for size %d, expected %v, but got %v", size, expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-25
@@ -143,25 +143,9 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("if src.%s != nil {", fname)
|
||||
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
|
||||
writef("for i := range dst.%s {", fname)
|
||||
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
|
||||
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname)
|
||||
if codegen.ContainsPointers(ptr.Elem()) {
|
||||
if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface {
|
||||
writef("\tdst.%s[i] = new((*src.%s[i]).Clone())", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
} else {
|
||||
writef("\tdst.%s[i] = new(*src.%s[i])", fname, fname)
|
||||
}
|
||||
writef("}")
|
||||
} else if ft.Elem().String() == "encoding/json.RawMessage" {
|
||||
writef("\tdst.%s[i] = append(src.%s[i][:0:0], src.%s[i]...)", fname, fname, fname)
|
||||
} else if _, isIface := ft.Elem().Underlying().(*types.Interface); isIface {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
writeSliceElemClone(writef, ft.Elem(),
|
||||
fmt.Sprintf("src.%s[i]", fname),
|
||||
fmt.Sprintf("dst.%s[i]", fname))
|
||||
writef("}")
|
||||
writef("}")
|
||||
} else {
|
||||
@@ -185,15 +169,32 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("}")
|
||||
case *types.Map:
|
||||
elem := ft.Elem()
|
||||
if sliceType, isSlice := elem.(*types.Slice); isSlice {
|
||||
if sliceType, isSlice := elem.Underlying().(*types.Slice); isSlice {
|
||||
n := it.QualifiedName(sliceType.Elem())
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
|
||||
writef("\tfor k := range src.%s {", fname)
|
||||
// use zero-length slice instead of nil to ensure
|
||||
// the key is always copied.
|
||||
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
|
||||
writef("\t}")
|
||||
if codegen.ContainsPointers(sliceType.Elem()) {
|
||||
writef("\tfor k, sv := range src.%s {", fname)
|
||||
writef("\t\tif sv == nil {")
|
||||
writef("\t\t\tdst.%s[k] = nil", fname)
|
||||
writef("\t\t\tcontinue")
|
||||
writef("\t\t}")
|
||||
writef("\t\tdst.%s[k] = make([]%s, len(sv))", fname, n)
|
||||
writef("\t\tfor i := range sv {")
|
||||
innerWritef := func(format string, args ...any) {
|
||||
writef("\t\t"+format, args...)
|
||||
}
|
||||
writeSliceElemClone(innerWritef, sliceType.Elem(),
|
||||
"sv[i]", fmt.Sprintf("dst.%s[k][i]", fname))
|
||||
writef("\t\t}")
|
||||
writef("\t}")
|
||||
} else {
|
||||
writef("\tfor k := range src.%s {", fname)
|
||||
// use zero-length slice instead of nil to ensure
|
||||
// the key is always copied.
|
||||
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
|
||||
writef("\t}")
|
||||
}
|
||||
writef("}")
|
||||
} else if codegen.IsViewType(elem) || !codegen.ContainsPointers(elem) {
|
||||
// If the map values are view types (which are
|
||||
@@ -242,6 +243,31 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
buf.Write(codegen.AssertStructUnchanged(t, name, typeParams, "Clone", it))
|
||||
}
|
||||
|
||||
// writeSliceElemClone generates code to deep-clone a single slice element
|
||||
// from srcExpr to dstExpr. It handles pointer, json.RawMessage, interface,
|
||||
// and named struct element types.
|
||||
func writeSliceElemClone(writef func(string, ...any), elemType types.Type, srcExpr, dstExpr string) {
|
||||
if ptr, isPtr := elemType.(*types.Pointer); isPtr {
|
||||
writef("if %s == nil { %s = nil } else {", srcExpr, dstExpr)
|
||||
if codegen.ContainsPointers(ptr.Elem()) {
|
||||
if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface {
|
||||
writef("\t%s = new((*%s).Clone())", dstExpr, srcExpr)
|
||||
} else {
|
||||
writef("\t%s = %s.Clone()", dstExpr, srcExpr)
|
||||
}
|
||||
} else {
|
||||
writef("\t%s = new(*%s)", dstExpr, srcExpr)
|
||||
}
|
||||
writef("}")
|
||||
} else if elemType.String() == "encoding/json.RawMessage" {
|
||||
writef("%s = append(%s[:0:0], %s...)", dstExpr, srcExpr, srcExpr)
|
||||
} else if _, isIface := elemType.Underlying().(*types.Interface); isIface {
|
||||
writef("%s = %s.Clone()", dstExpr, srcExpr)
|
||||
} else {
|
||||
writef("%s = *%s.Clone()", dstExpr, srcExpr)
|
||||
}
|
||||
}
|
||||
|
||||
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
|
||||
func hasBasicUnderlying(typ types.Type) bool {
|
||||
switch typ.Underlying().(type) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/cmd/cloner/clonerex"
|
||||
)
|
||||
|
||||
@@ -182,6 +183,46 @@ func TestNamedMapContainer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapSlicePointerContainer(t *testing.T) {
|
||||
num := 42
|
||||
orig := &clonerex.MapSlicePointerContainer{
|
||||
Routes: map[string][]*clonerex.SliceContainer{
|
||||
"route1": {
|
||||
{Slice: []*int{&num}},
|
||||
{Slice: []*int{&num, &num}},
|
||||
},
|
||||
"route2": {
|
||||
{Slice: []*int{&num}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cloned := orig.Clone()
|
||||
if !reflect.DeepEqual(orig, cloned) {
|
||||
t.Errorf("Clone() = %v, want %v", cloned, orig)
|
||||
}
|
||||
|
||||
// Mutate cloned.Routes pointer values
|
||||
*cloned.Routes["route1"][0].Slice[0] = 999
|
||||
if *orig.Routes["route1"][0].Slice[0] == 999 {
|
||||
t.Errorf("Clone() aliased memory in Routes: original was modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapSlicePointerContainerNilValue(t *testing.T) {
|
||||
num := 7
|
||||
orig := &clonerex.MapSlicePointerContainer{
|
||||
Routes: map[string][]*clonerex.SliceContainer{
|
||||
"nil-value": nil,
|
||||
"non-nil": {{Slice: []*int{&num}}},
|
||||
},
|
||||
}
|
||||
cloned := orig.Clone()
|
||||
if diff := cmp.Diff(orig.Routes, cloned.Routes); diff != "" {
|
||||
t.Errorf("Clone() Routes mismatch (-orig +cloned):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeeplyNestedMap(t *testing.T) {
|
||||
num := 123
|
||||
orig := &clonerex.DeeplyNestedMap{
|
||||
@@ -242,3 +283,13 @@ func TestDeeplyNestedMap(t *testing.T) {
|
||||
t.Errorf("Clone() aliased FourLevels map: new nested key appeared in original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapWithNamedSliceValues(t *testing.T) {
|
||||
orig := &clonerex.MapWithNamedSliceValues{
|
||||
M: map[string]clonerex.NamedSlice{"k": {"foo", "bar"}},
|
||||
}
|
||||
cloned := orig.Clone()
|
||||
if diff := cmp.Diff(orig, cloned); diff != "" {
|
||||
t.Errorf("Clone() mismatch (-orig +cloned):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer,MapWithNamedSliceValues
|
||||
|
||||
// Package clonerex is an example package for the cloner tool.
|
||||
package clonerex
|
||||
|
||||
import "maps"
|
||||
|
||||
type SliceContainer struct {
|
||||
Slice []*int
|
||||
}
|
||||
@@ -49,9 +51,7 @@ func (m NamedMap) Clone() NamedMap {
|
||||
return nil
|
||||
}
|
||||
m2 := make(NamedMap, len(m))
|
||||
for k, v := range m {
|
||||
m2[k] = v
|
||||
}
|
||||
maps.Copy(m2, m)
|
||||
return m2
|
||||
}
|
||||
|
||||
@@ -60,8 +60,24 @@ type NamedMapContainer struct {
|
||||
Attrs NamedMap
|
||||
}
|
||||
|
||||
// MapSlicePointerContainer has a map whose values are slices of pointers.
|
||||
// This tests that the cloner deep-clones the pointer elements in the slice,
|
||||
// not just the slice itself (which would leave aliased pointers).
|
||||
type MapSlicePointerContainer struct {
|
||||
Routes map[string][]*SliceContainer
|
||||
}
|
||||
|
||||
// DeeplyNestedMap tests arbitrary depth of map nesting (3+ levels)
|
||||
type DeeplyNestedMap struct {
|
||||
ThreeLevels map[string]map[string]map[string]int
|
||||
FourLevels map[string]map[string]map[string]map[string]*SliceContainer
|
||||
}
|
||||
|
||||
// MapWithNamedSliceValues has a map with a named slice type for values. This
|
||||
// tests that the generator treats these values like any other slice and not a
|
||||
// struct.
|
||||
type MapWithNamedSliceValues struct {
|
||||
M map[string]NamedSlice
|
||||
}
|
||||
|
||||
type NamedSlice []string
|
||||
|
||||
@@ -176,9 +176,64 @@ var _NamedMapContainerCloneNeedsRegeneration = NamedMapContainer(struct {
|
||||
Attrs NamedMap
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of MapSlicePointerContainer.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *MapSlicePointerContainer) Clone() *MapSlicePointerContainer {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(MapSlicePointerContainer)
|
||||
*dst = *src
|
||||
if dst.Routes != nil {
|
||||
dst.Routes = map[string][]*SliceContainer{}
|
||||
for k, sv := range src.Routes {
|
||||
if sv == nil {
|
||||
dst.Routes[k] = nil
|
||||
continue
|
||||
}
|
||||
dst.Routes[k] = make([]*SliceContainer, len(sv))
|
||||
for i := range sv {
|
||||
if sv[i] == nil {
|
||||
dst.Routes[k][i] = nil
|
||||
} else {
|
||||
dst.Routes[k][i] = sv[i].Clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _MapSlicePointerContainerCloneNeedsRegeneration = MapSlicePointerContainer(struct {
|
||||
Routes map[string][]*SliceContainer
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of MapWithNamedSliceValues.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *MapWithNamedSliceValues) Clone() *MapWithNamedSliceValues {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(MapWithNamedSliceValues)
|
||||
*dst = *src
|
||||
if dst.M != nil {
|
||||
dst.M = map[string]NamedSlice{}
|
||||
for k := range src.M {
|
||||
dst.M[k] = append([]string{}, src.M[k]...)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _MapWithNamedSliceValuesCloneNeedsRegeneration = MapWithNamedSliceValues(struct {
|
||||
M map[string]NamedSlice
|
||||
}{})
|
||||
|
||||
// Clone duplicates src into dst and reports whether it succeeded.
|
||||
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
|
||||
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer.
|
||||
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer,MapWithNamedSliceValues.
|
||||
func Clone(dst, src any) bool {
|
||||
switch src := src.(type) {
|
||||
case *SliceContainer:
|
||||
@@ -226,6 +281,24 @@ func Clone(dst, src any) bool {
|
||||
*dst = src.Clone()
|
||||
return true
|
||||
}
|
||||
case *MapSlicePointerContainer:
|
||||
switch dst := dst.(type) {
|
||||
case *MapSlicePointerContainer:
|
||||
*dst = *src.Clone()
|
||||
return true
|
||||
case **MapSlicePointerContainer:
|
||||
*dst = src.Clone()
|
||||
return true
|
||||
}
|
||||
case *MapWithNamedSliceValues:
|
||||
switch dst := dst.(type) {
|
||||
case *MapWithNamedSliceValues:
|
||||
*dst = *src.Clone()
|
||||
return true
|
||||
case **MapWithNamedSliceValues:
|
||||
*dst = src.Clone()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -22,11 +22,12 @@ import (
|
||||
"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/types/views"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/linuxfw"
|
||||
"tailscale.com/util/mak"
|
||||
@@ -54,9 +55,10 @@ type egressProxy struct {
|
||||
|
||||
tsClient *local.Client // never nil
|
||||
|
||||
netmapChan chan ipn.Notify // chan to receive netmap updates on
|
||||
netmapChan chan netmapState // chan to receive netmap state updates on
|
||||
|
||||
podIPv4 string // never empty string, currently only IPv4 is supported
|
||||
podIPv4 string // empty if Pod does not have IPv4 address
|
||||
podIPv6 string // empty if Pod does not have IPv6 address
|
||||
|
||||
// tailnetFQDNs is the egress service FQDN to tailnet IP mappings that
|
||||
// were last used to configure firewall rules for this proxy.
|
||||
@@ -86,7 +88,7 @@ type httpClient interface {
|
||||
// - 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 {
|
||||
func (ep *egressProxy) run(ctx context.Context, nm netmapState, opts egressProxyRunOpts) error {
|
||||
ep.configure(opts)
|
||||
var tickChan <-chan time.Time
|
||||
var eventChan <-chan fsnotify.Event
|
||||
@@ -105,7 +107,7 @@ func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRu
|
||||
eventChan = w.Events
|
||||
}
|
||||
|
||||
if err := ep.sync(ctx, n); err != nil {
|
||||
if err := ep.sync(ctx, nm); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
@@ -116,14 +118,14 @@ func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRu
|
||||
log.Printf("periodic sync, ensuring firewall config is up to date...")
|
||||
case <-eventChan:
|
||||
log.Printf("config file change detected, ensuring firewall config is up to date...")
|
||||
case n = <-ep.netmapChan:
|
||||
shouldResync := ep.shouldResync(n)
|
||||
case nm = <-ep.netmapChan:
|
||||
shouldResync := ep.shouldResync(nm)
|
||||
if !shouldResync {
|
||||
continue
|
||||
}
|
||||
log.Printf("netmap change detected, ensuring firewall config is up to date...")
|
||||
}
|
||||
if err := ep.sync(ctx, n); err != nil {
|
||||
if err := ep.sync(ctx, nm); err != nil {
|
||||
return fmt.Errorf("error syncing egress service config: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -135,8 +137,9 @@ type egressProxyRunOpts struct {
|
||||
kc kubeclient.Client
|
||||
tsClient *local.Client
|
||||
stateSecret string
|
||||
netmapChan chan ipn.Notify
|
||||
netmapChan chan netmapState
|
||||
podIPv4 string
|
||||
podIPv6 string
|
||||
tailnetAddrs []netip.Prefix
|
||||
}
|
||||
|
||||
@@ -149,6 +152,7 @@ func (ep *egressProxy) configure(opts egressProxyRunOpts) {
|
||||
ep.stateSecret = opts.stateSecret
|
||||
ep.netmapChan = opts.netmapChan
|
||||
ep.podIPv4 = opts.podIPv4
|
||||
ep.podIPv6 = opts.podIPv6
|
||||
ep.tailnetAddrs = opts.tailnetAddrs
|
||||
ep.client = &http.Client{} // default HTTP client
|
||||
sleepDuration := time.Second
|
||||
@@ -164,7 +168,7 @@ func (ep *egressProxy) configure(opts egressProxyRunOpts) {
|
||||
// any firewall rules need to be updated. Currently using status in state Secret as a reference for what is the current
|
||||
// firewall configuration is good enough because - the status is keyed by the Pod IP - we crash the Pod on errors such
|
||||
// as failed firewall update
|
||||
func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
|
||||
func (ep *egressProxy) sync(ctx context.Context, nm netmapState) error {
|
||||
cfgs, err := ep.getConfigs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving egress service configs: %w", err)
|
||||
@@ -173,28 +177,27 @@ func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving current egress proxy status: %w", err)
|
||||
}
|
||||
newStatus, err := ep.syncEgressConfigs(cfgs, status, n)
|
||||
newStatus, err := ep.syncEgressConfigs(cfgs, status, nm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error syncing egress service configs: %w", err)
|
||||
}
|
||||
if !servicesStatusIsEqual(newStatus, status) {
|
||||
if err := ep.setStatus(ctx, newStatus, n); err != nil {
|
||||
if err := ep.setStatus(ctx, newStatus, nm); err != nil {
|
||||
return fmt.Errorf("error setting egress proxy status: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
|
||||
// Netmap must not be nil.
|
||||
func (ep *egressProxy) addrsHaveChanged(n ipn.Notify) bool {
|
||||
return !reflect.DeepEqual(ep.tailnetAddrs, n.NetMap.SelfNode.Addresses())
|
||||
// addrsHaveChanged returns true if the provided netmap state contains tailnet address change for this proxy node.
|
||||
func (ep *egressProxy) addrsHaveChanged(nm netmapState) bool {
|
||||
return !views.SliceEqual(views.SliceOf(ep.tailnetAddrs), nm.self.Addresses())
|
||||
}
|
||||
|
||||
// syncEgressConfigs adds and deletes firewall rules to match the desired
|
||||
// configuration. It uses the provided status to determine what is currently
|
||||
// applied and updates the status after a successful sync.
|
||||
func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *egressservices.Status, n ipn.Notify) (*egressservices.Status, error) {
|
||||
func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *egressservices.Status, nm netmapState) (*egressservices.Status, error) {
|
||||
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -212,8 +215,8 @@ func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *e
|
||||
// Add new services, update rules for any that have changed.
|
||||
rulesPerSvcToAdd := make(map[string][]rule, 0)
|
||||
rulesPerSvcToDelete := make(map[string][]rule, 0)
|
||||
for svcName, cfg := range *cfgs {
|
||||
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, n)
|
||||
for svcName, cfg := range cfgs {
|
||||
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, nm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error determining tailnet target IPs: %w", err)
|
||||
}
|
||||
@@ -228,12 +231,12 @@ func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *e
|
||||
if len(rulesToDelete) != 0 {
|
||||
mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete)
|
||||
}
|
||||
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(n) {
|
||||
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(nm) {
|
||||
// For each tailnet target, set up SNAT from the local tailnet device address of the matching
|
||||
// family.
|
||||
for _, t := range tailnetTargetIPs {
|
||||
var local netip.Addr
|
||||
for _, pfx := range n.NetMap.SelfNode.Addresses().All() {
|
||||
for _, pfx := range nm.self.Addresses().All() {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
@@ -249,6 +252,9 @@ func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *e
|
||||
if err := ep.nfr.EnsureSNATForDst(local, t); err != nil {
|
||||
return nil, fmt.Errorf("error setting up SNAT rule: %w", err)
|
||||
}
|
||||
if err := ep.nfr.ClampMSSToPMTU(tailscaleTunInterface, t); err != nil {
|
||||
return nil, fmt.Errorf("error clamping MSS to PMTU: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update the status. Status will be written back to the state Secret by the caller.
|
||||
@@ -352,7 +358,7 @@ func updatesForCfg(svcName string, cfg egressservices.Config, status *egressserv
|
||||
|
||||
// deleteUnneccessaryServices ensure that any services found on status, but not
|
||||
// present in config are deleted.
|
||||
func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, status *egressservices.Status) error {
|
||||
func (ep *egressProxy) deleteUnnecessaryServices(cfgs egressservices.Configs, status *egressservices.Status) error {
|
||||
if !hasServicesConfigured(status) {
|
||||
return nil
|
||||
}
|
||||
@@ -367,7 +373,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, s
|
||||
}
|
||||
|
||||
for svcName, svc := range status.Services {
|
||||
if _, ok := (*cfgs)[svcName]; !ok {
|
||||
if _, ok := cfgs[svcName]; !ok {
|
||||
log.Printf("service %s is no longer required, deleting", svcName)
|
||||
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
|
||||
return fmt.Errorf("error deleting service %s: %w", svcName, err)
|
||||
@@ -379,7 +385,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, s
|
||||
}
|
||||
|
||||
// getConfigs gets the mounted egress service configuration.
|
||||
func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) {
|
||||
func (ep *egressProxy) getConfigs() (egressservices.Configs, error) {
|
||||
svcsCfg := filepath.Join(ep.cfgPath, egressservices.KeyEgressServices)
|
||||
j, err := os.ReadFile(svcsCfg)
|
||||
if os.IsNotExist(err) {
|
||||
@@ -391,7 +397,7 @@ func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) {
|
||||
if len(j) == 0 || string(j) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cfg := &egressservices.Configs{}
|
||||
cfg := egressservices.Configs{}
|
||||
if err := json.Unmarshal(j, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -415,7 +421,7 @@ func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, e
|
||||
if err := json.Unmarshal([]byte(raw), status); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling previous config: %w", err)
|
||||
}
|
||||
if reflect.DeepEqual(status.PodIPv4, ep.podIPv4) {
|
||||
if status.PodIPv4 == ep.podIPv4 && status.PodIPv6 == ep.podIPv6 {
|
||||
return status, nil
|
||||
}
|
||||
return nil, nil
|
||||
@@ -423,12 +429,13 @@ func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, e
|
||||
|
||||
// setStatus writes egress proxy's currently configured firewall to the state
|
||||
// Secret and updates proxy's tailnet addresses.
|
||||
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, n ipn.Notify) error {
|
||||
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, nm netmapState) error {
|
||||
// Pod IP is used to determine if a stored status applies to THIS proxy Pod.
|
||||
if status == nil {
|
||||
status = &egressservices.Status{}
|
||||
}
|
||||
status.PodIPv4 = ep.podIPv4
|
||||
status.PodIPv6 = ep.podIPv6
|
||||
secret, err := ep.kc.GetSecret(ctx, ep.stateSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving state Secret: %w", err)
|
||||
@@ -446,7 +453,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
|
||||
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
|
||||
return fmt.Errorf("error patching state Secret: %w", err)
|
||||
}
|
||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
ep.tailnetAddrs = nm.self.Addresses().AsSlice()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -456,7 +463,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
|
||||
// FQDN, resolve the FQDN and return the resolved IPs. It checks if the
|
||||
// netfilter runner supports IPv6 NAT and skips any IPv6 addresses if it
|
||||
// doesn't.
|
||||
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.Notify) (addrs []netip.Addr, err error) {
|
||||
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm netmapState) (addrs []netip.Addr, err error) {
|
||||
if svc.TailnetTarget.IP != "" {
|
||||
addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
|
||||
if err != nil {
|
||||
@@ -472,11 +479,11 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.N
|
||||
if svc.TailnetTarget.FQDN == "" {
|
||||
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
|
||||
}
|
||||
if n.NetMap == nil {
|
||||
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
|
||||
if !nm.self.Valid() {
|
||||
log.Printf("netmap state is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
|
||||
return addrs, nil
|
||||
}
|
||||
egressAddrs, err := resolveTailnetFQDN(n.NetMap, svc.TailnetTarget.FQDN)
|
||||
egressAddrs, err := resolveTailnetFQDN(nm, svc.TailnetTarget.FQDN)
|
||||
if err != nil {
|
||||
log.Printf("error fetching backend addresses for %q: %v", svc.TailnetTarget.FQDN, err)
|
||||
return addrs, nil
|
||||
@@ -500,26 +507,26 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.N
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// shouldResync parses netmap update and returns true if the update contains
|
||||
// shouldResync parses netmap state update and returns true if the update contains
|
||||
// changes for which the egress proxy's firewall should be reconfigured.
|
||||
func (ep *egressProxy) shouldResync(n ipn.Notify) bool {
|
||||
if n.NetMap == nil {
|
||||
func (ep *egressProxy) shouldResync(nm netmapState) bool {
|
||||
if !nm.self.Valid() {
|
||||
return false
|
||||
}
|
||||
|
||||
// If proxy's tailnet addresses have changed, resync.
|
||||
if !reflect.DeepEqual(n.NetMap.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
|
||||
if !views.SliceEqual(nm.self.Addresses(), views.SliceOf(ep.tailnetAddrs)) {
|
||||
log.Printf("node addresses have changed, trigger egress config resync")
|
||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
ep.tailnetAddrs = nm.self.Addresses().AsSlice()
|
||||
return true
|
||||
}
|
||||
|
||||
// If the IPs for any of the egress services configured via FQDN have
|
||||
// changed, resync.
|
||||
for fqdn, ips := range ep.targetFQDNs {
|
||||
for _, nn := range n.NetMap.Peers {
|
||||
for nn := range nm.peers() {
|
||||
if equalFQDNs(nn.Name(), fqdn) {
|
||||
if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) {
|
||||
if !views.SliceEqual(views.SliceOf(ips), nn.Addresses()) {
|
||||
log.Printf("backend addresses for egress target %q have changed old IPs %v, new IPs %v trigger egress config resync", nn.Name(), ips, nn.Addresses().AsSlice())
|
||||
return true
|
||||
}
|
||||
@@ -602,8 +609,8 @@ type rule struct {
|
||||
protocol string
|
||||
}
|
||||
|
||||
func wantsServicesConfigured(cfgs *egressservices.Configs) bool {
|
||||
return cfgs != nil && len(*cfgs) != 0
|
||||
func wantsServicesConfigured(cfgs egressservices.Configs) bool {
|
||||
return cfgs != nil && len(cfgs) != 0
|
||||
}
|
||||
|
||||
func hasServicesConfigured(status *egressservices.Status) bool {
|
||||
@@ -619,6 +626,8 @@ func servicesStatusIsEqual(st, st1 *egressservices.Status) bool {
|
||||
}
|
||||
st.PodIPv4 = ""
|
||||
st1.PodIPv4 = ""
|
||||
st.PodIPv6 = ""
|
||||
st1.PodIPv6 = ""
|
||||
return reflect.DeepEqual(*st, *st1)
|
||||
}
|
||||
|
||||
@@ -657,37 +666,42 @@ func (ep *egressProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// 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
|
||||
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...")
|
||||
var wg sync.WaitGroup
|
||||
for s, cfg := range *cfgs {
|
||||
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
|
||||
// TODO(beckypauley): In dual-stack clusters, this is a best-effort check as we do not control which IP family is used.
|
||||
// This confirms removal from routing on this node for one family only. The other IP family then relies on the longSleep below.
|
||||
wg.Go(func() {
|
||||
log.Printf("Ensuring that cluster traffic is no longer routed to %q via this Pod...", svc)
|
||||
podIP, header := ep.podIPv4, kubetypes.PodIPv4Header
|
||||
if podIP == "" {
|
||||
podIP, header = ep.podIPv6, kubetypes.PodIPv6Header
|
||||
}
|
||||
if ep.podDrained(ctx, svc, hep, podIP, header, hp) {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(ep.shortSleep)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if ctx.Err() != nil { // kubelet's HTTP request timeout
|
||||
select {
|
||||
case <-ctx.Done(): // kubelet's HTTP request timeout
|
||||
log.Printf("Cluster traffic for %s did not stop being routed to this Pod.", svc)
|
||||
return
|
||||
case <-ticker.C:
|
||||
if ep.podDrained(ctx, svc, hep, podIP, header, hp) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -701,9 +715,9 @@ func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs *egresss
|
||||
|
||||
// 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) {
|
||||
func lookupPodRoute(ctx context.Context, hep, podIP, podIPHeader string, repeat int, client httpClient) (bool, error) {
|
||||
for range repeat {
|
||||
f, err := lookup(ctx, hep, podIP, client)
|
||||
f, err := lookup(ctx, hep, podIP, podIPHeader, client)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -715,7 +729,7 @@ func lookupPodRoute(ctx context.Context, hep, podIP string, repeat int, client h
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func lookup(ctx context.Context, hep, podIP, podIPHeader 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)
|
||||
@@ -730,7 +744,7 @@ func lookup(ctx context.Context, hep, podIP string, client httpClient) (bool, er
|
||||
return true, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
gotIP := resp.Header.Get(kubetypes.PodIPv4Header)
|
||||
gotIP := resp.Header.Get(podIPHeader)
|
||||
return strings.EqualFold(podIP, gotIP), nil
|
||||
}
|
||||
|
||||
@@ -759,3 +773,17 @@ func (ep *egressProxy) getHEPPings() (int, error) {
|
||||
}
|
||||
return hp, nil
|
||||
}
|
||||
|
||||
func (ep *egressProxy) podDrained(ctx context.Context, svc, hep, podIP, header string, hp int) bool {
|
||||
found, err := lookupPodRoute(ctx, hep, podIP, header, 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)
|
||||
return true
|
||||
}
|
||||
if !found {
|
||||
log.Printf("service %q is no longer routed through this Pod", svc)
|
||||
return true
|
||||
}
|
||||
log.Printf("service %q is still routed through this Pod, waiting...", svc)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
@@ -255,13 +256,13 @@ func TestWaitTillSafeToShutdown(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfgs := &egressservices.Configs{}
|
||||
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{
|
||||
cfgs[svc] = egressservices.Config{
|
||||
HealthCheckEndpoint: endpoint,
|
||||
}
|
||||
}
|
||||
@@ -269,7 +270,8 @@ func TestWaitTillSafeToShutdown(t *testing.T) {
|
||||
}
|
||||
|
||||
ep := &egressProxy{
|
||||
podIPv4: podIP,
|
||||
podIPv4: podIP,
|
||||
shortSleep: time.Millisecond,
|
||||
client: &mockHTTPClient{
|
||||
podIP: podIP,
|
||||
anotherIP: anotherIP,
|
||||
|
||||
@@ -265,7 +265,13 @@ func ensureIngressRulesAdded(cfgs map[string]ingressservices.Config, nfr linuxfw
|
||||
|
||||
func addDNATRuleForSvc(nfr linuxfw.NetfilterRunner, serviceName string, tsIP, clusterIP netip.Addr) error {
|
||||
log.Printf("adding DNAT rule for Tailscale Service %s with IP %s to Kubernetes Service IP %s", serviceName, tsIP, clusterIP)
|
||||
return nfr.EnsureDNATRuleForSvc(serviceName, tsIP, clusterIP)
|
||||
if err := nfr.EnsureDNATRuleForSvc(serviceName, tsIP, clusterIP); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := nfr.ClampMSSToPMTU(tailscaleTunInterface, clusterIP); err != nil {
|
||||
return fmt.Errorf("error clamping MSS to PMTU: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureIngressRulesDeleted takes a map of Tailscale Services and rules and ensures that the firewall rules are deleted.
|
||||
|
||||
@@ -7,6 +7,7 @@ package main
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/kube/ingressservices"
|
||||
@@ -22,6 +23,7 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
TailscaleServiceIP netip.Addr
|
||||
ClusterIP netip.Addr
|
||||
}
|
||||
wantClampedAddrs []netip.Addr // cluster IPs that should have MSS clamping applied
|
||||
}{
|
||||
{
|
||||
name: "add_new_rules_when_no_existing_config",
|
||||
@@ -35,6 +37,7 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
}{
|
||||
"svc:foo": makeWantService("100.64.0.1", "10.0.0.1"),
|
||||
},
|
||||
wantClampedAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
|
||||
},
|
||||
{
|
||||
name: "add_multiple_services",
|
||||
@@ -52,6 +55,11 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
"svc:bar": makeWantService("100.64.0.2", "10.0.0.2"),
|
||||
"svc:baz": makeWantService("100.64.0.3", "10.0.0.3"),
|
||||
},
|
||||
wantClampedAddrs: []netip.Addr{
|
||||
netip.MustParseAddr("10.0.0.1"),
|
||||
netip.MustParseAddr("10.0.0.2"),
|
||||
netip.MustParseAddr("10.0.0.3"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add_both_ipv4_and_ipv6_rules",
|
||||
@@ -65,6 +73,10 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
}{
|
||||
"svc:foo": makeWantService("2001:db8::1", "2001:db8::2"),
|
||||
},
|
||||
wantClampedAddrs: []netip.Addr{
|
||||
netip.MustParseAddr("10.0.0.1"),
|
||||
netip.MustParseAddr("2001:db8::2"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add_ipv6_only_rules",
|
||||
@@ -78,6 +90,7 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
}{
|
||||
"svc:ipv6": makeWantService("2001:db8::10", "2001:db8::20"),
|
||||
},
|
||||
wantClampedAddrs: []netip.Addr{netip.MustParseAddr("2001:db8::20")},
|
||||
},
|
||||
{
|
||||
name: "delete_all_rules_when_config_removed",
|
||||
@@ -94,6 +107,7 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
TailscaleServiceIP netip.Addr
|
||||
ClusterIP netip.Addr
|
||||
}{},
|
||||
wantClampedAddrs: nil, // no rules added, no clamping
|
||||
},
|
||||
{
|
||||
name: "add_remove_modify",
|
||||
@@ -117,6 +131,10 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
"svc:foo": makeWantService("100.64.0.1", "10.0.0.2"),
|
||||
"svc:new": makeWantService("100.64.0.4", "10.0.0.4"),
|
||||
},
|
||||
wantClampedAddrs: []netip.Addr{
|
||||
netip.MustParseAddr("10.0.0.2"),
|
||||
netip.MustParseAddr("10.0.0.4"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_with_outdated_status",
|
||||
@@ -152,12 +170,17 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
"svc:web-ipv6": makeWantService("2001:db8::10", "2001:db8::20"),
|
||||
"svc:api": makeWantService("100.64.0.20", "10.0.0.20"),
|
||||
},
|
||||
wantClampedAddrs: []netip.Addr{
|
||||
netip.MustParseAddr("10.0.0.10"),
|
||||
netip.MustParseAddr("10.0.0.20"),
|
||||
netip.MustParseAddr("2001:db8::20"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var nfr linuxfw.NetfilterRunner = linuxfw.NewFakeNetfilterRunner()
|
||||
nfr := linuxfw.NewFakeNetfilterRunner()
|
||||
|
||||
ep := &ingressProxy{
|
||||
nfr: nfr,
|
||||
@@ -170,8 +193,7 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
t.Fatalf("syncIngressConfigs failed: %v", err)
|
||||
}
|
||||
|
||||
fake := nfr.(*linuxfw.FakeNetfilterRunner)
|
||||
gotServices := fake.GetServiceState()
|
||||
gotServices := nfr.GetServiceState()
|
||||
if len(gotServices) != len(tt.wantServices) {
|
||||
t.Errorf("got %d services, want %d", len(gotServices), len(tt.wantServices))
|
||||
}
|
||||
@@ -188,6 +210,20 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
t.Errorf("service %s: got ClusterIP %v, want %v", svc, got.ClusterIP, want.ClusterIP)
|
||||
}
|
||||
}
|
||||
|
||||
gotClamped := nfr.GetClampedAddrs()
|
||||
slices.SortFunc(gotClamped, func(a, b netip.Addr) int { return a.Compare(b) })
|
||||
slices.SortFunc(tt.wantClampedAddrs, func(a, b netip.Addr) int { return a.Compare(b) })
|
||||
if len(gotClamped) != len(tt.wantClampedAddrs) {
|
||||
t.Errorf("ClampMSSToPMTU: got %v, want %v", gotClamped, tt.wantClampedAddrs)
|
||||
} else {
|
||||
for i := range gotClamped {
|
||||
if gotClamped[i] != tt.wantClampedAddrs[i] {
|
||||
t.Errorf("ClampMSSToPMTU: got %v, want %v", gotClamped, tt.wantClampedAddrs)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+28
-85
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/authkey"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/kube/ingressservices"
|
||||
"tailscale.com/kube/kubeapi"
|
||||
@@ -32,7 +33,6 @@ import (
|
||||
)
|
||||
|
||||
const fieldManager = "tailscale-container"
|
||||
const kubeletMountedConfigLn = "..data"
|
||||
|
||||
// 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.
|
||||
@@ -127,6 +127,9 @@ func (kc *kubeClient) deleteAuthKey(ctx context.Context) error {
|
||||
|
||||
// resetContainerbootState resets state from previous runs of containerboot to
|
||||
// ensure the operator doesn't use stale state when a Pod is first recreated.
|
||||
//
|
||||
// Device identity keys (device_id, device_fqdn, device_ips) are preserved so
|
||||
// the operator can clean up the old device from the control plane.
|
||||
func (kc *kubeClient) resetContainerbootState(ctx context.Context, podUID string, tailscaledConfigAuthkey string) error {
|
||||
existingSecret, err := kc.GetSecret(ctx, kc.stateSecret)
|
||||
switch {
|
||||
@@ -139,12 +142,7 @@ func (kc *kubeClient) resetContainerbootState(ctx context.Context, podUID string
|
||||
|
||||
s := &kubeapi.Secret{
|
||||
Data: map[string][]byte{
|
||||
kubetypes.KeyCapVer: fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion),
|
||||
|
||||
// TODO(tomhjp): Perhaps shouldn't clear device ID and use a different signal, as this could leak tailnet devices.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyCapVer: fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion),
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -169,47 +167,18 @@ func (kc *kubeClient) setAndWaitForAuthKeyReissue(ctx context.Context, client *l
|
||||
return fmt.Errorf("error disconnecting from control: %w", err)
|
||||
}
|
||||
|
||||
err = kc.setReissueAuthKey(ctx, tailscaledConfigAuthKey)
|
||||
err = authkey.SetReissueAuthKey(ctx, kc.Client, kc.stateSecret, tailscaledConfigAuthKey, authkey.TailscaleContainerFieldManager)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set reissue_authkey in Kubernetes Secret: %w", err)
|
||||
}
|
||||
|
||||
err = kc.waitForAuthKeyReissue(ctx, cfg.TailscaledConfigFilePath, tailscaledConfigAuthKey, 10*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to receive new auth key: %w", err)
|
||||
clearFn := func(ctx context.Context) error {
|
||||
return authkey.ClearReissueAuthKey(ctx, kc.Client, kc.stateSecret, authkey.TailscaleContainerFieldManager)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (kc *kubeClient) setReissueAuthKey(ctx context.Context, authKey string) error {
|
||||
s := &kubeapi.Secret{
|
||||
Data: map[string][]byte{
|
||||
kubetypes.KeyReissueAuthkey: []byte(authKey),
|
||||
},
|
||||
}
|
||||
|
||||
log.Printf("Requesting a new auth key from operator")
|
||||
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, fieldManager)
|
||||
}
|
||||
|
||||
func (kc *kubeClient) waitForAuthKeyReissue(ctx context.Context, configPath string, oldAuthKey string, maxWait time.Duration) error {
|
||||
log.Printf("Waiting for operator to provide new auth key (max wait: %v)", maxWait)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, maxWait)
|
||||
defer cancel()
|
||||
|
||||
tailscaledCfgDir := filepath.Dir(configPath)
|
||||
toWatch := filepath.Join(tailscaledCfgDir, kubeletMountedConfigLn)
|
||||
|
||||
var (
|
||||
pollTicker <-chan time.Time
|
||||
eventChan <-chan fsnotify.Event
|
||||
)
|
||||
|
||||
pollInterval := 5 * time.Second
|
||||
|
||||
// Try to use fsnotify for faster notification
|
||||
getAuthKey := func() string { return authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath) }
|
||||
tailscaledCfgDir := filepath.Dir(cfg.TailscaledConfigFilePath)
|
||||
var notify <-chan struct{}
|
||||
if w, err := fsnotify.NewWatcher(); err != nil {
|
||||
log.Printf("auth key reissue: fsnotify unavailable, using polling: %v", err)
|
||||
} else if err := w.Add(tailscaledCfgDir); err != nil {
|
||||
@@ -217,54 +186,28 @@ func (kc *kubeClient) waitForAuthKeyReissue(ctx context.Context, configPath stri
|
||||
log.Printf("auth key reissue: fsnotify watch failed, using polling: %v", err)
|
||||
} else {
|
||||
defer w.Close()
|
||||
ch := make(chan struct{}, 1)
|
||||
toWatch := filepath.Join(tailscaledCfgDir, "..data")
|
||||
go func() {
|
||||
for ev := range w.Events {
|
||||
if ev.Name == toWatch {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
notify = ch
|
||||
log.Printf("auth key reissue: watching for config changes via fsnotify")
|
||||
eventChan = w.Events
|
||||
}
|
||||
|
||||
// still keep polling if using fsnotify, for logging and in case fsnotify fails
|
||||
pt := time.NewTicker(pollInterval)
|
||||
defer pt.Stop()
|
||||
pollTicker = pt.C
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timeout waiting for auth key reissue after %v", maxWait)
|
||||
case <-pollTicker: // Waits for polling tick, continues when received
|
||||
case event := <-eventChan:
|
||||
if event.Name != toWatch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
newAuthKey := authkeyFromTailscaledConfig(configPath)
|
||||
if newAuthKey != "" && newAuthKey != oldAuthKey {
|
||||
log.Printf("New auth key received from operator after %v", time.Since(start).Round(time.Second))
|
||||
|
||||
if err := kc.clearReissueAuthKeyRequest(ctx); err != nil {
|
||||
log.Printf("Warning: failed to clear reissue request: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if eventChan == nil && pollTicker != nil {
|
||||
log.Printf("Waiting for new auth key from operator (%v elapsed)", time.Since(start).Round(time.Second))
|
||||
}
|
||||
err = authkey.WaitForAuthKeyReissue(ctx, tailscaledConfigAuthKey, 10*time.Minute, getAuthKey, clearFn, notify)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to receive new auth key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// clearReissueAuthKeyRequest removes the reissue_authkey marker from the Secret
|
||||
// to signal to the operator that we've successfully received the new key.
|
||||
func (kc *kubeClient) clearReissueAuthKeyRequest(ctx context.Context) error {
|
||||
s := &kubeapi.Secret{
|
||||
Data: map[string][]byte{
|
||||
kubetypes.KeyReissueAuthkey: nil,
|
||||
},
|
||||
}
|
||||
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, fieldManager)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForConsistentState waits for tailscaled to finish writing state if it
|
||||
|
||||
@@ -257,12 +257,8 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
authkey: "new-authkey",
|
||||
initial: map[string][]byte{},
|
||||
expected: map[string][]byte{
|
||||
kubetypes.KeyCapVer: capver,
|
||||
kubetypes.KeyPodUID: []byte("1234"),
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyCapVer: capver,
|
||||
kubetypes.KeyPodUID: []byte("1234"),
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -271,11 +267,7 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
"empty_initial_no_pod_uid": {
|
||||
initial: map[string][]byte{},
|
||||
expected: map[string][]byte{
|
||||
kubetypes.KeyCapVer: capver,
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyCapVer: capver,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -303,9 +295,6 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
kubetypes.KeyCapVer: capver,
|
||||
kubetypes.KeyPodUID: []byte("1234"),
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -321,9 +310,6 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
kubetypes.KeyCapVer: capver,
|
||||
kubetypes.KeyReissueAuthkey: nil,
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -338,9 +324,6 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
kubetypes.KeyCapVer: capver,
|
||||
// reissue_authkey not cleared.
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -355,9 +338,6 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
kubetypes.KeyCapVer: capver,
|
||||
// reissue_authkey not cleared.
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
|
||||
+444
-264
@@ -120,6 +120,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"iter"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
@@ -135,12 +136,15 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/immutable"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
kubeutils "tailscale.com/k8s-operator"
|
||||
"tailscale.com/kube/authkey"
|
||||
healthz "tailscale.com/kube/health"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
klc "tailscale.com/kube/localclient"
|
||||
@@ -148,21 +152,170 @@ import (
|
||||
"tailscale.com/kube/services"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/deephash"
|
||||
"tailscale.com/util/def"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/linuxfw"
|
||||
)
|
||||
|
||||
func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) {
|
||||
if defaultBool("TS_TEST_FAKE_NETFILTER", false) {
|
||||
if def.Bool(os.Getenv("TS_TEST_FAKE_NETFILTER"), false) {
|
||||
return linuxfw.NewFakeIPTablesRunner(), nil
|
||||
}
|
||||
return linuxfw.New(logf, "")
|
||||
}
|
||||
|
||||
func getAutoAdvertiseBool() bool {
|
||||
return defaultBool("TS_EXPERIMENTAL_SERVICE_AUTO_ADVERTISEMENT", true)
|
||||
return def.Bool(os.Getenv("TS_EXPERIMENTAL_SERVICE_AUTO_ADVERTISEMENT"), true)
|
||||
}
|
||||
|
||||
const containerbootWatchMask = ipn.NotifyInitialStatus |
|
||||
ipn.NotifyPeerChanges |
|
||||
ipn.NotifyNoNetMap
|
||||
|
||||
func notifyState(n ipn.Notify) (_ ipn.State, ok bool) {
|
||||
if n.State != nil {
|
||||
return *n.State, true
|
||||
}
|
||||
if n.InitialStatus != nil && n.InitialStatus.BackendState != "" {
|
||||
if state, ok := ipn.StateFromString(n.InitialStatus.BackendState); ok {
|
||||
return state, true
|
||||
}
|
||||
}
|
||||
return ipn.NoState, false
|
||||
}
|
||||
|
||||
var netmapStatePeerIDHasher = immutable.NewHasher(tailcfg.NodeID(0))
|
||||
|
||||
type netmapState struct {
|
||||
self tailcfg.NodeView
|
||||
peersByID *immutable.Map[tailcfg.NodeID, tailcfg.NodeView]
|
||||
peersByName *immutable.Map[string, tailcfg.NodeView] // keyed by tailcfg.Node.Name when NodeID is unavailable
|
||||
certDomains views.Slice[string]
|
||||
dnsExtraRecords views.Slice[tailcfg.DNSRecord]
|
||||
}
|
||||
|
||||
func (s netmapState) updateFromNotify(n ipn.Notify) netmapState {
|
||||
if n.InitialStatus != nil {
|
||||
s = s.updateFromStatus(n.InitialStatus)
|
||||
}
|
||||
if n.SelfChange != nil {
|
||||
s.self = n.SelfChange.View()
|
||||
}
|
||||
for _, p := range n.PeersChanged {
|
||||
s = s.upsertPeer(p.View())
|
||||
}
|
||||
for _, id := range n.PeersRemoved {
|
||||
if s.peersByID != nil {
|
||||
s.peersByID = s.peersByID.Delete(id)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// processNotify updates the netmap state from an IPN bus Notify. On
|
||||
// SelfChange it also refetches DNS via the LocalAPI dns-config
|
||||
// endpoint; the bus carries no DNS delta.
|
||||
func (s netmapState) processNotify(ctx context.Context, client *local.Client, n ipn.Notify) netmapState {
|
||||
s = s.updateFromNotify(n)
|
||||
if n.SelfChange != nil {
|
||||
dns, err := client.DNSConfig(ctx)
|
||||
if err != nil {
|
||||
log.Printf("error refreshing DNS config from tailscaled: %v", err)
|
||||
} else if dns != nil {
|
||||
s.dnsExtraRecords = views.SliceOf(dns.ExtraRecords)
|
||||
s.certDomains = views.SliceOf(dns.CertDomains)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s netmapState) updateFromStatus(st *ipnstate.Status) netmapState {
|
||||
s.certDomains = views.SliceOf(st.CertDomains)
|
||||
s.dnsExtraRecords = views.SliceOf(st.ExtraRecords)
|
||||
if st.Self != nil {
|
||||
s.self = nodeFromPeerStatus(st.Self).View()
|
||||
}
|
||||
if len(st.Peer) != 0 {
|
||||
s.peersByID = nil
|
||||
s.peersByName = nil
|
||||
for _, ps := range st.Peer {
|
||||
s = s.upsertPeer(nodeFromPeerStatus(ps).View())
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s netmapState) upsertPeer(n tailcfg.NodeView) netmapState {
|
||||
if !n.Valid() {
|
||||
return s
|
||||
}
|
||||
if s.peersByID == nil {
|
||||
s.peersByID = immutable.NewMap[tailcfg.NodeID, tailcfg.NodeView](netmapStatePeerIDHasher)
|
||||
}
|
||||
if s.peersByName == nil {
|
||||
s.peersByName = immutable.NewMap[string, tailcfg.NodeView](nil)
|
||||
}
|
||||
if n.ID() != 0 {
|
||||
s.peersByID = s.peersByID.Set(n.ID(), n)
|
||||
if name := n.Name(); name != "" {
|
||||
s.peersByName = s.peersByName.Delete(name)
|
||||
}
|
||||
return s
|
||||
}
|
||||
if n.Name() != "" {
|
||||
s.peersByName = s.peersByName.Set(n.Name(), n)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nodeFromPeerStatus(ps *ipnstate.PeerStatus) *tailcfg.Node {
|
||||
if ps == nil {
|
||||
return nil
|
||||
}
|
||||
n := &tailcfg.Node{
|
||||
ID: ps.NodeID,
|
||||
StableID: ps.ID,
|
||||
Name: ps.DNSName,
|
||||
Key: ps.PublicKey,
|
||||
}
|
||||
for _, ip := range ps.TailscaleIPs {
|
||||
n.Addresses = append(n.Addresses, netip.PrefixFrom(ip, ip.BitLen()))
|
||||
}
|
||||
if ps.AllowedIPs != nil {
|
||||
n.AllowedIPs = ps.AllowedIPs.AsSlice()
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s netmapState) peers() iter.Seq[tailcfg.NodeView] {
|
||||
return func(yield func(tailcfg.NodeView) bool) {
|
||||
if s.peersByID != nil {
|
||||
it := s.peersByID.Iterator()
|
||||
for {
|
||||
_, p, ok := it.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if !yield(p) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.peersByName != nil {
|
||||
it := s.peersByName.Iterator()
|
||||
for {
|
||||
_, p, ok := it.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if !yield(p) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -209,7 +362,7 @@ func run() error {
|
||||
|
||||
var tailscaledConfigAuthkey string
|
||||
if isOneStepConfig(cfg) {
|
||||
tailscaledConfigAuthkey = authkeyFromTailscaledConfig(cfg.TailscaledConfigFilePath)
|
||||
tailscaledConfigAuthkey = authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath)
|
||||
}
|
||||
|
||||
var kc *kubeClient
|
||||
@@ -271,7 +424,7 @@ func run() error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
log.Printf("Running healthcheck endpoint at %s/healthz", cfg.HealthCheckAddrPort)
|
||||
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, log.Printf)
|
||||
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, cfg.PodIPv6, log.Printf)
|
||||
|
||||
close := runHTTPServer(mux, cfg.HealthCheckAddrPort)
|
||||
defer close()
|
||||
@@ -287,7 +440,7 @@ func run() error {
|
||||
|
||||
if cfg.localHealthEnabled() {
|
||||
log.Printf("Running healthcheck endpoint at %s/healthz", cfg.LocalAddrPort)
|
||||
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, log.Printf)
|
||||
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, cfg.PodIPv6, log.Printf)
|
||||
}
|
||||
|
||||
if cfg.egressSvcsTerminateEPEnabled() {
|
||||
@@ -305,7 +458,7 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState|ipn.NotifyInitialHealthState|ipn.NotifyRateLimit)
|
||||
w, err := client.WatchIPNBus(bootCtx, containerbootWatchMask|ipn.NotifyInitialPrefs|ipn.NotifyInitialHealthState)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to watch tailscaled for updates: %w", err)
|
||||
}
|
||||
@@ -345,7 +498,7 @@ func run() error {
|
||||
if err := tailscaleUp(bootCtx, cfg); err != nil {
|
||||
return fmt.Errorf("failed to auth tailscale: %w", err)
|
||||
}
|
||||
w, err = client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState|ipn.NotifyRateLimit)
|
||||
w, err = client.WatchIPNBus(bootCtx, containerbootWatchMask)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rewatching tailscaled for updates after auth: %w", err)
|
||||
}
|
||||
@@ -365,8 +518,8 @@ authLoop:
|
||||
return fmt.Errorf("failed to read from tailscaled: %w", err)
|
||||
}
|
||||
|
||||
if n.State != nil {
|
||||
switch *n.State {
|
||||
if state, ok := notifyState(n); ok {
|
||||
switch state {
|
||||
case ipn.NeedsLogin:
|
||||
if isOneStepConfig(cfg) {
|
||||
// This could happen if this is the first time tailscaled was run for this
|
||||
@@ -374,7 +527,7 @@ authLoop:
|
||||
if hasKubeStateStore(cfg) {
|
||||
log.Printf("Auth key missing or invalid (NeedsLogin state), disconnecting from control and requesting new key from operator")
|
||||
|
||||
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey)
|
||||
err := kc.setAndWaitForAuthKeyReissue(ctx, client, cfg, tailscaledConfigAuthkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get a reissued authkey: %w", err)
|
||||
}
|
||||
@@ -402,7 +555,7 @@ authLoop:
|
||||
// deadline to continue monitoring for changes.
|
||||
break authLoop
|
||||
default:
|
||||
log.Printf("tailscaled in state %q, waiting", *n.State)
|
||||
log.Printf("tailscaled in state %q, waiting", state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +567,7 @@ authLoop:
|
||||
if isOneStepConfig(cfg) && hasKubeStateStore(cfg) {
|
||||
log.Printf("Auth key failed to authenticate (may be expired or single-use), disconnecting from control and requesting new key from operator")
|
||||
|
||||
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey)
|
||||
err := kc.setAndWaitForAuthKeyReissue(ctx, client, cfg, tailscaledConfigAuthkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get a reissued authkey: %w", err)
|
||||
}
|
||||
@@ -457,7 +610,7 @@ authLoop:
|
||||
}
|
||||
}
|
||||
|
||||
w, err = client.WatchIPNBus(ctx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState|ipn.NotifyRateLimit)
|
||||
w, err = client.WatchIPNBus(ctx, containerbootWatchMask)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rewatching tailscaled for updates after auth: %w", err)
|
||||
}
|
||||
@@ -536,7 +689,7 @@ authLoop:
|
||||
failedResolveAttempts++
|
||||
}
|
||||
|
||||
var egressSvcsNotify chan ipn.Notify
|
||||
var egressSvcsNotify chan netmapState
|
||||
notifyChan := make(chan ipn.Notify)
|
||||
errChan := make(chan error)
|
||||
go func() {
|
||||
@@ -550,10 +703,12 @@ authLoop:
|
||||
}
|
||||
}
|
||||
}()
|
||||
var nmState netmapState
|
||||
var wg sync.WaitGroup
|
||||
|
||||
runLoop:
|
||||
for {
|
||||
var processNetmap bool
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Although killTailscaled() is deferred earlier, if we
|
||||
@@ -567,244 +722,17 @@ runLoop:
|
||||
case err := <-cfgWatchErrChan:
|
||||
return fmt.Errorf("failed to watch tailscaled config: %w", err)
|
||||
case n := <-notifyChan:
|
||||
// TODO: (ChaosInTheCRD) Add node removed check when supported by ipn
|
||||
if n.State != nil && *n.State != ipn.Running {
|
||||
nmState = nmState.processNotify(ctx, client, n)
|
||||
if state, ok := notifyState(n); ok && state != ipn.Running {
|
||||
// Something's gone wrong and we've left the authenticated state.
|
||||
// Our container image never recovered gracefully from this, and the
|
||||
// control flow required to make it work now is hard. So, just crash
|
||||
// the container and rely on the container runtime to restart us,
|
||||
// whereupon we'll go through initial auth again.
|
||||
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", *n.State)
|
||||
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", state)
|
||||
}
|
||||
if n.NetMap != nil {
|
||||
addrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
newCurrentIPs := deephash.Hash(&addrs)
|
||||
ipsHaveChanged := newCurrentIPs != currentIPs
|
||||
|
||||
// Store device ID in a Kubernetes Secret before
|
||||
// setting up any routing rules. This ensures
|
||||
// that, for containerboot instances that are
|
||||
// Kubernetes operator proxies, the operator is
|
||||
// able to retrieve the device ID from the
|
||||
// Kubernetes Secret to clean up tailnet nodes
|
||||
// for proxies whose route setup continuously
|
||||
// fails.
|
||||
deviceID := n.NetMap.SelfNode.StableID()
|
||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceID, &deviceID) {
|
||||
if err := kc.storeDeviceID(ctx, n.NetMap.SelfNode.StableID()); err != nil {
|
||||
return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg.TailnetTargetFQDN != "" {
|
||||
egressAddrs, err := resolveTailnetFQDN(n.NetMap, cfg.TailnetTargetFQDN)
|
||||
if err != nil {
|
||||
log.Print(err.Error())
|
||||
break
|
||||
}
|
||||
|
||||
newCurentEgressIPs := deephash.Hash(&egressAddrs)
|
||||
egressIPsHaveChanged := newCurentEgressIPs != currentEgressIPs
|
||||
// The firewall rules get (re-)installed:
|
||||
// - on startup
|
||||
// - when the tailnet IPs of the tailnet target have changed
|
||||
// - when the tailnet IPs of this node have changed
|
||||
if (egressIPsHaveChanged || ipsHaveChanged) && len(egressAddrs) != 0 {
|
||||
var rulesInstalled bool
|
||||
for _, egressAddr := range egressAddrs {
|
||||
ea := egressAddr.Addr()
|
||||
if ea.Is4() || (ea.Is6() && nfr.HasIPV6NAT()) {
|
||||
rulesInstalled = true
|
||||
log.Printf("Installing forwarding rules for destination %v", ea.String())
|
||||
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
|
||||
return fmt.Errorf("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())
|
||||
}
|
||||
}
|
||||
currentEgressIPs = newCurentEgressIPs
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
if cfg.ProxyTargetDNSName != "" && len(addrs) != 0 && ipsHaveChanged {
|
||||
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
|
||||
if err != nil {
|
||||
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
|
||||
resetTimer(true)
|
||||
continue
|
||||
}
|
||||
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
|
||||
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
|
||||
}))
|
||||
if backendsHaveChanged {
|
||||
log.Printf("installing ingress proxy rules for backends %v", newBackendAddrs)
|
||||
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
|
||||
return fmt.Errorf("error installing ingress proxy rules: %w", err)
|
||||
}
|
||||
}
|
||||
resetTimer(false)
|
||||
backendAddrs = newBackendAddrs
|
||||
}
|
||||
if cfg.ServeConfigPath != "" {
|
||||
cd := certDomainFromNetmap(n.NetMap)
|
||||
if cd == "" {
|
||||
cd = kubetypes.ValueNoHTTPS
|
||||
}
|
||||
prev := certDomain.Swap(new(cd))
|
||||
if prev == nil || *prev != cd {
|
||||
select {
|
||||
case certDomainChanged <- true:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) != 0 {
|
||||
log.Printf("Installing forwarding rules for destination %v", cfg.TailnetTargetIP)
|
||||
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs, nfr); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
}
|
||||
// If this is a L7 cluster ingress proxy (set up
|
||||
// by Kubernetes operator) and proxying of
|
||||
// cluster traffic to the ingress target is
|
||||
// enabled, set up proxy rule each time the
|
||||
// tailnet IPs of this node change (including
|
||||
// the first time they become available).
|
||||
if cfg.AllowProxyingClusterTrafficViaIngress && cfg.ServeConfigPath != "" && ipsHaveChanged && len(addrs) != 0 {
|
||||
log.Printf("installing rules to forward traffic for %s to node's tailnet IP", cfg.PodIP)
|
||||
if err := installTSForwardingRuleForDestination(ctx, cfg.PodIP, addrs, nfr); err != nil {
|
||||
return fmt.Errorf("installing rules to forward traffic to node's tailnet IP: %w", err)
|
||||
}
|
||||
}
|
||||
currentIPs = newCurrentIPs
|
||||
|
||||
// Only store device FQDN and IP addresses to
|
||||
// Kubernetes Secret when any required proxy
|
||||
// route setup has succeeded. IPs and FQDN are
|
||||
// read from the Secret by the Tailscale
|
||||
// Kubernetes operator and, for some proxy
|
||||
// types, such as Tailscale Ingress, advertized
|
||||
// on the Ingress status. Writing them to the
|
||||
// Secret only after the proxy routing has been
|
||||
// set up ensures that the operator does not
|
||||
// advertize endpoints of broken proxies.
|
||||
// TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
|
||||
deviceEndpoints := []any{n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses()}
|
||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceEndpoints, &deviceEndpoints) {
|
||||
if err := 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 healthCheck != nil {
|
||||
healthCheck.Update(len(addrs) != 0)
|
||||
}
|
||||
|
||||
var prevServeConfig *ipn.ServeConfig
|
||||
if getAutoAdvertiseBool() {
|
||||
prevServeConfig, err = client.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autoadvertisement: failed to get serve config: %w", err)
|
||||
}
|
||||
|
||||
err = refreshAdvertiseServices(ctx, prevServeConfig, klc.New(client))
|
||||
if err != nil {
|
||||
return fmt.Errorf("autoadvertisement: failed to refresh advertise services: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.ServeConfigPath != "" {
|
||||
triggerWatchServeConfigChanges.Do(func() {
|
||||
go watchServeConfigChanges(ctx, certDomainChanged, certDomain, client, kc, cfg, prevServeConfig)
|
||||
})
|
||||
}
|
||||
|
||||
if egressSvcsNotify != nil {
|
||||
egressSvcsNotify <- n
|
||||
}
|
||||
}
|
||||
if !startupTasksDone {
|
||||
// For containerboot instances that act as TCP proxies (proxying traffic to an endpoint
|
||||
// passed via one of the env vars that containerboot reads) and store state in a
|
||||
// Kubernetes Secret, we consider startup tasks done at the point when device info has
|
||||
// been successfully stored to state Secret. For all other containerboot instances, if
|
||||
// we just get to this point the startup tasks can be considered done.
|
||||
if !isL3Proxy(cfg) || !hasKubeStateStore(cfg) || (currentDeviceEndpoints != deephash.Sum{} && currentDeviceID != deephash.Sum{}) {
|
||||
// This log message is used in tests to detect when all
|
||||
// post-auth configuration is done.
|
||||
log.Println("Startup complete, waiting for shutdown signal")
|
||||
startupTasksDone = true
|
||||
|
||||
// Configure egress proxy. Egress proxy will set up firewall rules to proxy
|
||||
// traffic to tailnet targets configured in the provided configuration file. It
|
||||
// will then continuously monitor the config file and netmap updates and
|
||||
// reconfigure the firewall rules as needed. If any of its operations fail, it
|
||||
// will crash this node.
|
||||
if cfg.EgressProxiesCfgPath != "" {
|
||||
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath)
|
||||
egressSvcsNotify = make(chan ipn.Notify)
|
||||
opts := egressProxyRunOpts{
|
||||
cfgPath: cfg.EgressProxiesCfgPath,
|
||||
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 {
|
||||
egressSvcsErrorChan <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
ip := ingressProxy{}
|
||||
if cfg.IngressProxiesCfgPath != "" {
|
||||
log.Printf("configuring ingress proxy using configuration file at %s", cfg.IngressProxiesCfgPath)
|
||||
opts := ingressProxyOpts{
|
||||
cfgPath: cfg.IngressProxiesCfgPath,
|
||||
nfr: nfr,
|
||||
kc: kc,
|
||||
stateSecret: cfg.KubeSecret,
|
||||
podIPv4: cfg.PodIPv4,
|
||||
podIPv6: cfg.PodIPv6,
|
||||
}
|
||||
go func() {
|
||||
if err := ip.run(ctx, opts); err != nil {
|
||||
ingressSvcsErrorChan <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait on tailscaled process. It won't be cleaned up by default when the
|
||||
// container exits as it is not PID1. TODO (irbekrm): perhaps we can replace the
|
||||
// reaper by a running cmd.Wait in a goroutine immediately after starting
|
||||
// tailscaled?
|
||||
reaper := func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
var status unix.WaitStatus
|
||||
_, err := unix.Wait4(daemonProcess.Pid, &status, 0, nil)
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("Waiting for tailscaled to exit: %v", err)
|
||||
}
|
||||
log.Print("tailscaled exited")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
wg.Add(1)
|
||||
go reaper()
|
||||
}
|
||||
if n.InitialStatus != nil || n.SelfChange != nil || len(n.PeersChanged) != 0 || len(n.PeersRemoved) != 0 || len(n.PeerChangedPatch) != 0 {
|
||||
processNetmap = true
|
||||
}
|
||||
case <-tc:
|
||||
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
|
||||
@@ -824,11 +752,253 @@ runLoop:
|
||||
}
|
||||
backendAddrs = newBackendAddrs
|
||||
resetTimer(false)
|
||||
continue
|
||||
case e := <-egressSvcsErrorChan:
|
||||
return fmt.Errorf("egress proxy failed: %v", e)
|
||||
case e := <-ingressSvcsErrorChan:
|
||||
return fmt.Errorf("ingress proxy failed: %v", e)
|
||||
}
|
||||
if !processNetmap {
|
||||
continue
|
||||
}
|
||||
self := nmState.self
|
||||
if !self.Valid() {
|
||||
continue
|
||||
}
|
||||
{
|
||||
addrs = self.Addresses().AsSlice()
|
||||
newCurrentIPs := deephash.Hash(&addrs)
|
||||
ipsHaveChanged := newCurrentIPs != currentIPs
|
||||
|
||||
// Store device ID in a Kubernetes Secret before
|
||||
// setting up any routing rules. This ensures
|
||||
// that, for containerboot instances that are
|
||||
// Kubernetes operator proxies, the operator is
|
||||
// able to retrieve the device ID from the
|
||||
// Kubernetes Secret to clean up tailnet nodes
|
||||
// for proxies whose route setup continuously
|
||||
// fails.
|
||||
deviceID := self.StableID()
|
||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceID, &deviceID) {
|
||||
if err := kc.storeDeviceID(ctx, deviceID); err != nil {
|
||||
return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg.TailnetTargetFQDN != "" {
|
||||
egressAddrs, err := resolveTailnetFQDN(nmState, cfg.TailnetTargetFQDN)
|
||||
if err != nil {
|
||||
log.Print(err.Error())
|
||||
break
|
||||
}
|
||||
|
||||
newCurentEgressIPs := deephash.Hash(&egressAddrs)
|
||||
egressIPsHaveChanged := newCurentEgressIPs != currentEgressIPs
|
||||
// The firewall rules get (re-)installed:
|
||||
// - on startup
|
||||
// - when the tailnet IPs of the tailnet target have changed
|
||||
// - when the tailnet IPs of this node have changed
|
||||
if (egressIPsHaveChanged || ipsHaveChanged) && len(egressAddrs) != 0 {
|
||||
var rulesInstalled bool
|
||||
for _, egressAddr := range egressAddrs {
|
||||
ea := egressAddr.Addr()
|
||||
if ea.Is4() || (ea.Is6() && nfr.HasIPV6NAT()) {
|
||||
rulesInstalled = true
|
||||
log.Printf("Installing forwarding rules for destination %v", ea.String())
|
||||
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
|
||||
return fmt.Errorf("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())
|
||||
}
|
||||
}
|
||||
currentEgressIPs = newCurentEgressIPs
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
if cfg.ProxyTargetDNSName != "" && len(addrs) != 0 && ipsHaveChanged {
|
||||
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
|
||||
if err != nil {
|
||||
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
|
||||
resetTimer(true)
|
||||
continue
|
||||
}
|
||||
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
|
||||
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
|
||||
}))
|
||||
if backendsHaveChanged {
|
||||
log.Printf("installing ingress proxy rules for backends %v", newBackendAddrs)
|
||||
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
|
||||
return fmt.Errorf("error installing ingress proxy rules: %w", err)
|
||||
}
|
||||
}
|
||||
resetTimer(false)
|
||||
backendAddrs = newBackendAddrs
|
||||
}
|
||||
if cfg.ServeConfigPath != "" {
|
||||
var cd string
|
||||
if nmState.certDomains.Len() != 0 {
|
||||
cd = nmState.certDomains.At(0)
|
||||
}
|
||||
if cd == "" {
|
||||
cd = kubetypes.ValueNoHTTPS
|
||||
}
|
||||
prev := certDomain.Swap(new(cd))
|
||||
if prev == nil || *prev != cd {
|
||||
select {
|
||||
case certDomainChanged <- true:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) != 0 {
|
||||
log.Printf("Installing forwarding rules for destination %v", cfg.TailnetTargetIP)
|
||||
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs, nfr); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
}
|
||||
// If this is a L7 cluster ingress proxy (set up
|
||||
// by Kubernetes operator) and proxying of
|
||||
// cluster traffic to the ingress target is
|
||||
// enabled, set up proxy rule each time the
|
||||
// tailnet IPs of this node change (including
|
||||
// the first time they become available).
|
||||
if cfg.AllowProxyingClusterTrafficViaIngress && cfg.ServeConfigPath != "" && ipsHaveChanged && len(addrs) != 0 {
|
||||
log.Printf("installing rules to forward traffic for %s to node's tailnet IP", cfg.PodIP)
|
||||
if err := installTSForwardingRuleForDestination(ctx, cfg.PodIP, addrs, nfr); err != nil {
|
||||
return fmt.Errorf("installing rules to forward traffic to node's tailnet IP: %w", err)
|
||||
}
|
||||
}
|
||||
currentIPs = newCurrentIPs
|
||||
|
||||
// Only store device FQDN and IP addresses to
|
||||
// Kubernetes Secret when any required proxy
|
||||
// route setup has succeeded. IPs and FQDN are
|
||||
// read from the Secret by the Tailscale
|
||||
// Kubernetes operator and, for some proxy
|
||||
// types, such as Tailscale Ingress, advertized
|
||||
// on the Ingress status. Writing them to the
|
||||
// Secret only after the proxy routing has been
|
||||
// set up ensures that the operator does not
|
||||
// advertize endpoints of broken proxies.
|
||||
// TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
|
||||
deviceEndpoints := []any{self.Name(), self.Addresses()}
|
||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceEndpoints, &deviceEndpoints) {
|
||||
if err := kc.storeDeviceEndpoints(ctx, self.Name(), addrs); err != nil {
|
||||
return fmt.Errorf("storing device IPs and FQDN in Kubernetes Secret: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if healthCheck != nil {
|
||||
healthCheck.Update(len(addrs) != 0)
|
||||
}
|
||||
|
||||
var prevServeConfig *ipn.ServeConfig
|
||||
if getAutoAdvertiseBool() {
|
||||
prevServeConfig, err = client.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autoadvertisement: failed to get serve config: %w", err)
|
||||
}
|
||||
|
||||
err = refreshAdvertiseServices(ctx, prevServeConfig, klc.New(client))
|
||||
if err != nil {
|
||||
return fmt.Errorf("autoadvertisement: failed to refresh advertise services: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.ServeConfigPath != "" {
|
||||
triggerWatchServeConfigChanges.Do(func() {
|
||||
go watchServeConfigChanges(ctx, certDomainChanged, certDomain, client, kc, cfg, prevServeConfig)
|
||||
})
|
||||
}
|
||||
|
||||
if egressSvcsNotify != nil {
|
||||
egressSvcsNotify <- nmState
|
||||
}
|
||||
}
|
||||
if !startupTasksDone {
|
||||
// For containerboot instances that act as TCP proxies (proxying traffic to an endpoint
|
||||
// passed via one of the env vars that containerboot reads) and store state in a
|
||||
// Kubernetes Secret, we consider startup tasks done at the point when device info has
|
||||
// been successfully stored to state Secret. For all other containerboot instances, if
|
||||
// we just get to this point the startup tasks can be considered done.
|
||||
if !isL3Proxy(cfg) || !hasKubeStateStore(cfg) || (currentDeviceEndpoints != deephash.Sum{} && currentDeviceID != deephash.Sum{}) {
|
||||
// This log message is used in tests to detect when all
|
||||
// post-auth configuration is done.
|
||||
log.Println("Startup complete, waiting for shutdown signal")
|
||||
startupTasksDone = true
|
||||
|
||||
// Configure egress proxy. Egress proxy will set up firewall rules to proxy
|
||||
// traffic to tailnet targets configured in the provided configuration file. It
|
||||
// will then continuously monitor the config file and netmap updates and
|
||||
// reconfigure the firewall rules as needed. If any of its operations fail, it
|
||||
// will crash this node.
|
||||
if cfg.EgressProxiesCfgPath != "" {
|
||||
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath)
|
||||
egressSvcsNotify = make(chan netmapState)
|
||||
opts := egressProxyRunOpts{
|
||||
cfgPath: cfg.EgressProxiesCfgPath,
|
||||
nfr: nfr,
|
||||
kc: kc,
|
||||
tsClient: client,
|
||||
stateSecret: cfg.KubeSecret,
|
||||
netmapChan: egressSvcsNotify,
|
||||
podIPv4: cfg.PodIPv4,
|
||||
podIPv6: cfg.PodIPv6,
|
||||
tailnetAddrs: addrs,
|
||||
}
|
||||
go func() {
|
||||
if err := ep.run(ctx, nmState, opts); err != nil {
|
||||
egressSvcsErrorChan <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
ip := ingressProxy{}
|
||||
if cfg.IngressProxiesCfgPath != "" {
|
||||
log.Printf("configuring ingress proxy using configuration file at %s", cfg.IngressProxiesCfgPath)
|
||||
opts := ingressProxyOpts{
|
||||
cfgPath: cfg.IngressProxiesCfgPath,
|
||||
nfr: nfr,
|
||||
kc: kc,
|
||||
stateSecret: cfg.KubeSecret,
|
||||
podIPv4: cfg.PodIPv4,
|
||||
podIPv6: cfg.PodIPv6,
|
||||
}
|
||||
go func() {
|
||||
if err := ip.run(ctx, opts); err != nil {
|
||||
ingressSvcsErrorChan <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait on tailscaled process. It won't be cleaned up by default when the
|
||||
// container exits as it is not PID1. TODO (irbekrm): perhaps we can replace the
|
||||
// reaper by a running cmd.Wait in a goroutine immediately after starting
|
||||
// tailscaled?
|
||||
reaper := func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
var status unix.WaitStatus
|
||||
_, err := unix.Wait4(daemonProcess.Pid, &status, 0, nil)
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("Waiting for tailscaled to exit: %v", err)
|
||||
}
|
||||
log.Print("tailscaled exited")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
wg.Add(1)
|
||||
go reaper()
|
||||
}
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -964,34 +1134,52 @@ func runHTTPServer(mux *http.ServeMux, addr string) (close func() error) {
|
||||
}
|
||||
|
||||
// resolveTailnetFQDN resolves a tailnet FQDN to a list of IP prefixes, which
|
||||
// can be either a peer device or a Tailscale Service.
|
||||
func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, error) {
|
||||
// can be either a peer device, a Tailscale Service, or a 4via6 synthesized
|
||||
// DNS name (e.g. "10-1-0-5-via-7.tailnet.ts.net").
|
||||
func resolveTailnetFQDN(nm netmapState, fqdn string) ([]netip.Prefix, error) {
|
||||
dnsFQDN, err := dnsname.ToFQDN(fqdn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing %q as FQDN: %w", fqdn, err)
|
||||
}
|
||||
|
||||
// Check all peer devices first.
|
||||
for _, p := range nm.Peers {
|
||||
var ret []netip.Prefix
|
||||
for p := range nm.peers() {
|
||||
if strings.EqualFold(p.Name(), dnsFQDN.WithTrailingDot()) {
|
||||
return p.Addresses().AsSlice(), nil
|
||||
ret = p.Addresses().AsSlice()
|
||||
break
|
||||
}
|
||||
}
|
||||
if ret != nil {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// If not found yet, check for a matching Tailscale Service.
|
||||
if svcIPs := serviceIPsFromNetMap(nm, dnsFQDN); len(svcIPs) != 0 {
|
||||
return svcIPs, nil
|
||||
}
|
||||
// If not found yet, check for a matching 4via6 DNS name.
|
||||
if addr, ok := kubeutils.ResolveViaDomain(dnsFQDN.WithTrailingDot()); ok {
|
||||
prefix := netip.PrefixFrom(addr, addr.BitLen())
|
||||
for nn := range nm.peers() {
|
||||
for _, allowedIP := range nn.AllowedIPs().All() {
|
||||
if allowedIP.Contains(addr) {
|
||||
return []netip.Prefix{prefix}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("resolved 4via6 address %v for %q but no peer advertises a route containing it", addr, fqdn)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("could not find Tailscale node or service %q; it either does not exist, or not reachable because of ACLs", fqdn)
|
||||
return nil, fmt.Errorf("could not find Tailscale node, service or 4via6 address %q; it either does not exist, or not reachable because of ACLs", fqdn)
|
||||
}
|
||||
|
||||
// serviceIPsFromNetMap returns all IPs of a Tailscale Service if its FQDN is
|
||||
// found in the netmap. Note that Tailscale Services are not a first-class
|
||||
// object in the netmap, so we guess based on DNS ExtraRecords and AllowedIPs.
|
||||
func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Prefix {
|
||||
func serviceIPsFromNetMap(nm netmapState, fqdn dnsname.FQDN) []netip.Prefix {
|
||||
var extraRecords []tailcfg.DNSRecord
|
||||
for _, rec := range nm.DNS.ExtraRecords {
|
||||
for _, rec := range nm.dnsExtraRecords.All() {
|
||||
recFQDN, err := dnsname.ToFQDN(rec.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -1013,7 +1201,7 @@ func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Pref
|
||||
continue
|
||||
}
|
||||
ipPrefix := netip.PrefixFrom(ip, ip.BitLen())
|
||||
for _, ps := range nm.Peers {
|
||||
for ps := range nm.peers() {
|
||||
for _, allowedIP := range ps.AllowedIPs().All() {
|
||||
if allowedIP == ipPrefix {
|
||||
prefixes = append(prefixes, ipPrefix)
|
||||
@@ -1024,11 +1212,3 @@ func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Pref
|
||||
|
||||
return prefixes
|
||||
}
|
||||
|
||||
func authkeyFromTailscaledConfig(path string) string {
|
||||
if cfg, err := conffile.Load(path); err == nil && cfg.Parsed.AuthKey != nil {
|
||||
return *cfg.Parsed.AuthKey
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
+150
-37
@@ -7,6 +7,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -32,24 +33,30 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/cmd/testwrapper/flakytest"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/net/memnet"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
const configFileAuthKey = "some-auth-key"
|
||||
|
||||
func TestContainerBoot(t *testing.T) {
|
||||
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/19380")
|
||||
boot := filepath.Join(t.TempDir(), "containerboot")
|
||||
if err := exec.Command("go", "build", "-ldflags", "-X main.testSleepDuration=1ms", "-o", boot, "tailscale.com/cmd/containerboot").Run(); err != nil {
|
||||
t.Fatalf("Building containerboot: %v", err)
|
||||
}
|
||||
egressStatus := egressSvcStatus("foo", "foo.tailnetxyz.ts.net", "100.64.0.2")
|
||||
egressStatusUpdated := egressSvcStatus("foo", "foo.tailnetxyz.ts.net", "100.64.0.3")
|
||||
|
||||
metricsURL := func(port int) string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
|
||||
@@ -103,12 +110,10 @@ func TestContainerBoot(t *testing.T) {
|
||||
}
|
||||
runningNotify := &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
}).View(),
|
||||
SelfChange: &tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
}
|
||||
type testCase struct {
|
||||
@@ -381,18 +386,16 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
}).View(),
|
||||
Peers: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("ipv6ID"),
|
||||
Name: "ipv6-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
|
||||
}).View(),
|
||||
SelfChange: &tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
PeersChanged: []*tailcfg.Node{
|
||||
{
|
||||
StableID: tailcfg.StableNodeID("ipv6ID"),
|
||||
Name: "ipv6-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -629,12 +632,10 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("newID"),
|
||||
Name: "new-name.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
}).View(),
|
||||
SelfChange: &tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("newID"),
|
||||
Name: "new-name.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
@@ -1093,18 +1094,16 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
}).View(),
|
||||
Peers: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("fooID"),
|
||||
Name: "foo.tailnetxyz.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
|
||||
}).View(),
|
||||
SelfChange: &tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
PeersChanged: []*tailcfg.Node{
|
||||
{
|
||||
StableID: tailcfg.StableNodeID("fooID"),
|
||||
Name: "foo.tailnetxyz.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1120,6 +1119,23 @@ func TestContainerBoot(t *testing.T) {
|
||||
egressSvcTerminateURL(env.localAddrPort): 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
PeersChanged: []*tailcfg.Node{{
|
||||
StableID: tailcfg.StableNodeID("fooID"),
|
||||
Name: "foo.tailnetxyz.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.3/32")},
|
||||
}},
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"egress-services": string(mustJSON(t, egressStatusUpdated)),
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "test-node.test.ts.net.",
|
||||
"device_id": "myID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
kubetypes.KeyCapVer: capver,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -1274,6 +1290,12 @@ func TestContainerBoot(t *testing.T) {
|
||||
t.Fatalf("phase %d: updating mtime for %q: %v", i, path, err)
|
||||
}
|
||||
}
|
||||
if p.Notify != nil && p.Notify.InitialStatus == nil {
|
||||
// Shallow-copy before mutating to avoid a race with
|
||||
// parallel subtests that share the same *ipn.Notify.
|
||||
p.Notify = new(*p.Notify)
|
||||
p.Notify.InitialStatus = statusFromNotify(p.Notify)
|
||||
}
|
||||
env.lapi.Notify(p.Notify)
|
||||
if p.Signal != nil {
|
||||
cmd.Process.Signal(*p.Signal)
|
||||
@@ -1502,6 +1524,43 @@ func (lc *localAPI) Notify(n *ipn.Notify) {
|
||||
lc.cond.Broadcast()
|
||||
}
|
||||
|
||||
func statusFromNotify(n *ipn.Notify) *ipnstate.Status {
|
||||
st := new(ipnstate.Status)
|
||||
if n.State != nil {
|
||||
st.BackendState = n.State.String()
|
||||
}
|
||||
if n.SelfChange != nil {
|
||||
st.Self = peerStatusFromNode(n.SelfChange.View())
|
||||
}
|
||||
if len(n.PeersChanged) != 0 {
|
||||
st.Peer = map[key.NodePublic]*ipnstate.PeerStatus{}
|
||||
for _, p := range n.PeersChanged {
|
||||
pv := p.View()
|
||||
st.Peer[pv.Key()] = peerStatusFromNode(pv)
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func peerStatusFromNode(n tailcfg.NodeView) *ipnstate.PeerStatus {
|
||||
ps := &ipnstate.PeerStatus{
|
||||
ID: n.StableID(),
|
||||
NodeID: n.ID(),
|
||||
PublicKey: n.Key(),
|
||||
DNSName: n.Name(),
|
||||
}
|
||||
for _, p := range n.Addresses().All() {
|
||||
if p.IsSingleIP() {
|
||||
ps.TailscaleIPs = append(ps.TailscaleIPs, p.Addr())
|
||||
}
|
||||
}
|
||||
if n.AllowedIPs().Len() != 0 {
|
||||
v := n.AllowedIPs()
|
||||
ps.AllowedIPs = &v
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
func (lc *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/localapi/v0/serve-config":
|
||||
@@ -1889,3 +1948,57 @@ func newTestEnv(t *testing.T) testEnv {
|
||||
healthAddrPort: healthAddrPort,
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessNotifyRefreshesDNSOnSelfChange verifies that a SelfChange
|
||||
// notification triggers a DNS refresh; without it, VIPServices created
|
||||
// after pod boot are invisible to resolveTailnetFQDN.
|
||||
func TestProcessNotifyRefreshesDNSOnSelfChange(t *testing.T) {
|
||||
extraRec := tailcfg.DNSRecord{
|
||||
Name: "my-ingress.tailnet.ts.net.",
|
||||
Type: "A",
|
||||
Value: "100.99.10.20",
|
||||
}
|
||||
dnsCfg := &tailcfg.DNSConfig{
|
||||
ExtraRecords: []tailcfg.DNSRecord{extraRec},
|
||||
CertDomains: []string{"node.tailnet.ts.net"},
|
||||
}
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/localapi/v0/dns-config", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(dnsCfg); err != nil {
|
||||
t.Errorf("encoding dns config: %v", err)
|
||||
}
|
||||
})
|
||||
srv := &http.Server{Handler: mux}
|
||||
go srv.Serve(lal)
|
||||
t.Cleanup(func() { srv.Shutdown(context.Background()) })
|
||||
|
||||
client := &local.Client{Dial: lal.Dial}
|
||||
|
||||
// Empty starting state, as if the InitialStatus captured at pod
|
||||
// boot carried no ExtraRecords because the VIPService didn't exist
|
||||
// yet at that time.
|
||||
var s netmapState
|
||||
|
||||
n := ipn.Notify{
|
||||
SelfChange: &tailcfg.Node{
|
||||
ID: 1,
|
||||
Name: "self.tailnet.ts.net.",
|
||||
},
|
||||
}
|
||||
|
||||
got := s.processNotify(context.Background(), client, n)
|
||||
|
||||
if got.dnsExtraRecords.Len() != 1 {
|
||||
t.Fatalf("dnsExtraRecords.Len() = %d, want 1", got.dnsExtraRecords.Len())
|
||||
}
|
||||
if rec := got.dnsExtraRecords.At(0); rec.Name != extraRec.Name {
|
||||
t.Errorf("dnsExtraRecords[0].Name = %q, want %q", rec.Name, extraRec.Name)
|
||||
}
|
||||
if got.certDomains.Len() != 1 || got.certDomains.At(0) != "node.tailnet.ts.net" {
|
||||
t.Errorf("certDomains = %v, want [node.tailnet.ts.net]", got.certDomains.AsSlice())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"tailscale.com/kube/kubetypes"
|
||||
klc "tailscale.com/kube/localclient"
|
||||
"tailscale.com/kube/services"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
// watchServeConfigChanges watches path for changes, and when it sees one, reads
|
||||
@@ -142,13 +141,6 @@ func refreshAdvertiseServices(ctx context.Context, sc *ipn.ServeConfig, lc klc.L
|
||||
return nil
|
||||
}
|
||||
|
||||
func certDomainFromNetmap(nm *netmap.NetworkMap) string {
|
||||
if len(nm.DNS.CertDomains) == 0 {
|
||||
return ""
|
||||
}
|
||||
return nm.DNS.CertDomains[0]
|
||||
}
|
||||
|
||||
func updateServeConfig(ctx context.Context, sc *ipn.ServeConfig, certDomain string, lc klc.LocalClient) error {
|
||||
if !isValidHTTPSConfig(certDomain, sc) {
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/util/def"
|
||||
)
|
||||
|
||||
// settings is all the configuration for containerboot.
|
||||
@@ -89,47 +91,50 @@ type settings struct {
|
||||
|
||||
func configFromEnv() (*settings, error) {
|
||||
cfg := &settings{
|
||||
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
|
||||
ClientID: defaultEnv("TS_CLIENT_ID", ""),
|
||||
ClientSecret: defaultEnv("TS_CLIENT_SECRET", ""),
|
||||
IDToken: defaultEnv("TS_ID_TOKEN", ""),
|
||||
Audience: defaultEnv("TS_AUDIENCE", ""),
|
||||
Hostname: defaultEnv("TS_HOSTNAME", ""),
|
||||
AuthKey: cmp.Or(os.Getenv("TS_AUTHKEY"), os.Getenv("TS_AUTH_KEY")),
|
||||
ClientID: os.Getenv("TS_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("TS_CLIENT_SECRET"),
|
||||
IDToken: os.Getenv("TS_ID_TOKEN"),
|
||||
Audience: os.Getenv("TS_AUDIENCE"),
|
||||
Hostname: os.Getenv("TS_HOSTNAME"),
|
||||
Routes: defaultEnvStringPointer("TS_ROUTES"),
|
||||
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
|
||||
ProxyTargetIP: defaultEnv("TS_DEST_IP", ""),
|
||||
ProxyTargetDNSName: defaultEnv("TS_EXPERIMENTAL_DEST_DNS_NAME", ""),
|
||||
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
|
||||
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
|
||||
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
|
||||
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
|
||||
ServeConfigPath: os.Getenv("TS_SERVE_CONFIG"),
|
||||
ProxyTargetIP: os.Getenv("TS_DEST_IP"),
|
||||
ProxyTargetDNSName: os.Getenv("TS_EXPERIMENTAL_DEST_DNS_NAME"),
|
||||
TailnetTargetIP: os.Getenv("TS_TAILNET_TARGET_IP"),
|
||||
TailnetTargetFQDN: os.Getenv("TS_TAILNET_TARGET_FQDN"),
|
||||
DaemonExtraArgs: os.Getenv("TS_TAILSCALED_EXTRA_ARGS"),
|
||||
ExtraArgs: os.Getenv("TS_EXTRA_ARGS"),
|
||||
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
|
||||
UserspaceMode: defaultBool("TS_USERSPACE", true),
|
||||
StateDir: defaultEnv("TS_STATE_DIR", ""),
|
||||
UserspaceMode: def.Bool(os.Getenv("TS_USERSPACE"), true),
|
||||
StateDir: os.Getenv("TS_STATE_DIR"),
|
||||
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
|
||||
KubeSecret: func() string {
|
||||
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
|
||||
return defaultEnv("TS_KUBE_SECRET", "tailscale")
|
||||
if os.Getenv("KUBERNETES_SERVICE_HOST") == "" {
|
||||
return os.Getenv("TS_KUBE_SECRET")
|
||||
}
|
||||
return defaultEnv("TS_KUBE_SECRET", "")
|
||||
// An explicitly empty TS_KUBE_SECRET disables Secret storage, so
|
||||
// unset and empty must stay distinguishable: def.LookupEnv keeps
|
||||
// an explicit "" rather than falling back to the default.
|
||||
return def.LookupEnv("TS_KUBE_SECRET", "tailscale")
|
||||
}(),
|
||||
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
|
||||
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
|
||||
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
SOCKSProxyAddr: os.Getenv("TS_SOCKS5_SERVER"),
|
||||
HTTPProxyAddr: os.Getenv("TS_OUTBOUND_HTTP_PROXY_LISTEN"),
|
||||
Socket: cmp.Or(os.Getenv("TS_SOCKET"), "/tmp/tailscaled.sock"),
|
||||
AuthOnce: def.Bool(os.Getenv("TS_AUTH_ONCE"), false),
|
||||
Root: cmp.Or(os.Getenv("TS_TEST_ONLY_ROOT"), "/"),
|
||||
TailscaledConfigFilePath: tailscaledConfigFilePath(),
|
||||
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
|
||||
PodIP: defaultEnv("POD_IP", ""),
|
||||
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
|
||||
HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""),
|
||||
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", ""),
|
||||
IngressProxiesCfgPath: defaultEnv("TS_INGRESS_PROXIES_CONFIG_PATH", ""),
|
||||
PodUID: defaultEnv("POD_UID", ""),
|
||||
AllowProxyingClusterTrafficViaIngress: def.Bool(os.Getenv("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS"), false),
|
||||
PodIP: os.Getenv("POD_IP"),
|
||||
EnableForwardingOptimizations: def.Bool(os.Getenv("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS"), false),
|
||||
HealthCheckAddrPort: os.Getenv("TS_HEALTHCHECK_ADDR_PORT"),
|
||||
LocalAddrPort: cmp.Or(os.Getenv("TS_LOCAL_ADDR_PORT"), "[::]:9002"),
|
||||
MetricsEnabled: def.Bool(os.Getenv("TS_ENABLE_METRICS"), false),
|
||||
HealthCheckEnabled: def.Bool(os.Getenv("TS_ENABLE_HEALTH_CHECK"), false),
|
||||
DebugAddrPort: os.Getenv("TS_DEBUG_ADDR_PORT"),
|
||||
EgressProxiesCfgPath: os.Getenv("TS_EGRESS_PROXIES_CONFIG_PATH"),
|
||||
IngressProxiesCfgPath: os.Getenv("TS_INGRESS_PROXIES_CONFIG_PATH"),
|
||||
PodUID: os.Getenv("POD_UID"),
|
||||
}
|
||||
|
||||
podIPs, ok := os.LookupEnv("POD_IPS")
|
||||
@@ -153,7 +158,7 @@ func configFromEnv() (*settings, error) {
|
||||
|
||||
// If cert share is enabled, set the replica as read or write. Only 0th
|
||||
// replica should be able to write.
|
||||
isInCertShareMode := defaultBool("TS_EXPERIMENTAL_CERT_SHARE", false)
|
||||
isInCertShareMode := def.Bool(os.Getenv("TS_EXPERIMENTAL_CERT_SHARE"), false)
|
||||
if isInCertShareMode {
|
||||
cfg.CertShareMode = "ro"
|
||||
podName := os.Getenv("POD_NAME")
|
||||
@@ -454,15 +459,6 @@ 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 {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
|
||||
// defaultEnvStringPointer returns a pointer to the given envvar value if set, else
|
||||
// returns nil. This is useful in cases where we need to distinguish between a
|
||||
// variable being set to empty string vs unset.
|
||||
@@ -484,23 +480,3 @@ func defaultEnvBoolPointer(name string) *bool {
|
||||
}
|
||||
return &ret
|
||||
}
|
||||
|
||||
func defaultEnvs(names []string, defVal string) string {
|
||||
for _, name := range names {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
|
||||
// defaultBool returns the boolean value of the given envvar name, or
|
||||
// defVal if unset or not a bool.
|
||||
func defaultBool(name string, defVal bool) bool {
|
||||
v := os.Getenv(name)
|
||||
ret, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return defVal
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package main
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -228,6 +229,78 @@ func TestValidateAuthMethods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromEnvEmptyDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
env string
|
||||
get func(*settings) string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
env: "TS_SOCKET",
|
||||
get: func(c *settings) string { return c.Socket },
|
||||
want: "/tmp/tailscaled.sock",
|
||||
},
|
||||
{
|
||||
env: "TS_LOCAL_ADDR_PORT",
|
||||
get: func(c *settings) string { return c.LocalAddrPort },
|
||||
want: "[::]:9002",
|
||||
},
|
||||
{
|
||||
env: "TS_TEST_ONLY_ROOT",
|
||||
get: func(c *settings) string { return c.Root },
|
||||
want: "/",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.env, func(t *testing.T) {
|
||||
t.Setenv(tt.env, "")
|
||||
cfg, err := configFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := tt.get(cfg); got != tt.want {
|
||||
t.Errorf(`%s set to empty "": got %q, want default %q`, tt.env, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromEnvKubeSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inKubernetes bool
|
||||
unset bool
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{name: "in_kubernetes_unset", inKubernetes: true, unset: true, want: "tailscale"},
|
||||
{name: "in_kubernetes_empty", inKubernetes: true, value: "", want: ""},
|
||||
{name: "in_kubernetes_set", inKubernetes: true, value: "custom", want: "custom"},
|
||||
{name: "not_in_kubernetes_unset", inKubernetes: false, unset: true, want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// t.Setenv registers a t.Cleanup to restore the original value, so
|
||||
// route the unset cases through it rather than a bare os.Unsetenv.
|
||||
t.Setenv("KUBERNETES_SERVICE_HOST", "10.96.0.1")
|
||||
if !tt.inKubernetes {
|
||||
os.Unsetenv("KUBERNETES_SERVICE_HOST")
|
||||
}
|
||||
t.Setenv("TS_KUBE_SECRET", tt.value)
|
||||
if tt.unset {
|
||||
os.Unsetenv("TS_KUBE_SECRET")
|
||||
}
|
||||
cfg, err := configFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.KubeSecret != tt.want {
|
||||
t.Errorf("KubeSecret = %q, want %q", cfg.KubeSecret, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlesKubeIPV6(t *testing.T) {
|
||||
t.Setenv("TS_LOCAL_ADDR_PORT", "fd7a:115c:a1e0::6c34:352:9002")
|
||||
t.Setenv("POD_IPS", "fd7a:115c:a1e0::6c34:352")
|
||||
|
||||
@@ -150,7 +150,15 @@ func tailscaleUp(ctx context.Context, cfg *settings) error {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tailscale up failed: %v", err)
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
// A canceled context kills the command, and cmd.Run can
|
||||
// report the subprocess's death ("signal: killed") rather
|
||||
// than the context error that caused it. Return the
|
||||
// context error so that callers (and ultimately main) can
|
||||
// recognize a graceful shutdown with errors.Is.
|
||||
return fmt.Errorf("tailscale up failed: %w", ctxErr)
|
||||
}
|
||||
return fmt.Errorf("tailscale up failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -180,7 +188,11 @@ func tailscaleSet(ctx context.Context, cfg *settings) error {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tailscale set failed: %v", err)
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
// See the equivalent check in tailscaleUp.
|
||||
return fmt.Errorf("tailscale set failed: %w", ctxErr)
|
||||
}
|
||||
return fmt.Errorf("tailscale set failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -41,8 +41,28 @@ func (b *bitbucketResponseWriter) Write(p []byte) (int, error) { return len(p),
|
||||
|
||||
func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {}
|
||||
|
||||
// setDNSCache sets the published DNS cache for tests.
|
||||
func setDNSCache(tb testing.TB, m *dnsEntryMap) {
|
||||
tb.Helper()
|
||||
j, err := json.Marshal(m.IPs)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
tstest.AssertNotParallel(tb)
|
||||
dnsCache.Store(m)
|
||||
dnsCacheBytes.Store(j)
|
||||
tb.Cleanup(func() {
|
||||
dnsCache.Store(nil)
|
||||
dnsCacheBytes.Store(nil)
|
||||
})
|
||||
}
|
||||
|
||||
func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP {
|
||||
t.Helper()
|
||||
tstest.AssertNotParallel(t)
|
||||
if dnsCache.Load() == nil {
|
||||
t.Fatal("dnsCache not initialized; call setDNSCache before getBootstrapDNS")
|
||||
}
|
||||
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleBootstrapDNS(w, req)
|
||||
@@ -100,7 +120,8 @@ func TestUnpublishedDNS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func resetMetrics() {
|
||||
func resetMetrics(tb testing.TB) {
|
||||
tstest.AssertNotParallel(tb)
|
||||
publishedDNSHits.Set(0)
|
||||
publishedDNSMisses.Set(0)
|
||||
unpublishedDNSHits.Set(0)
|
||||
@@ -114,8 +135,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
pub := &dnsEntryMap{
|
||||
IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}},
|
||||
}
|
||||
dnsCache.Store(pub)
|
||||
dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`))
|
||||
setDNSCache(t, pub)
|
||||
|
||||
unpublishedDNSCache.Store(&dnsEntryMap{
|
||||
IPs: map[string][]net.IP{
|
||||
@@ -131,7 +151,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
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"} {
|
||||
resetMetrics()
|
||||
resetMetrics(t)
|
||||
ips := getBootstrapDNS(t, q)
|
||||
|
||||
// Expected our public map to be returned on a cache miss
|
||||
@@ -149,7 +169,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
|
||||
// Verify that we do get a valid response and metric.
|
||||
t.Run("CacheHit", func(t *testing.T) {
|
||||
resetMetrics()
|
||||
resetMetrics(t)
|
||||
ips := getBootstrapDNS(t, "controlplane.tailscale.com")
|
||||
want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
|
||||
if !reflect.DeepEqual(ips, want) {
|
||||
@@ -166,8 +186,10 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLookupMetric(t *testing.T) {
|
||||
setDNSCache(t, &dnsEntryMap{})
|
||||
|
||||
d := []string{"a.io", "b.io", "c.io", "d.io", "e.io", "e.io", "e.io", "a.io"}
|
||||
resetMetrics()
|
||||
resetMetrics(t)
|
||||
for _, q := range d {
|
||||
_ = getBootstrapDNS(t, q)
|
||||
}
|
||||
|
||||
+27
-11
@@ -23,6 +23,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
@@ -35,21 +36,34 @@ var unsafeHostnameCharacters = regexp.MustCompile(`[^a-zA-Z0-9-\.]`)
|
||||
type certProvider interface {
|
||||
// TLSConfig creates a new TLS config suitable for net/http.Server servers.
|
||||
//
|
||||
// The returned Config must have a GetCertificate function set and that
|
||||
// function must return a unique *tls.Certificate for each call. The
|
||||
// returned *tls.Certificate will be mutated by the caller to append to the
|
||||
// (*tls.Certificate).Certificate field.
|
||||
// The returned Config must have a GetCertificate function set. The
|
||||
// *tls.Certificate values it returns may be shared and cached, so
|
||||
// callers must not mutate them.
|
||||
TLSConfig() *tls.Config
|
||||
// HTTPHandler handle ACME related request, if any.
|
||||
HTTPHandler(fallback http.Handler) http.Handler
|
||||
}
|
||||
|
||||
func certProviderByCertMode(mode, dir, hostname, eabKID, eabKey, email string) (certProvider, error) {
|
||||
func certProviderByCertMode(mode, dir, hostname string, ipCerts bool, eabKID, eabKey, email string) (certProvider, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("missing required --certdir flag")
|
||||
}
|
||||
if ipCerts && mode != "letsencrypt" {
|
||||
return nil, errors.New("--acme-ip-certs requires --certmode=letsencrypt")
|
||||
}
|
||||
switch mode {
|
||||
case "letsencrypt", "gcp":
|
||||
if net.ParseIP(hostname) != nil {
|
||||
if mode == "gcp" {
|
||||
return nil, errors.New("--certmode=gcp requires --hostname to be a DNS name, not an IP address")
|
||||
}
|
||||
if !ipCerts {
|
||||
return nil, errors.New("--hostname is an IP address; use --certmode=manual for a self-signed cert, or set --acme-ip-certs to get LetsEncrypt IP address certs")
|
||||
}
|
||||
// IP-only server: certs are issued on demand per
|
||||
// connection, so there is no hostname cert provider.
|
||||
return newIPCertManager(dir, email, "", nil)
|
||||
}
|
||||
certManager := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(hostname),
|
||||
@@ -82,6 +96,9 @@ func certProviderByCertMode(mode, dir, hostname, eabKID, eabKey, email string) (
|
||||
} else if hostname == "derp.tailscale.com" {
|
||||
certManager.Email = "security@tailscale.com"
|
||||
}
|
||||
if ipCerts {
|
||||
return newIPCertManager(dir, email, "", certManager)
|
||||
}
|
||||
return certManager, nil
|
||||
case "manual":
|
||||
return NewManualCertManager(dir, hostname)
|
||||
@@ -157,12 +174,11 @@ func (m *manualCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certif
|
||||
return nil, fmt.Errorf("cert mismatch with hostname: %q", hi.ServerName)
|
||||
}
|
||||
|
||||
// Return a shallow copy of the cert so the caller can append to its
|
||||
// Certificate field.
|
||||
certCopy := new(tls.Certificate)
|
||||
*certCopy = *m.cert
|
||||
certCopy.Certificate = certCopy.Certificate[:len(certCopy.Certificate):len(certCopy.Certificate)]
|
||||
return certCopy, nil
|
||||
// Return a shallow copy of the cert with a capacity-clamped chain
|
||||
// so callers can never mutate the manager's long-lived certificate.
|
||||
certCopy := *m.cert
|
||||
certCopy.Certificate = slices.Clip(certCopy.Certificate)
|
||||
return &certCopy, nil
|
||||
}
|
||||
|
||||
func (m *manualCertManager) HTTPHandler(fallback http.Handler) http.Handler {
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestCertIP(t *testing.T) {
|
||||
t.Fatalf("Error closing key.pem: %v", err)
|
||||
}
|
||||
|
||||
cp, err := certProviderByCertMode("manual", dir, hostname, "", "", "")
|
||||
cp, err := certProviderByCertMode("manual", dir, hostname, false, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -174,25 +174,25 @@ func TestGCPCertMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Missing EAB credentials
|
||||
_, err := certProviderByCertMode("gcp", dir, "test.example.com", "", "", "test@example.com")
|
||||
_, err := certProviderByCertMode("gcp", dir, "test.example.com", false, "", "", "test@example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when EAB credentials are missing")
|
||||
}
|
||||
|
||||
// Missing email
|
||||
_, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk", "")
|
||||
_, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when email is missing")
|
||||
}
|
||||
|
||||
// Invalid base64
|
||||
_, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "not-valid!", "test@example.com")
|
||||
_, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "not-valid!", "test@example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid base64")
|
||||
}
|
||||
|
||||
// Valid base64url (no padding)
|
||||
cp, err := certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk", "test@example.com")
|
||||
cp, err := certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("base64url: %v", err)
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func TestGCPCertMode(t *testing.T) {
|
||||
}
|
||||
|
||||
// Valid standard base64 (with padding, gcloud format)
|
||||
cp, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk=", "test@example.com")
|
||||
cp, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk=", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("base64: %v", err)
|
||||
}
|
||||
|
||||
+23
-17
@@ -6,10 +6,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
github.com/axiomhq/hyperloglog from tailscale.com/derp/derpserver
|
||||
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
|
||||
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
|
||||
github.com/coder/websocket from tailscale.com/cmd/derper+
|
||||
github.com/coder/websocket from tailscale.com/derp/derpserver+
|
||||
github.com/coder/websocket/internal/errd from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/util from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
|
||||
github.com/creachadair/msync/throttle from github.com/tailscale/setec/client/setec
|
||||
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
|
||||
github.com/dgryski/go-metro from github.com/axiomhq/hyperloglog
|
||||
@@ -20,6 +19,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck+
|
||||
💣 github.com/go4org/hashtriemap from tailscale.com/derp/derpserver
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
||||
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
|
||||
@@ -90,6 +91,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/envknob from tailscale.com/client/local+
|
||||
tailscale.com/feature from tailscale.com/tsweb+
|
||||
tailscale.com/feature/buildfeatures from tailscale.com/feature+
|
||||
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
|
||||
tailscale.com/health from tailscale.com/net/tlsdial+
|
||||
tailscale.com/hostinfo from tailscale.com/net/netmon+
|
||||
tailscale.com/ipn from tailscale.com/client/local
|
||||
@@ -106,19 +108,23 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
💣 tailscale.com/net/netns from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/netutil from tailscale.com/client/local
|
||||
tailscale.com/net/netx from tailscale.com/net/dnscache+
|
||||
tailscale.com/net/routecheck from tailscale.com/client/local
|
||||
tailscale.com/net/routecheck/peernode from tailscale.com/net/routecheck
|
||||
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/stun from tailscale.com/net/stunserver
|
||||
tailscale.com/net/stunserver from tailscale.com/cmd/derper
|
||||
L tailscale.com/net/tcpinfo from tailscale.com/derp/derpserver
|
||||
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
||||
tailscale.com/net/traffic from tailscale.com/net/routecheck
|
||||
tailscale.com/net/tsaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/udprelay/status from tailscale.com/client/local
|
||||
tailscale.com/net/wsconn from tailscale.com/cmd/derper
|
||||
tailscale.com/net/wsconn from tailscale.com/derp/derpserver
|
||||
tailscale.com/paths from tailscale.com/client/local
|
||||
💣 tailscale.com/safesocket from tailscale.com/client/local
|
||||
tailscale.com/syncs from tailscale.com/cmd/derper+
|
||||
tailscale.com/tailcfg from tailscale.com/client/local+
|
||||
tailscale.com/tempfork/acme from tailscale.com/cmd/derper
|
||||
tailscale.com/tka from tailscale.com/client/local+
|
||||
tailscale.com/tsconst from tailscale.com/net/netmon+
|
||||
tailscale.com/tstime from tailscale.com/derp+
|
||||
@@ -134,7 +140,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/types/key from tailscale.com/client/local+
|
||||
tailscale.com/types/lazy from tailscale.com/version+
|
||||
tailscale.com/types/logger from tailscale.com/cmd/derper+
|
||||
tailscale.com/types/netmap from tailscale.com/ipn
|
||||
tailscale.com/types/netmap from tailscale.com/ipn+
|
||||
tailscale.com/types/opt from tailscale.com/envknob+
|
||||
tailscale.com/types/persist from tailscale.com/ipn+
|
||||
tailscale.com/types/preftype from tailscale.com/ipn
|
||||
@@ -163,7 +169,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/util/syspolicy/pkey from tailscale.com/ipn+
|
||||
tailscale.com/util/syspolicy/policyclient from tailscale.com/ipn
|
||||
tailscale.com/util/syspolicy/ptype from tailscale.com/util/syspolicy/policyclient+
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/client/local
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/client/local+
|
||||
tailscale.com/util/testenv from tailscale.com/net/bakedroots+
|
||||
tailscale.com/util/usermetric from tailscale.com/health
|
||||
tailscale.com/util/vizerror from tailscale.com/tailcfg+
|
||||
@@ -243,22 +249,22 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
crypto/internal/boring/bbig from crypto/ecdsa+
|
||||
crypto/internal/boring/sig from crypto/internal/boring
|
||||
crypto/internal/constanttime from crypto/internal/fips140/edwards25519+
|
||||
crypto/internal/fips140 from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140 from crypto/fips140+
|
||||
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/check from crypto/fips140+
|
||||
crypto/internal/fips140/drbg from crypto/hpke+
|
||||
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/hkdf from crypto/hkdf+
|
||||
crypto/internal/fips140/hmac from crypto/hmac+
|
||||
crypto/internal/fips140/mlkem from crypto/mlkem
|
||||
crypto/internal/fips140/nistec from crypto/elliptic+
|
||||
crypto/internal/fips140/nistec from crypto/ecdsa+
|
||||
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+
|
||||
@@ -309,8 +315,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
go/token from google.golang.org/protobuf/internal/strs
|
||||
hash from crypto+
|
||||
hash/crc32 from compress/gzip+
|
||||
hash/fnv from google.golang.org/protobuf/internal/detrand
|
||||
hash/maphash from go4.org/mem
|
||||
hash/fnv from google.golang.org/protobuf/internal/detrand+
|
||||
hash/maphash from go4.org/mem+
|
||||
html from net/http/pprof+
|
||||
html/template from tailscale.com/cmd/derper+
|
||||
internal/abi from crypto/x509/internal/macos+
|
||||
@@ -324,13 +330,13 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
internal/filepathlite from os+
|
||||
internal/fmtsort from fmt+
|
||||
internal/goarch from crypto/internal/fips140deps/cpu+
|
||||
internal/godebug from crypto/internal/fips140deps/godebug+
|
||||
internal/godebug from crypto/ed25519+
|
||||
internal/godebugs from internal/godebug+
|
||||
internal/goexperiment from net/http/pprof+
|
||||
internal/goos from crypto/x509+
|
||||
internal/msan from internal/runtime/maps+
|
||||
internal/nettrace from net+
|
||||
internal/oserror from io/fs+
|
||||
internal/oserror from internal/syscall/windows+
|
||||
internal/poll from net+
|
||||
internal/profile from net/http/pprof
|
||||
internal/profilerecord from runtime+
|
||||
@@ -340,9 +346,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
internal/runtime/atomic from internal/runtime/exithook+
|
||||
L internal/runtime/cgroup from runtime
|
||||
internal/runtime/exithook from runtime
|
||||
internal/runtime/gc from runtime+
|
||||
internal/runtime/gc from internal/runtime/gc/scan+
|
||||
internal/runtime/gc/scan from runtime
|
||||
internal/runtime/maps from reflect+
|
||||
internal/runtime/maps from hash/maphash+
|
||||
internal/runtime/math from internal/runtime/maps+
|
||||
internal/runtime/pprof/label from runtime+
|
||||
internal/runtime/sys from crypto/subtle+
|
||||
@@ -356,7 +362,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
internal/synctest 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 from crypto/internal/fips140deps/time+
|
||||
W internal/syscall/windows/registry from mime+
|
||||
W internal/syscall/windows/sysdll from internal/syscall/windows+
|
||||
internal/testlog from os
|
||||
|
||||
+32
-20
@@ -62,10 +62,11 @@ var (
|
||||
configPath = flag.String("c", "", "config file path")
|
||||
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt, gcp")
|
||||
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store ACME (e.g. LetsEncrypt) certs, if addr's port is :443")
|
||||
hostname = flag.String("hostname", "derp.tailscale.com", "TLS host name for certs, 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", "TLS host name for certs, if addr's port is :443. It can be an IP address when --certmode=manual (to avoid SNI checks) or when --acme-ip-certs is set (to run an IP-only server with no hostname cert)")
|
||||
acmeEABKid = flag.String("acme-eab-kid", "", "ACME External Account Binding (EAB) Key ID (required for --certmode=gcp)")
|
||||
acmeEABKey = flag.String("acme-eab-key", "", "ACME External Account Binding (EAB) HMAC key, base64-encoded (required for --certmode=gcp)")
|
||||
acmeEmail = flag.String("acme-email", "", "ACME account contact email address (required for --certmode=gcp, optional for letsencrypt)")
|
||||
acmeIPCerts = flag.Bool("acme-ip-certs", false, "whether to serve LetsEncrypt certs for the server's IP addresses: when a client connects by IP address (sending no TLS SNI, or an IP address SNI matching the connection's destination IP), get and serve a LetsEncrypt cert for that IP, using the short-lived (~6 day) ACME certificate profile. This works for both IPv4 and IPv6 with no per-address configuration. It requires --certmode=letsencrypt and the ACME server must be able to reach port 80 at each such IP for the HTTP-01 challenge.")
|
||||
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")
|
||||
@@ -87,8 +88,7 @@ var (
|
||||
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")
|
||||
|
||||
perClientRateLimit = flag.Uint("per-client-rate-limit", 0, "per-client receive rate limit in bytes/sec; 0 means unlimited. Mesh peers are exempt.")
|
||||
perClientRateBurst = flag.Uint("per-client-rate-burst", 0, "per-client receive rate burst in bytes; 0 defaults to 2x the rate limit (only relevant when using nonzero --per-client-rate-limit)")
|
||||
rateConfigPath = flag.String("rate-config", "", "if non-empty, path to JSON rate limit config file. Rate limiting is experimental and subject to change. Configuration is reloaded on SIGHUP.")
|
||||
|
||||
// tcpKeepAlive is intentionally long, to reduce battery cost. There is an L7 keepalive on a higher frequency schedule.
|
||||
tcpKeepAlive = flag.Duration("tcp-keepalive-time", 10*time.Minute, "TCP keepalive time")
|
||||
@@ -195,12 +195,11 @@ func main() {
|
||||
s.SetVerifyClientURL(*verifyClientURL)
|
||||
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
|
||||
s.SetTCPWriteTimeout(*tcpWriteTimeout)
|
||||
if *perClientRateLimit > 0 {
|
||||
burst := *perClientRateBurst
|
||||
if burst < 1 {
|
||||
burst = *perClientRateLimit * 2
|
||||
if *rateConfigPath != "" {
|
||||
if err := s.LoadAndApplyRateConfig(*rateConfigPath); err != nil {
|
||||
log.Fatalf("derper: loading rate config: %v", err)
|
||||
}
|
||||
s.SetPerClientRateLimit(*perClientRateLimit, burst)
|
||||
go watchRateConfig(ctx, s, *rateConfigPath)
|
||||
}
|
||||
|
||||
var meshKey string
|
||||
@@ -254,7 +253,7 @@ func main() {
|
||||
if err := startMesh(s); err != nil {
|
||||
log.Fatalf("startMesh: %v", err)
|
||||
}
|
||||
expvar.Publish("derp", s.ExpVar())
|
||||
expvar.Publish("derp", s.ExpVar(*rateConfigPath != ""))
|
||||
|
||||
handleHome, ok := getHomeHandler(*flagHome)
|
||||
if !ok {
|
||||
@@ -264,7 +263,7 @@ func main() {
|
||||
mux := http.NewServeMux()
|
||||
if *runDERP {
|
||||
derpHandler := derpserver.Handler(s)
|
||||
derpHandler = addWebSocketSupport(s, derpHandler)
|
||||
derpHandler = derpserver.AddWebSocketSupport(s, derpHandler)
|
||||
mux.Handle("/derp", derpHandler)
|
||||
} else {
|
||||
mux.Handle("/derp", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -351,20 +350,12 @@ func main() {
|
||||
if serveTLS {
|
||||
log.Printf("derper: serving on %s with TLS", *addr)
|
||||
var certManager certProvider
|
||||
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeEABKid, *acmeEABKey, *acmeEmail)
|
||||
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeIPCerts, *acmeEABKid, *acmeEABKey, *acmeEmail)
|
||||
if err != nil {
|
||||
log.Fatalf("derper: can not start cert provider: %v", err)
|
||||
}
|
||||
httpsrv.TLSConfig = certManager.TLSConfig()
|
||||
getCert := httpsrv.TLSConfig.GetCertificate
|
||||
httpsrv.TLSConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cert, err := getCert(hi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert.Certificate = append(cert.Certificate, s.MetaCert())
|
||||
return cert, nil
|
||||
}
|
||||
s.ModifyTLSConfigToAddMetaCert(httpsrv.TLSConfig)
|
||||
// Disable TLS 1.0 and 1.1, which are obsolete and have security issues.
|
||||
httpsrv.TLSConfig.MinVersion = tls.VersionTLS12
|
||||
httpsrv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -436,6 +427,27 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// watchRateConfig listens for SIGHUP signals and reloads the rate config
|
||||
// file on each signal, applying it to the server. It returns when ctx is done.
|
||||
func watchRateConfig(ctx context.Context, s *derpserver.Server, path string) {
|
||||
sighup := make(chan os.Signal, 1)
|
||||
signal.Notify(sighup, syscall.SIGHUP)
|
||||
defer signal.Stop(sighup)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-sighup:
|
||||
log.Printf("derper: received SIGHUP, reloading rate config from %s", path)
|
||||
if err := s.LoadAndApplyRateConfig(path); err != nil {
|
||||
log.Printf("derper: rate config reload failed: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("derper: rate config reloaded successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
|
||||
|
||||
func prodAutocertHostPolicy(_ context.Context, host string) error {
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/atomicfile"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tempfork/acme"
|
||||
)
|
||||
|
||||
// shortlivedProfile is the ACME certificate profile required by
|
||||
// LetsEncrypt for IP address certificates. Certificates issued under
|
||||
// it are valid for about six days.
|
||||
// See https://letsencrypt.org/docs/profiles/.
|
||||
const shortlivedProfile = "shortlived"
|
||||
|
||||
// ipCertManager is a certProvider that obtains and renews LetsEncrypt
|
||||
// TLS certificates for the server's IP addresses on demand, using the
|
||||
// short-lived ACME certificate profile and the HTTP-01 challenge
|
||||
// served on the derper's plaintext HTTP port.
|
||||
//
|
||||
// Clients connecting to an IP address usually send no SNI, so the
|
||||
// requested IP address is taken from the TCP connection's local
|
||||
// address. That works for however many IPv4 and IPv6 addresses the
|
||||
// server has, with no configuration. Clients that do send an IP
|
||||
// address in the SNI get a certificate only if it matches the
|
||||
// connection's local address, so a client can never make us request a
|
||||
// certificate for an address that isn't ours.
|
||||
//
|
||||
// Connections with a DNS name in the SNI are passed through to the
|
||||
// optional next provider (the regular autocert manager for the
|
||||
// --hostname certificate), if any.
|
||||
type ipCertManager struct {
|
||||
certDir string
|
||||
email string // optional ACME account contact
|
||||
client *acme.Client
|
||||
next certProvider // provider for DNS hostname connections, or nil
|
||||
nextTLS *tls.Config // next.TLSConfig(), or nil
|
||||
|
||||
mu sync.Mutex
|
||||
certs map[netip.Addr]*ipCertEntry
|
||||
tokens map[string]string // HTTP-01 challenge URL path => response body
|
||||
}
|
||||
|
||||
// ipCertEntry is the issuance state for one IP address.
|
||||
// All fields are guarded by ipCertManager.mu.
|
||||
type ipCertEntry struct {
|
||||
cert *tls.Certificate // current cert with Leaf set, or nil if not yet issued
|
||||
|
||||
flight chan struct{} // non-nil while an issuance is running; closed when it finishes
|
||||
flightErr error // result of the last finished issuance
|
||||
nextAttempt time.Time // earliest time of the next issuance attempt, after a failure
|
||||
retryDelay time.Duration // backoff to apply after the next failure
|
||||
}
|
||||
|
||||
// newIPCertManager returns an ipCertManager storing its ACME account
|
||||
// key and issued certificates in certdir.
|
||||
//
|
||||
// If directoryURL is empty, the LetsEncrypt production directory is
|
||||
// used; tests point it at a fake ACME server. If next is non-nil,
|
||||
// connections with a DNS name in the SNI are served by it.
|
||||
func newIPCertManager(certdir, email, directoryURL string, next certProvider) (*ipCertManager, error) {
|
||||
if err := os.MkdirAll(certdir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountKey, err := loadOrCreateAccountKey(filepath.Join(certdir, "acme-account.key"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ACME account key: %w", err)
|
||||
}
|
||||
m := &ipCertManager{
|
||||
certDir: certdir,
|
||||
email: email,
|
||||
client: &acme.Client{
|
||||
Key: accountKey,
|
||||
DirectoryURL: directoryURL,
|
||||
UserAgent: "tailscale-derper",
|
||||
},
|
||||
next: next,
|
||||
certs: make(map[netip.Addr]*ipCertEntry),
|
||||
tokens: make(map[string]string),
|
||||
}
|
||||
if next != nil {
|
||||
m.nextTLS = next.TLSConfig()
|
||||
}
|
||||
go m.renewLoop()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func loadOrCreateAccountKey(path string) (*ecdsa.PrivateKey, error) {
|
||||
if pemBytes, err := os.ReadFile(path); err == nil {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("invalid PEM in %s", path)
|
||||
}
|
||||
return x509.ParseECPrivateKey(block.Bytes)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
der, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
|
||||
if err := atomicfile.WriteFile(path, pemBytes, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// certPaths returns the cert and key file paths for ip in m.certDir.
|
||||
// Colons in IPv6 addresses are replaced with dots to keep the names
|
||||
// filesystem-safe.
|
||||
func (m *ipCertManager) certPaths(ip netip.Addr) (crtPath, keyPath string) {
|
||||
base := strings.ReplaceAll(ip.String(), ":", ".")
|
||||
return filepath.Join(m.certDir, base+".crt"), filepath.Join(m.certDir, base+".key")
|
||||
}
|
||||
|
||||
func (m *ipCertManager) TLSConfig() *tls.Config {
|
||||
var conf *tls.Config
|
||||
if m.nextTLS != nil {
|
||||
conf = m.nextTLS.Clone()
|
||||
} else {
|
||||
conf = &tls.Config{
|
||||
NextProtos: []string{
|
||||
"http/1.1",
|
||||
},
|
||||
}
|
||||
}
|
||||
conf.GetCertificate = m.getCertificate
|
||||
return conf
|
||||
}
|
||||
|
||||
// connLocalIP returns the local (server side) IP address of the
|
||||
// connection that sent the ClientHello.
|
||||
func connLocalIP(hi *tls.ClientHelloInfo) (netip.Addr, bool) {
|
||||
if hi.Conn == nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
ta, ok := hi.Conn.LocalAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
ip := ta.AddrPort().Addr().Unmap()
|
||||
return ip, ip.IsValid()
|
||||
}
|
||||
|
||||
func (m *ipCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
connIP, connIPOK := connLocalIP(hi)
|
||||
if hi.ServerName != "" {
|
||||
sniIP, err := netip.ParseAddr(hi.ServerName)
|
||||
if err != nil {
|
||||
// The SNI is a DNS name; let the hostname provider handle it.
|
||||
if m.nextTLS != nil && m.nextTLS.GetCertificate != nil {
|
||||
return m.nextTLS.GetCertificate(hi)
|
||||
}
|
||||
return nil, fmt.Errorf("no certificate for hostname %q; this server only serves IP address certificates", hi.ServerName)
|
||||
}
|
||||
if !connIPOK || sniIP.Unmap() != connIP {
|
||||
return nil, fmt.Errorf("requested certificate for IP %v does not match the connection's IP address", sniIP)
|
||||
}
|
||||
}
|
||||
if !connIPOK {
|
||||
return nil, errors.New("unable to determine the connection's local IP address")
|
||||
}
|
||||
ctx := hi.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return m.certForIP(ctx, connIP)
|
||||
}
|
||||
|
||||
// certForIP returns the current certificate for ip, obtaining one
|
||||
// first if there is no unexpired certificate for it. Concurrent
|
||||
// callers for the same IP share a single issuance.
|
||||
func (m *ipCertManager) certForIP(ctx context.Context, ip netip.Addr) (*tls.Certificate, error) {
|
||||
m.mu.Lock()
|
||||
e := m.entryLocked(ip)
|
||||
if e.cert != nil && time.Now().Before(e.cert.Leaf.NotAfter) {
|
||||
defer m.mu.Unlock()
|
||||
return clipCert(e.cert), nil
|
||||
}
|
||||
if e.flight == nil && time.Now().Before(e.nextAttempt) {
|
||||
m.mu.Unlock()
|
||||
return nil, fmt.Errorf("cert issuance for %v failed recently; next attempt no earlier than %v", ip, e.nextAttempt.Format(time.RFC3339))
|
||||
}
|
||||
flight := m.startFlightLocked(ip, e)
|
||||
m.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-flight:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if e.cert == nil {
|
||||
return nil, e.flightErr
|
||||
}
|
||||
return clipCert(e.cert), nil
|
||||
}
|
||||
|
||||
func (m *ipCertManager) entryLocked(ip netip.Addr) *ipCertEntry {
|
||||
e, ok := m.certs[ip]
|
||||
if !ok {
|
||||
e = &ipCertEntry{}
|
||||
m.certs[ip] = e
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// clipCert returns a shallow copy of cert with a capacity-clamped
|
||||
// chain so callers can never mutate the manager's long-lived
|
||||
// certificate.
|
||||
func clipCert(cert *tls.Certificate) *tls.Certificate {
|
||||
certCopy := *cert
|
||||
certCopy.Certificate = slices.Clip(certCopy.Certificate)
|
||||
return &certCopy
|
||||
}
|
||||
|
||||
// startFlightLocked starts an issuance for ip if none is running and
|
||||
// returns a channel that is closed when it finishes.
|
||||
func (m *ipCertManager) startFlightLocked(ip netip.Addr, e *ipCertEntry) chan struct{} {
|
||||
if e.flight != nil {
|
||||
return e.flight
|
||||
}
|
||||
done := make(chan struct{})
|
||||
e.flight = done
|
||||
go func() {
|
||||
err := m.issue(ip)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
e.flight = nil
|
||||
e.flightErr = err
|
||||
if err != nil {
|
||||
if e.retryDelay == 0 {
|
||||
e.retryDelay = time.Minute
|
||||
}
|
||||
e.nextAttempt = time.Now().Add(e.retryDelay)
|
||||
e.retryDelay = min(e.retryDelay*2, 30*time.Minute)
|
||||
log.Printf("derper: acme: getting cert for %v: %v (next attempt in %v)", ip, err, time.Until(e.nextAttempt).Round(time.Second))
|
||||
} else {
|
||||
e.retryDelay = 0
|
||||
e.nextAttempt = time.Time{}
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
// issue obtains a certificate for ip, preferring a still-fresh one
|
||||
// cached on disk over a new ACME order.
|
||||
func (m *ipCertManager) issue(ip netip.Addr) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
if cert, err := m.loadCachedCert(ip); err == nil && !certNeedsRenewal(cert.Leaf) {
|
||||
m.mu.Lock()
|
||||
m.entryLocked(ip).cert = cert
|
||||
m.mu.Unlock()
|
||||
log.Printf("derper: acme: loaded cached cert for %v (expires %v)", ip, cert.Leaf.NotAfter)
|
||||
return nil
|
||||
}
|
||||
return m.obtainCert(ctx, ip)
|
||||
}
|
||||
|
||||
// loadCachedCert loads a previously issued certificate for ip from
|
||||
// disk, if present and still valid.
|
||||
func (m *ipCertManager) loadCachedCert(ip netip.Addr) (*tls.Certificate, error) {
|
||||
crtPath, keyPath := m.certPaths(ip)
|
||||
cert, err := tls.LoadX509KeyPair(crtPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
|
||||
return nil, fmt.Errorf("cached cert is expired or not yet valid (NotAfter %v)", leaf.NotAfter)
|
||||
}
|
||||
if err := leaf.VerifyHostname(ip.String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert.Leaf = leaf
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// HTTPHandler returns a handler serving HTTP-01 challenge responses on
|
||||
// the derper's plaintext HTTP port, sending all other requests to the
|
||||
// next provider's handler, if any, and otherwise to fallback.
|
||||
func (m *ipCertManager) HTTPHandler(fallback http.Handler) http.Handler {
|
||||
if m.next != nil {
|
||||
fallback = m.next.HTTPHandler(fallback)
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") {
|
||||
fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
response, ok := m.tokens[r.URL.Path]
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
io.WriteString(w, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ipCertManager) setToken(path, response string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.tokens[path] = response
|
||||
}
|
||||
|
||||
func (m *ipCertManager) deleteToken(path string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.tokens, path)
|
||||
}
|
||||
|
||||
// certNeedsRenewal reports whether leaf has less than a third of its
|
||||
// lifetime remaining. LetsEncrypt short-lived certs are valid for
|
||||
// about six days, so renewal happens roughly every four.
|
||||
func certNeedsRenewal(leaf *x509.Certificate) bool {
|
||||
total := leaf.NotAfter.Sub(leaf.NotBefore)
|
||||
return time.Until(leaf.NotAfter) < total/3
|
||||
}
|
||||
|
||||
// renewLoop runs for the lifetime of the process, renewing each issued
|
||||
// certificate as it approaches expiry.
|
||||
func (m *ipCertManager) renewLoop() {
|
||||
for {
|
||||
time.Sleep(time.Hour)
|
||||
m.mu.Lock()
|
||||
now := time.Now()
|
||||
for ip, e := range m.certs {
|
||||
if e.cert != nil && certNeedsRenewal(e.cert.Leaf) && e.flight == nil && now.After(e.nextAttempt) {
|
||||
m.startFlightLocked(ip, e)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// obtainCert does one ACME issuance flow for ip: it registers the
|
||||
// account if needed, orders a short-lived profile certificate for the
|
||||
// IP address identifier, fulfills the HTTP-01 challenges, and installs
|
||||
// and caches the issued certificate.
|
||||
func (m *ipCertManager) obtainCert(ctx context.Context, ip netip.Addr) error {
|
||||
ipStr := ip.String()
|
||||
|
||||
var contact []string
|
||||
if m.email != "" {
|
||||
contact = []string{"mailto:" + m.email}
|
||||
}
|
||||
_, err := m.client.Register(ctx, &acme.Account{Contact: contact}, acme.AcceptTOS)
|
||||
if err != nil && !errors.Is(err, acme.ErrAccountAlreadyExists) {
|
||||
return fmt.Errorf("register: %w", err)
|
||||
}
|
||||
|
||||
order, err := m.client.AuthorizeOrder(ctx, acme.IPIDs(ipStr), acme.WithOrderProfile(shortlivedProfile))
|
||||
if err != nil {
|
||||
return fmt.Errorf("new order: %w", err)
|
||||
}
|
||||
for _, authzURL := range order.AuthzURLs {
|
||||
if err := m.fulfillAuthz(ctx, authzURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
order, err = m.client.WaitOrder(ctx, order.URI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("waiting for order: %w", err)
|
||||
}
|
||||
|
||||
certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
|
||||
IPAddresses: []net.IP{ip.AsSlice()},
|
||||
}, certKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
der, _, err := m.client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finalizing order: %w", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(der[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing issued cert: %w", err)
|
||||
}
|
||||
if err := leaf.VerifyHostname(ipStr); err != nil {
|
||||
return fmt.Errorf("issued cert: %w", err)
|
||||
}
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(certKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
var chainPEM []byte
|
||||
for _, b := range der {
|
||||
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: b})...)
|
||||
}
|
||||
crtPath, keyPath := m.certPaths(ip)
|
||||
if err := atomicfile.WriteFile(keyPath, keyPEM, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := atomicfile.WriteFile(crtPath, chainPEM, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.entryLocked(ip).cert = &tls.Certificate{
|
||||
Certificate: der,
|
||||
PrivateKey: certKey,
|
||||
Leaf: leaf,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
dn := &tailcfg.DERPNode{
|
||||
Name: "custom",
|
||||
RegionID: 900,
|
||||
HostName: ipStr,
|
||||
}
|
||||
dnJSON, _ := json.Marshal(dn)
|
||||
log.Printf("derper: acme: got cert for %v (expires %v). Configure it in DERPMap using (https://tailscale.com/s/custom-derp):\n %s", ip, leaf.NotAfter, dnJSON)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fulfillAuthz completes the HTTP-01 challenge for one authorization,
|
||||
// if it is still pending.
|
||||
func (m *ipCertManager) fulfillAuthz(ctx context.Context, authzURL string) error {
|
||||
authz, err := m.client.GetAuthorization(ctx, authzURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting authorization: %w", err)
|
||||
}
|
||||
if authz.Status != acme.StatusPending {
|
||||
return nil
|
||||
}
|
||||
var challenge *acme.Challenge
|
||||
for _, c := range authz.Challenges {
|
||||
if c.Type == "http-01" {
|
||||
challenge = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if challenge == nil {
|
||||
return errors.New("authorization offers no http-01 challenge")
|
||||
}
|
||||
response, err := m.client.HTTP01ChallengeResponse(challenge.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := m.client.HTTP01ChallengePath(challenge.Token)
|
||||
m.setToken(path, response)
|
||||
defer m.deleteToken(path)
|
||||
if _, err := m.client.Accept(ctx, challenge); err != nil {
|
||||
return fmt.Errorf("accepting challenge: %w", err)
|
||||
}
|
||||
if _, err := m.client.WaitAuthorization(ctx, authz.URI); err != nil {
|
||||
return fmt.Errorf("waiting for authorization: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeIPACME is a minimal fake ACME (RFC 8555) certificate authority
|
||||
// for testing ipCertManager. It implements just enough of the protocol
|
||||
// for http-01 order flows with IP address identifiers and the
|
||||
// "shortlived" profile: one order at a time, no JWS signature
|
||||
// verification, no nonce tracking.
|
||||
type fakeIPACME struct {
|
||||
t *testing.T
|
||||
srv *httptest.Server
|
||||
challengeBase string // base URL at which http-01 challenges are fetched
|
||||
|
||||
caKey *ecdsa.PrivateKey
|
||||
caCert *x509.Certificate
|
||||
|
||||
mu sync.Mutex
|
||||
orders int // number of orders created
|
||||
gotProfile string // profile of the last order
|
||||
gotIDType string // identifier type of the last order
|
||||
gotIDValue string // identifier value of the last order
|
||||
authzStatus string // "pending" or "valid"
|
||||
orderStatus string // "pending", "ready", or "valid"
|
||||
token string
|
||||
certPEM []byte
|
||||
}
|
||||
|
||||
func newFakeIPACME(t *testing.T) *fakeIPACME {
|
||||
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "fake IP ACME root"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caCert, err := x509.ParseCertificate(caDER)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeIPACME{
|
||||
t: t,
|
||||
caKey: caKey,
|
||||
caCert: caCert,
|
||||
}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(f.serveHTTP))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeIPACME) directoryURL() string { return f.srv.URL + "/directory" }
|
||||
|
||||
func (f *fakeIPACME) numOrders() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.orders
|
||||
}
|
||||
|
||||
func (f *fakeIPACME) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Replay-Nonce", "test-nonce")
|
||||
writeJSON := func(v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
orderJSON := func() any {
|
||||
return map[string]any{
|
||||
"status": f.orderStatus,
|
||||
"identifiers": []map[string]string{{"type": f.gotIDType, "value": f.gotIDValue}},
|
||||
"authorizations": []string{f.srv.URL + "/authz/1"},
|
||||
"finalize": f.srv.URL + "/finalize/1",
|
||||
"certificate": f.srv.URL + "/cert/1",
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == "/directory":
|
||||
writeJSON(map[string]any{
|
||||
"newNonce": f.srv.URL + "/new-nonce",
|
||||
"newAccount": f.srv.URL + "/new-account",
|
||||
"newOrder": f.srv.URL + "/new-order",
|
||||
"revokeCert": f.srv.URL + "/revoke-cert",
|
||||
"keyChange": f.srv.URL + "/key-change",
|
||||
"meta": map[string]any{
|
||||
"profiles": map[string]string{
|
||||
"classic": "the default profile",
|
||||
shortlivedProfile: "six day certificates",
|
||||
},
|
||||
},
|
||||
})
|
||||
case r.URL.Path == "/new-nonce":
|
||||
// The Replay-Nonce header was already set above.
|
||||
case r.URL.Path == "/new-account":
|
||||
w.Header().Set("Location", f.srv.URL+"/account/1")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(map[string]any{"status": "valid"})
|
||||
case r.URL.Path == "/new-order":
|
||||
var req struct {
|
||||
Identifiers []struct{ Type, Value string } `json:"identifiers"`
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
if err := decodeJWSPayload(r, &req); err != nil {
|
||||
f.t.Errorf("new-order payload: %v", err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.orders++
|
||||
f.gotProfile = req.Profile
|
||||
if len(req.Identifiers) == 1 {
|
||||
f.gotIDType = req.Identifiers[0].Type
|
||||
f.gotIDValue = req.Identifiers[0].Value
|
||||
}
|
||||
f.authzStatus = "pending"
|
||||
f.orderStatus = "pending"
|
||||
f.token = fmt.Sprintf("tok-%d", f.orders)
|
||||
f.mu.Unlock()
|
||||
w.Header().Set("Location", f.srv.URL+"/order/1")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
writeJSON(orderJSON())
|
||||
case r.URL.Path == "/authz/1":
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
writeJSON(map[string]any{
|
||||
"status": f.authzStatus,
|
||||
"identifier": map[string]string{"type": f.gotIDType, "value": f.gotIDValue},
|
||||
"challenges": []map[string]string{{
|
||||
"type": "http-01",
|
||||
"url": f.srv.URL + "/chal/1",
|
||||
"token": f.token,
|
||||
"status": f.authzStatus,
|
||||
}},
|
||||
})
|
||||
case r.URL.Path == "/chal/1":
|
||||
if err := f.validateChallenge(); err != nil {
|
||||
f.t.Errorf("challenge validation: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.authzStatus = "valid"
|
||||
f.orderStatus = "ready"
|
||||
f.mu.Unlock()
|
||||
writeJSON(map[string]any{"type": "http-01", "status": "valid", "token": f.token})
|
||||
case r.URL.Path == "/order/1":
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
w.Header().Set("Location", f.srv.URL+"/order/1")
|
||||
writeJSON(orderJSON())
|
||||
case r.URL.Path == "/finalize/1":
|
||||
var req struct {
|
||||
CSR string `json:"csr"`
|
||||
}
|
||||
if err := decodeJWSPayload(r, &req); err != nil {
|
||||
f.t.Errorf("finalize payload: %v", err)
|
||||
}
|
||||
if err := f.issueCert(req.CSR); err != nil {
|
||||
f.t.Errorf("issuing cert: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.orderStatus = "valid"
|
||||
f.mu.Unlock()
|
||||
w.Header().Set("Location", f.srv.URL+"/order/1")
|
||||
writeJSON(orderJSON())
|
||||
case r.URL.Path == "/cert/1":
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/pem-certificate-chain")
|
||||
w.Write(f.certPEM)
|
||||
default:
|
||||
f.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// validateChallenge fetches the http-01 challenge response from the
|
||||
// server under test, standing in for the CA dialing port 80 at the IP
|
||||
// address being validated.
|
||||
func (f *fakeIPACME) validateChallenge() error {
|
||||
f.mu.Lock()
|
||||
token := f.token
|
||||
base := f.challengeBase
|
||||
f.mu.Unlock()
|
||||
res, err := http.Get(base + "/.well-known/acme-challenge/" + token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status %d", res.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(string(body), token+".") {
|
||||
return fmt.Errorf("challenge response %q does not start with %q", body, token+".")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// issueCert signs a certificate for the CSR (base64url DER), valid for
|
||||
// six days like a LetsEncrypt shortlived profile certificate.
|
||||
func (f *fakeIPACME) issueCert(csrB64 string) error {
|
||||
csrDER, err := base64.RawURLEncoding.DecodeString(csrB64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(csrDER)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(csr.IPAddresses) != 1 {
|
||||
return fmt.Errorf("CSR has %d IP addresses; want 1", len(csr.IPAddresses))
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
IPAddresses: csr.IPAddresses,
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(6 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, f.caCert, csr.PublicKey, f.caKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var buf []byte
|
||||
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...)
|
||||
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: f.caCert.Raw})...)
|
||||
f.mu.Lock()
|
||||
f.certPEM = buf
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeJWSPayload decodes the payload of a JWS-encoded ACME request
|
||||
// without verifying its signature.
|
||||
func decodeJWSPayload(r *http.Request, v any) error {
|
||||
var req struct{ Payload string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := base64.RawURLEncoding.DecodeString(req.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(b, v)
|
||||
}
|
||||
|
||||
// ipConn is a stub net.Conn whose LocalAddr is the given IP, standing
|
||||
// in for the accepted TLS connection whose destination address decides
|
||||
// which certificate to serve.
|
||||
type ipConn struct {
|
||||
net.Conn
|
||||
local net.Addr
|
||||
}
|
||||
|
||||
func (c ipConn) LocalAddr() net.Addr { return c.local }
|
||||
|
||||
// helloFor returns a ClientHelloInfo as ipCertManager.getCertificate
|
||||
// would see it for a connection to localIP with the given SNI value.
|
||||
func helloFor(t *testing.T, localIP, sni string) *tls.ClientHelloInfo {
|
||||
t.Helper()
|
||||
ip := net.ParseIP(localIP)
|
||||
if ip == nil {
|
||||
t.Fatalf("bad IP %q", localIP)
|
||||
}
|
||||
return &tls.ClientHelloInfo{
|
||||
ServerName: sni,
|
||||
Conn: ipConn{local: &net.TCPAddr{IP: ip, Port: 443}},
|
||||
}
|
||||
}
|
||||
|
||||
// stubCertProvider is a certProvider returning a fixed certificate,
|
||||
// standing in for the autocert manager handling DNS hostname
|
||||
// connections.
|
||||
type stubCertProvider struct {
|
||||
cert *tls.Certificate
|
||||
}
|
||||
|
||||
func (p *stubCertProvider) TLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return p.cert, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *stubCertProvider) HTTPHandler(fallback http.Handler) http.Handler { return fallback }
|
||||
|
||||
// TestIPCertManager exercises the on-demand issuance flow of
|
||||
// ipCertManager against a fake ACME CA: certs are ordered for whatever
|
||||
// IP address a connection arrives on (IPv4 and IPv6), with the
|
||||
// shortlived profile, answering the http-01 challenge, and reusing the
|
||||
// on-disk cache.
|
||||
func TestIPCertManager(t *testing.T) {
|
||||
const ip4 = "203.0.113.7"
|
||||
const ip6 = "2001:db8::7"
|
||||
dir := t.TempDir()
|
||||
ca := newFakeIPACME(t)
|
||||
|
||||
m, err := newIPCertManager(dir, "test@example.com", ca.directoryURL(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Serve the manager's HTTP-01 challenge handler like derper's port
|
||||
// 80 listener does.
|
||||
challengeSrv := httptest.NewServer(m.HTTPHandler(http.NotFoundHandler()))
|
||||
defer challengeSrv.Close()
|
||||
ca.challengeBase = challengeSrv.URL
|
||||
|
||||
// A connection to the IPv4 address with no SNI mints a cert for it.
|
||||
cert, err := m.getCertificate(helloFor(t, ip4, ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cert.Leaf.VerifyHostname(ip4); err != nil {
|
||||
t.Errorf("issued cert not valid for %v: %v", ip4, err)
|
||||
}
|
||||
if got, want := ca.gotProfile, shortlivedProfile; got != want {
|
||||
t.Errorf("order profile = %q; want %q", got, want)
|
||||
}
|
||||
if ca.gotIDType != "ip" || ca.gotIDValue != ip4 {
|
||||
t.Errorf("order identifier = %q %q; want %q %q", ca.gotIDType, ca.gotIDValue, "ip", ip4)
|
||||
}
|
||||
if n := ca.numOrders(); n != 1 {
|
||||
t.Errorf("orders created = %d; want 1", n)
|
||||
}
|
||||
|
||||
// A connection to the IPv6 address mints a second, separate cert.
|
||||
cert6, err := m.getCertificate(helloFor(t, ip6, ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cert6.Leaf.VerifyHostname(ip6); err != nil {
|
||||
t.Errorf("issued cert not valid for %v: %v", ip6, err)
|
||||
}
|
||||
if ca.gotIDType != "ip" || ca.gotIDValue != ip6 {
|
||||
t.Errorf("order identifier = %q %q; want %q %q", ca.gotIDType, ca.gotIDValue, "ip", ip6)
|
||||
}
|
||||
if n := ca.numOrders(); n != 2 {
|
||||
t.Errorf("orders created = %d; want 2", n)
|
||||
}
|
||||
|
||||
// An SNI containing the connection's own IP address is served from
|
||||
// the cache.
|
||||
if _, err := m.getCertificate(helloFor(t, ip4, ip4)); err != nil {
|
||||
t.Errorf("getCertificate with matching IP SNI: %v", err)
|
||||
}
|
||||
if n := ca.numOrders(); n != 2 {
|
||||
t.Errorf("orders created after cached hit = %d; want 2", n)
|
||||
}
|
||||
|
||||
// An SNI naming some other IP address is rejected.
|
||||
if _, err := m.getCertificate(helloFor(t, ip4, "203.0.113.8")); err == nil {
|
||||
t.Error("getCertificate with mismatched IP SNI succeeded; want error")
|
||||
}
|
||||
|
||||
// A DNS name SNI has no provider to go to here.
|
||||
if _, err := m.getCertificate(helloFor(t, ip4, "derp.example.com")); err == nil {
|
||||
t.Error("getCertificate with DNS SNI and no next provider succeeded; want error")
|
||||
}
|
||||
|
||||
// A second manager over the same cert directory must use the
|
||||
// on-disk cache rather than creating more orders.
|
||||
m2, err := newIPCertManager(dir, "test@example.com", ca.directoryURL(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cachedCert, err := m2.getCertificate(helloFor(t, ip4, ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cachedCert.Leaf.VerifyHostname(ip4); err != nil {
|
||||
t.Errorf("cached cert not valid for %v: %v", ip4, err)
|
||||
}
|
||||
if n := ca.numOrders(); n != 2 {
|
||||
t.Errorf("orders created after cache reuse = %d; want 2", n)
|
||||
}
|
||||
|
||||
// Non-challenge requests go to the fallback handler.
|
||||
rec := httptest.NewRecorder()
|
||||
m.HTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "fallback")
|
||||
})).ServeHTTP(rec, httptest.NewRequest("GET", "/other", nil))
|
||||
if got := rec.Body.String(); got != "fallback" {
|
||||
t.Errorf("fallback body = %q; want %q", got, "fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPCertManagerNextProvider verifies that connections with a DNS
|
||||
// name in the SNI are passed through to the next provider.
|
||||
func TestIPCertManagerNextProvider(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ca := newFakeIPACME(t)
|
||||
stubCert := &tls.Certificate{}
|
||||
m, err := newIPCertManager(dir, "", ca.directoryURL(), &stubCertProvider{cert: stubCert})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := m.getCertificate(helloFor(t, "203.0.113.7", "derp.example.com"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != stubCert {
|
||||
t.Errorf("DNS SNI returned %p; want the next provider's cert %p", got, stubCert)
|
||||
}
|
||||
if n := ca.numOrders(); n != 0 {
|
||||
t.Errorf("orders created = %d; want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCertModeIPCertsGating verifies the flag validation around
|
||||
// --acme-ip-certs and IP address hostnames.
|
||||
func TestCertModeIPCertsGating(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
host string
|
||||
ipCerts bool
|
||||
wantErr string // or empty to expect success
|
||||
}{
|
||||
{"letsencrypt_ip_no_flag", "letsencrypt", "1.2.3.4", false, "--acme-ip-certs"},
|
||||
{"gcp_ip", "gcp", "1.2.3.4", false, "--certmode=gcp requires --hostname to be a DNS name"},
|
||||
{"gcp_flag", "gcp", "1.2.3.4", true, "--acme-ip-certs requires --certmode=letsencrypt"},
|
||||
{"manual_flag", "manual", "1.2.3.4", true, "--acme-ip-certs requires --certmode=letsencrypt"},
|
||||
{"letsencrypt_ip_flag", "letsencrypt", "1.2.3.4", true, ""},
|
||||
{"letsencrypt_hostname_flag", "letsencrypt", "derp.example.com", true, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cp, err := certProviderByCertMode(tt.mode, t.TempDir(), tt.host, tt.ipCerts, "", "", "")
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("certProviderByCertMode(%q, %q, ipCerts=%v) = %v; want success", tt.mode, tt.host, tt.ipCerts, err)
|
||||
}
|
||||
m, ok := cp.(*ipCertManager)
|
||||
if !ok {
|
||||
t.Fatalf("provider type = %T; want *ipCertManager", cp)
|
||||
}
|
||||
wantNext := net.ParseIP(tt.host) == nil
|
||||
if gotNext := m.next != nil; gotNext != wantNext {
|
||||
t.Errorf("has next provider = %v; want %v", gotNext, wantNext)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("certProviderByCertMode(%q, %q, ipCerts=%v) error = %v; want contains %q",
|
||||
tt.mode, tt.host, tt.ipCerts, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
// fbstatus is a Linux framebuffer status display for the Tailscale
|
||||
// appliance. It draws the Tailscale logo, the tailscaled backend state,
|
||||
// the device's tailnet IP addresses, and (when the device needs to be
|
||||
// logged in) a QR code containing the login URL so a user can enroll
|
||||
// the appliance into a tailnet by pointing their phone camera at the
|
||||
// screen.
|
||||
//
|
||||
// fbstatus accesses the framebuffer via the Linux UAPI in
|
||||
// include/uapi/linux/fb.h: FBIOGET_VSCREENINFO and FBIOGET_FSCREENINFO
|
||||
// ioctls plus an mmap of /dev/fb0. Only 32-bit truecolor framebuffers
|
||||
// (the Raspberry Pi default) are supported.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/util/cloudenv"
|
||||
)
|
||||
|
||||
//go:embed tailscale.png
|
||||
var tailscalePNG []byte
|
||||
|
||||
// Linux framebuffer ioctl numbers, from include/uapi/linux/fb.h.
|
||||
const (
|
||||
fbioGetVScreenInfo = 0x4600
|
||||
fbioGetFScreenInfo = 0x4602
|
||||
)
|
||||
|
||||
// Linux VT ioctl numbers and KD_* modes, from include/uapi/linux/kd.h
|
||||
// and include/uapi/linux/vt.h.
|
||||
const (
|
||||
kdSetMode = 0x4B3A
|
||||
kdGraphics = 1
|
||||
kdText = 0
|
||||
vtActivate = 0x5606
|
||||
vtWaitActive = 0x5607
|
||||
)
|
||||
|
||||
// Byte offsets into the raw fb_var_screeninfo struct returned by
|
||||
// FBIOGET_VSCREENINFO. All fields we read are little-endian uint32.
|
||||
const (
|
||||
vsOffXres = 0
|
||||
vsOffYres = 4
|
||||
vsOffBitsPerPixel = 24
|
||||
vsOffRedOffset = 32 // start of struct fb_bitfield red
|
||||
vsOffGreenOffset = 44 // start of struct fb_bitfield green
|
||||
vsOffBlueOffset = 56 // start of struct fb_bitfield blue
|
||||
)
|
||||
|
||||
// Byte offsets into the raw fb_fix_screeninfo struct returned by
|
||||
// FBIOGET_FSCREENINFO. Layout assumes a 64-bit kernel (the gokrazy
|
||||
// appliance targets — arm64/amd64 — are both 64-bit). smem_start and
|
||||
// mmio_start are "unsigned long", which is 8 bytes on 64-bit.
|
||||
const (
|
||||
fsOffSmemLen = 24
|
||||
fsOffLineLength = 48
|
||||
)
|
||||
|
||||
var flagFB = flag.String("fb", "/dev/fb0", "framebuffer device to draw to")
|
||||
|
||||
// noFramebufferReason reports whether this host lacks a usable Linux
|
||||
// framebuffer, along with a short human-readable explanation. If the
|
||||
// framebuffer device is missing, or we're running on a cloud (currently
|
||||
// only AWS) whose instances don't expose one, we return true.
|
||||
func noFramebufferReason(fbPath string) (string, bool) {
|
||||
if cloudenv.Get() == cloudenv.AWS {
|
||||
return "running on AWS (no framebuffer)", true
|
||||
}
|
||||
if _, err := os.Stat(fbPath); err != nil {
|
||||
return fmt.Sprintf("no framebuffer at %s: %v", fbPath, err), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Bail out early on cloud VMs that don't ship a framebuffer. We
|
||||
// still kick off breakglass once DHCP succeeds (otherwise the
|
||||
// appliance is unreachable — breakglass declares DontStartOnBoot
|
||||
// and only runs when fbstatus pokes the supervisor), then exit
|
||||
// 125 so the gokrazy supervisor stops respawning us. See
|
||||
// https://gokrazy.org/development/process-interface/.
|
||||
if reason, ok := noFramebufferReason(*flagFB); ok {
|
||||
log.Printf("%s; starting breakglass after DHCP then exiting 125", reason)
|
||||
startBreakglassAfterDHCP(&uiState{})
|
||||
log.Printf("breakglass started; exiting 125 so gokrazy won't respawn fbstatus")
|
||||
os.Exit(125)
|
||||
}
|
||||
|
||||
if restore, err := claimVTGraphics(); err != nil {
|
||||
log.Printf("could not put VT into graphics mode (fbcon may overdraw): %v", err)
|
||||
} else {
|
||||
defer restore()
|
||||
}
|
||||
|
||||
fb, err := openFramebuffer(*flagFB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open framebuffer: %w", err)
|
||||
}
|
||||
defer fb.Close()
|
||||
log.Printf("framebuffer %s: %dx%d, %d bpp, line=%d, RGB offsets %d/%d/%d",
|
||||
*flagFB, fb.width, fb.height, fb.bpp, fb.lineLength,
|
||||
fb.redShift, fb.greenShift, fb.blueShift)
|
||||
|
||||
logo, err := png.Decode(bytes.NewReader(tailscalePNG))
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding embedded logo: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
var lc local.Client
|
||||
st := &uiState{fb: fb, logo: logo}
|
||||
st.updateLAN()
|
||||
st.render()
|
||||
|
||||
go st.pollLAN(ctx)
|
||||
go startBreakglassAfterDHCP(st)
|
||||
go watchKeyboardForConsole(ctx, st)
|
||||
|
||||
for ctx.Err() == nil {
|
||||
if err := watchBusOnce(ctx, &lc, st); err != nil && ctx.Err() == nil {
|
||||
log.Printf("ipn watch: %v; retrying in 2s", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func watchBusOnce(ctx context.Context, lc *local.Client, st *uiState) error {
|
||||
w, err := lc.WatchIPNBus(ctx,
|
||||
ipn.NotifyInitialState|ipn.NotifyInitialPrefs|ipn.NotifyInitialStatus)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
loginRequested := false
|
||||
|
||||
for ctx.Err() == nil {
|
||||
n, err := w.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n.State != nil {
|
||||
st.state = *n.State
|
||||
// On a fresh appliance, tailscaled enters NeedsLogin but
|
||||
// does not generate a login URL until someone asks. Trigger
|
||||
// an interactive login so the control server sends us a URL
|
||||
// (and thus a QR code appears on the display).
|
||||
if *n.State == ipn.NeedsLogin && !loginRequested {
|
||||
loginRequested = true
|
||||
go func() {
|
||||
if err := lc.StartLoginInteractive(ctx); err != nil {
|
||||
log.Printf("StartLoginInteractive: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
if n.BrowseToURL != nil {
|
||||
st.loginURL = *n.BrowseToURL
|
||||
}
|
||||
if n.InitialStatus != nil {
|
||||
st.ips = append(st.ips[:0], n.InitialStatus.TailscaleIPs...)
|
||||
}
|
||||
if n.SelfChange != nil {
|
||||
st.ips = st.ips[:0]
|
||||
for _, p := range n.SelfChange.Addresses {
|
||||
st.ips = append(st.ips, p.Addr())
|
||||
}
|
||||
}
|
||||
st.render()
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// updateLAN scans network interfaces for a non-loopback interface with a
|
||||
// hardware address, updating st.lanIP and st.lanMAC. Shows the MAC even
|
||||
// if DHCP hasn't assigned an IP yet.
|
||||
func (st *uiState) updateLAN() {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var bestMAC string
|
||||
var bestIP string
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
if len(iface.HardwareAddr) == 0 {
|
||||
continue
|
||||
}
|
||||
if bestMAC == "" {
|
||||
bestMAC = iface.HardwareAddr.String()
|
||||
}
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
// Prefer the first UP interface with a MAC.
|
||||
if bestMAC != iface.HardwareAddr.String() && bestIP == "" {
|
||||
bestMAC = iface.HardwareAddr.String()
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil {
|
||||
bestMAC = iface.HardwareAddr.String()
|
||||
bestIP = ipnet.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
st.lanMAC = bestMAC
|
||||
st.lanIP = bestIP
|
||||
}
|
||||
|
||||
// startBreakglassAfterDHCP waits until a LAN IP is assigned (meaning DHCP
|
||||
// succeeded), then restarts breakglass. This ensures breakglass sees the
|
||||
// real LAN address in PrivateInterfaceAddrs and binds to it, rather than
|
||||
// only binding to 127.0.0.1.
|
||||
func startBreakglassAfterDHCP(st *uiState) {
|
||||
for {
|
||||
st.updateLAN()
|
||||
if st.lanIP != "" {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
startBreakglass()
|
||||
}
|
||||
|
||||
// startBreakglass asks the gokrazy init HTTP API (over its unix socket) to
|
||||
// restart the breakglass service so it actually runs. By default breakglass
|
||||
// calls DontStartOnBoot and exits on the first launch attempt; this poke
|
||||
// tells the supervisor to try again (without GOKRAZY_FIRST_START=1).
|
||||
func startBreakglass() {
|
||||
const sock = "/run/gokrazy-http.sock"
|
||||
hc := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", sock)
|
||||
},
|
||||
},
|
||||
}
|
||||
form := url.Values{
|
||||
"path": {"/user/breakglass"},
|
||||
"xsrftoken": {"1"},
|
||||
}
|
||||
req, err := http.NewRequest("POST", "http://gokrazy/restart", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
log.Printf("startBreakglass: %v", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "gokrazy_xsrf", Value: "1"})
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("startBreakglass: %v", err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 300 || resp.StatusCode == http.StatusSeeOther {
|
||||
log.Printf("startBreakglass: restarted (status %s)", resp.Status)
|
||||
} else {
|
||||
log.Printf("startBreakglass: unexpected status %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// watchKeyboardForConsole monitors keyboard input devices for VT-switching
|
||||
// accelerators.
|
||||
// - Ctrl-Alt-F2 (or plain Esc — easier to type in NoVNC where the
|
||||
// Ctrl-Alt-Fn sequence doesn't always propagate) switches to VT2, a
|
||||
// text-mode busybox shell. When that shell exits, we switch back
|
||||
// automatically.
|
||||
// - Ctrl-Alt-F1 switches back to VT1 (fbstatus graphics mode).
|
||||
//
|
||||
// This mirrors standard Linux VT switching conventions plus a NoVNC-
|
||||
// friendly shortcut.
|
||||
func watchKeyboardForConsole(ctx context.Context, st *uiState) {
|
||||
kbdPath := findKeyboard()
|
||||
if kbdPath == "" {
|
||||
log.Printf("no keyboard found for VT switching")
|
||||
return
|
||||
}
|
||||
kbd, err := os.Open(kbdPath)
|
||||
if err != nil {
|
||||
log.Printf("open keyboard %s: %v", kbdPath, err)
|
||||
return
|
||||
}
|
||||
defer kbd.Close()
|
||||
|
||||
ttyFile, err := os.OpenFile("/dev/tty0", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
log.Printf("open /dev/tty0 for VT switch: %v", err)
|
||||
return
|
||||
}
|
||||
defer ttyFile.Close()
|
||||
ttyFd := int(ttyFile.Fd())
|
||||
|
||||
log.Printf("watching %s for Ctrl-Alt-F1/F2 and Esc (VT switching)", kbdPath)
|
||||
|
||||
// Linux input_event has the same layout on both arm64 and amd64
|
||||
// (24 bytes: two uint64 timestamps + uint16 type + uint16 code +
|
||||
// int32 value), so this parser handles both the Pi and Proxmox VM.
|
||||
const evSize = 24
|
||||
const evKey = 1 // EV_KEY
|
||||
const keyEsc = 1 // KEY_ESC
|
||||
const keyF1 = 59 // KEY_F1
|
||||
const keyF2 = 60 // KEY_F2
|
||||
const keyLeftCtrl = 29
|
||||
const keyLeftAlt = 56
|
||||
const keyRightCtrl = 97
|
||||
const keyRightAlt = 100
|
||||
const keyPress = 1
|
||||
|
||||
buf := make([]byte, evSize)
|
||||
var ctrlHeld, altHeld bool
|
||||
|
||||
switchToFbstatus := func() {
|
||||
st.paused.Store(false)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 1)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 1)
|
||||
ioctlSetInt(ttyFile, kdSetMode, kdGraphics)
|
||||
st.render()
|
||||
}
|
||||
switchToShell := func(reason string) {
|
||||
st.paused.Store(true)
|
||||
ioctlSetInt(ttyFile, kdSetMode, kdText)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 2)
|
||||
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 2)
|
||||
go ensureShellOnVT2(switchToFbstatus)
|
||||
log.Printf("%s: switched to text console", reason)
|
||||
}
|
||||
|
||||
for ctx.Err() == nil {
|
||||
n, err := kbd.Read(buf)
|
||||
if err != nil || n < evSize {
|
||||
continue
|
||||
}
|
||||
evType := binary.LittleEndian.Uint16(buf[16:18])
|
||||
evCode := binary.LittleEndian.Uint16(buf[18:20])
|
||||
evValue := int32(binary.LittleEndian.Uint32(buf[20:24]))
|
||||
|
||||
if evType != evKey {
|
||||
continue
|
||||
}
|
||||
|
||||
pressed := evValue == keyPress
|
||||
released := evValue == 0
|
||||
|
||||
switch evCode {
|
||||
case keyLeftCtrl, keyRightCtrl:
|
||||
if pressed {
|
||||
ctrlHeld = true
|
||||
} else if released {
|
||||
ctrlHeld = false
|
||||
}
|
||||
case keyLeftAlt, keyRightAlt:
|
||||
if pressed {
|
||||
altHeld = true
|
||||
} else if released {
|
||||
altHeld = false
|
||||
}
|
||||
case keyEsc:
|
||||
if pressed && !ctrlHeld && !altHeld {
|
||||
// Bare Esc: NoVNC-friendly shortcut to the shell.
|
||||
switchToShell("Esc")
|
||||
}
|
||||
case keyF1:
|
||||
if pressed && ctrlHeld && altHeld {
|
||||
switchToFbstatus()
|
||||
log.Printf("Ctrl-Alt-F1: switched to fbstatus")
|
||||
}
|
||||
case keyF2:
|
||||
if pressed && ctrlHeld && altHeld {
|
||||
switchToShell("Ctrl-Alt-F2")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensureShellOnVT2 spawns a busybox ash shell on /dev/tty2 if one isn't
|
||||
// already running. The shell gets the VT2 tty as its controlling terminal
|
||||
// so keyboard input on VT2 goes to it. When the shell exits, onExit is
|
||||
// called (typically to switch back to VT1 / fbstatus graphics mode).
|
||||
var shellOnVT2Running atomic.Bool
|
||||
|
||||
func ensureShellOnVT2(onExit func()) {
|
||||
if !shellOnVT2Running.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer shellOnVT2Running.Store(false)
|
||||
defer func() {
|
||||
if onExit != nil {
|
||||
onExit()
|
||||
}
|
||||
}()
|
||||
shell := "/tmp/serial-busybox/ash"
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
log.Printf("no shell at %s for VT2", shell)
|
||||
return
|
||||
}
|
||||
tty, err := os.OpenFile("/dev/tty2", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
log.Printf("open /dev/tty2: %v", err)
|
||||
return
|
||||
}
|
||||
defer tty.Close()
|
||||
cmd := exec.Command(shell)
|
||||
cmd.Stdin = tty
|
||||
cmd.Stdout = tty
|
||||
cmd.Stderr = tty
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
Setctty: true,
|
||||
Ctty: 0, // index into cmd's file descriptors (stdin = tty)
|
||||
}
|
||||
cmd.Env = append(os.Environ(), "TERM=linux", "HOME=/tmp", "PATH=/tmp/serial-busybox:/user:/gokrazy")
|
||||
log.Printf("starting shell on VT2")
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("shell on VT2 exited: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// findKeyboard looks for a keyboard among /dev/input/event* devices by
|
||||
// checking that the device's key capability bitmap has KEY_ESC set. The
|
||||
// alternative "any non-zero key bitmap" check picks up the ACPI power
|
||||
// button (which advertises KEY_POWER but no Esc) and misses the real
|
||||
// keyboard on amd64 Proxmox VMs, where the AT keyboard is event1 but
|
||||
// event0 is Power Button.
|
||||
func findKeyboard() string {
|
||||
matches, _ := filepath.Glob("/dev/input/event*")
|
||||
for _, path := range matches {
|
||||
name := filepath.Base(path)
|
||||
capData, err := os.ReadFile("/sys/class/input/" + name + "/device/capabilities/key")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(string(capData)))
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
// The kernel prints capability bitmaps as space-separated 64-bit
|
||||
// hex chunks, most-significant chunk first. The last chunk holds
|
||||
// bits 0..63. KEY_ESC = 1, so its bit-mask is 1<<1 == 0x2.
|
||||
low, err := strconv.ParseUint(fields[len(fields)-1], 16, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
const keyEscBit = 1 << 1
|
||||
if low&keyEscBit != 0 {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pollLAN periodically refreshes LAN info and re-renders.
|
||||
func (st *uiState) pollLAN(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
st.updateLAN()
|
||||
st.render()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// uiState is the most-recently-known view of the appliance state that
|
||||
// gets rendered to the framebuffer on each notify.
|
||||
type uiState struct {
|
||||
fb *framebuffer
|
||||
logo image.Image
|
||||
|
||||
state ipn.State
|
||||
loginURL string
|
||||
ips []netip.Addr
|
||||
|
||||
lanIP string // LAN IPv4 address (from DHCP)
|
||||
lanMAC string // MAC address of the primary interface
|
||||
|
||||
paused atomic.Bool // when true, render() is a no-op (VT switched away)
|
||||
}
|
||||
|
||||
var (
|
||||
bgColor = color.RGBA{0x10, 0x12, 0x20, 0xff} // near-black slate
|
||||
fgColor = color.RGBA{0xff, 0xff, 0xff, 0xff}
|
||||
dimColor = color.RGBA{0xa0, 0xa6, 0xb8, 0xff}
|
||||
stateOK = color.RGBA{0x4a, 0xc8, 0x82, 0xff} // green for Running
|
||||
stateWait = color.RGBA{0xf0, 0xc8, 0x60, 0xff} // amber for NeedsLogin/Starting
|
||||
)
|
||||
|
||||
// render composes the current state into an in-memory image and blits
|
||||
// it to the framebuffer.
|
||||
func (st *uiState) render() {
|
||||
if st.paused.Load() {
|
||||
return
|
||||
}
|
||||
w, h := st.fb.width, st.fb.height
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{C: bgColor}, image.Point{}, draw.Src)
|
||||
|
||||
shortSide := min(w, h)
|
||||
|
||||
// Logo, scaled to ~25% of the shorter dimension, centered
|
||||
// horizontally near the top.
|
||||
logoSize := shortSide / 4
|
||||
logoRect := image.Rect(0, 0, logoSize, logoSize).Add(image.Point{
|
||||
X: (w - logoSize) / 2,
|
||||
Y: shortSide / 16,
|
||||
})
|
||||
xdraw.ApproxBiLinear.Scale(img, logoRect, st.logo, st.logo.Bounds(), xdraw.Over, nil)
|
||||
|
||||
lineH := basicfont.Face7x13.Metrics().Height.Ceil()
|
||||
textTop := logoRect.Max.Y + shortSide/24
|
||||
|
||||
// Hide the state line when the QR code is visible (the "Scan to
|
||||
// enroll" label is clear enough context).
|
||||
showState := !(st.state == ipn.NeedsLogin && st.loginURL != "")
|
||||
if showState {
|
||||
stateColor := dimColor
|
||||
switch st.state {
|
||||
case ipn.Running:
|
||||
stateColor = stateOK
|
||||
case ipn.NeedsLogin, ipn.Starting, ipn.NoState:
|
||||
stateColor = stateWait
|
||||
}
|
||||
drawCenteredScaled(img, fmt.Sprintf("State: %s", stateLabel(st.state)),
|
||||
stateColor, w/2, textTop, 3)
|
||||
}
|
||||
|
||||
y := textTop + 3*lineH + shortSide/40
|
||||
|
||||
if len(st.ips) > 0 {
|
||||
drawCenteredScaled(img, "Tailscale IPs:", dimColor, w/2, y, 2)
|
||||
y += 2 * lineH
|
||||
for _, a := range st.ips {
|
||||
drawCenteredScaled(img, a.String(), fgColor, w/2, y, 2)
|
||||
y += 2*lineH + 4
|
||||
}
|
||||
}
|
||||
|
||||
// QR code with the login URL when enrollment is needed.
|
||||
if st.state == ipn.NeedsLogin && st.loginURL != "" {
|
||||
qrSize := shortSide / 2
|
||||
q, err := qrcode.New(st.loginURL, qrcode.Medium)
|
||||
if err != nil {
|
||||
log.Printf("qr encode %q: %v", st.loginURL, err)
|
||||
} else {
|
||||
q.DisableBorder = false
|
||||
qrImg := q.Image(qrSize)
|
||||
qrRect := qrImg.Bounds().Add(image.Point{
|
||||
X: (w - qrSize) / 2,
|
||||
Y: h - qrSize - shortSide/16,
|
||||
})
|
||||
draw.Draw(img, qrRect, qrImg, qrImg.Bounds().Min, draw.Src)
|
||||
drawCenteredScaled(img, "Scan to enroll this device",
|
||||
fgColor, w/2, qrRect.Min.Y-lineH*2-8, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// LAN status pinned to the bottom-left corner.
|
||||
{
|
||||
lanY := h - lineH - 4
|
||||
var lanText string
|
||||
if st.lanIP != "" {
|
||||
lanText = "LAN IP: " + st.lanIP
|
||||
} else if st.lanMAC != "" {
|
||||
lanText = "Waiting for DHCP (" + st.lanMAC + ")"
|
||||
}
|
||||
if lanText != "" {
|
||||
face := basicfont.Face7x13
|
||||
textW := font.MeasureString(face, lanText).Ceil()
|
||||
small := image.NewRGBA(image.Rect(0, 0, textW, lineH))
|
||||
d := font.Drawer{
|
||||
Dst: small,
|
||||
Src: &image.Uniform{C: dimColor},
|
||||
Face: face,
|
||||
Dot: fixed.P(0, face.Metrics().Ascent.Ceil()),
|
||||
}
|
||||
d.DrawString(lanText)
|
||||
dstRect := image.Rect(4, lanY, 4+textW, lanY+lineH)
|
||||
draw.Draw(img, dstRect, small, image.Point{}, draw.Over)
|
||||
}
|
||||
}
|
||||
|
||||
st.fb.blit(img)
|
||||
}
|
||||
|
||||
// drawCenteredScaled draws s with basicfont.Face7x13 at the given
|
||||
// integer pixel scale, centered horizontally on x at top y, in col.
|
||||
func drawCenteredScaled(dst *image.RGBA, s string, col color.Color, x, y, scale int) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
face := basicfont.Face7x13
|
||||
width := font.MeasureString(face, s).Ceil()
|
||||
height := face.Metrics().Height.Ceil()
|
||||
|
||||
small := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
d := font.Drawer{
|
||||
Dst: small,
|
||||
Src: &image.Uniform{C: col},
|
||||
Face: face,
|
||||
Dot: fixed.P(0, face.Metrics().Ascent.Ceil()),
|
||||
}
|
||||
d.DrawString(s)
|
||||
|
||||
scaledW, scaledH := width*scale, height*scale
|
||||
dstRect := image.Rect(0, 0, scaledW, scaledH).Add(image.Point{
|
||||
X: x - scaledW/2,
|
||||
Y: y,
|
||||
})
|
||||
xdraw.NearestNeighbor.Scale(dst, dstRect, small, small.Bounds(), xdraw.Over, nil)
|
||||
}
|
||||
|
||||
func stateLabel(s ipn.State) string {
|
||||
switch s {
|
||||
case ipn.NoState, ipn.Starting:
|
||||
return "starting"
|
||||
case ipn.NeedsLogin:
|
||||
return "needs login"
|
||||
case ipn.NeedsMachineAuth:
|
||||
return "needs machine auth"
|
||||
case ipn.Stopped:
|
||||
return "stopped"
|
||||
case ipn.Running:
|
||||
return "running"
|
||||
}
|
||||
return strings.ToLower(s.String())
|
||||
}
|
||||
|
||||
// framebuffer is an mmap'd Linux framebuffer device.
|
||||
type framebuffer struct {
|
||||
f *os.File
|
||||
mem []byte
|
||||
width int
|
||||
height int
|
||||
bpp int
|
||||
lineLength int
|
||||
|
||||
// Bit offsets into a 32-bit pixel for each channel, from the
|
||||
// fb_bitfield values returned by FBIOGET_VSCREENINFO.
|
||||
redShift uint32
|
||||
greenShift uint32
|
||||
blueShift uint32
|
||||
}
|
||||
|
||||
// openFramebuffer opens path, queries dimensions and pixel format via
|
||||
// the FBIOGET_* ioctls, and mmaps the framebuffer memory.
|
||||
//
|
||||
// Only 32-bits-per-pixel framebuffers are supported. Raspberry Pi 3/4/5
|
||||
// default to that.
|
||||
func openFramebuffer(path string) (*framebuffer, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var (
|
||||
vbuf [160]byte // fb_var_screeninfo
|
||||
fbuf [80]byte // fb_fix_screeninfo
|
||||
)
|
||||
if err := ioctlGet(f, fbioGetVScreenInfo, vbuf[:]); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("FBIOGET_VSCREENINFO: %w", err)
|
||||
}
|
||||
if err := ioctlGet(f, fbioGetFScreenInfo, fbuf[:]); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("FBIOGET_FSCREENINFO: %w", err)
|
||||
}
|
||||
|
||||
fb := &framebuffer{
|
||||
f: f,
|
||||
width: int(binary.LittleEndian.Uint32(vbuf[vsOffXres:])),
|
||||
height: int(binary.LittleEndian.Uint32(vbuf[vsOffYres:])),
|
||||
bpp: int(binary.LittleEndian.Uint32(vbuf[vsOffBitsPerPixel:])),
|
||||
lineLength: int(binary.LittleEndian.Uint32(fbuf[fsOffLineLength:])),
|
||||
redShift: binary.LittleEndian.Uint32(vbuf[vsOffRedOffset:]),
|
||||
greenShift: binary.LittleEndian.Uint32(vbuf[vsOffGreenOffset:]),
|
||||
blueShift: binary.LittleEndian.Uint32(vbuf[vsOffBlueOffset:]),
|
||||
}
|
||||
if fb.bpp != 32 {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("unsupported framebuffer bpp %d (only 32 is supported)", fb.bpp)
|
||||
}
|
||||
|
||||
memLen := int(binary.LittleEndian.Uint32(fbuf[fsOffSmemLen:]))
|
||||
mem, err := unix.Mmap(int(f.Fd()), 0, memLen,
|
||||
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("mmap %s: %w", path, err)
|
||||
}
|
||||
fb.mem = mem
|
||||
return fb, nil
|
||||
}
|
||||
|
||||
func (fb *framebuffer) Close() error {
|
||||
if fb.mem != nil {
|
||||
unix.Munmap(fb.mem)
|
||||
fb.mem = nil
|
||||
}
|
||||
return fb.f.Close()
|
||||
}
|
||||
|
||||
// blit copies img into the mapped framebuffer, packing each
|
||||
// image.RGBA pixel into the framebuffer's per-channel bit layout.
|
||||
func (fb *framebuffer) blit(img *image.RGBA) {
|
||||
srcStride := img.Stride
|
||||
for y := 0; y < fb.height; y++ {
|
||||
srcRow := img.Pix[y*srcStride : y*srcStride+fb.width*4]
|
||||
dstRow := fb.mem[y*fb.lineLength:]
|
||||
for x := 0; x < fb.width; x++ {
|
||||
r := uint32(srcRow[x*4+0])
|
||||
g := uint32(srcRow[x*4+1])
|
||||
b := uint32(srcRow[x*4+2])
|
||||
px := r<<fb.redShift | g<<fb.greenShift | b<<fb.blueShift
|
||||
binary.LittleEndian.PutUint32(dstRow[x*4:], px)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// claimVTGraphics puts the active virtual terminal into KD_GRAPHICS so
|
||||
// the kernel's framebuffer console (fbcon) stops drawing on /dev/fb0
|
||||
// while fbstatus owns it. It returns a function that restores KD_TEXT.
|
||||
//
|
||||
// The Linux kernel applies VT mode to whatever VT is current; the open
|
||||
// path /dev/tty0 always refers to the foreground VT, which on a
|
||||
// headless gokrazy appliance is the only VT.
|
||||
func claimVTGraphics() (restore func(), err error) {
|
||||
f, err := os.OpenFile("/dev/tty0", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ioctlSetInt(f, kdSetMode, kdGraphics); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("KDSETMODE KD_GRAPHICS: %w", err)
|
||||
}
|
||||
return func() {
|
||||
if err := ioctlSetInt(f, kdSetMode, kdText); err != nil {
|
||||
log.Printf("KDSETMODE KD_TEXT on shutdown: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ioctlSetInt runs an ioctl with a single integer arg, like KDSETMODE.
|
||||
func ioctlSetInt(f *os.File, req uintptr, arg uintptr) error {
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), req, arg)
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ioctlGet runs an ioctl that fills a struct of len(buf) bytes in buf.
|
||||
// Used for the FBIOGET_* ioctls; on success buf holds the kernel's
|
||||
// fb_*_screeninfo struct.
|
||||
func ioctlGet(f *os.File, req uintptr, buf []byte) error {
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), req,
|
||||
uintptr(unsafe.Pointer(&buf[0])))
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stderr, "fbstatus is only supported on Linux (got %s)\n", runtime.GOOS)
|
||||
os.Exit(1)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -26,7 +26,7 @@ import (
|
||||
"github.com/tailscale/hujson"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
tsclient "tailscale.com/client/tailscale"
|
||||
_ "tailscale.com/feature/condregister/identityfederation"
|
||||
_ "tailscale.com/feature/identityfederation"
|
||||
"tailscale.com/internal/client/tailscale"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
@@ -255,7 +255,11 @@ func getCredentials() (*http.Client, string) {
|
||||
} else if idok && idToken != "" && oiok && oauthId != "" {
|
||||
if exchangeJWTForToken, ok := tailscale.HookExchangeJWTForTokenViaWIF.GetOk(); ok {
|
||||
var err error
|
||||
apiKeyEnv, err = exchangeJWTForToken(context.Background(), fmt.Sprintf("https://%s", *apiServer), oauthId, idToken)
|
||||
apiKeyEnv, err = exchangeJWTForToken(context.Background(), tailscale.ExchangeJWTForTokenWIFArgs{
|
||||
BaseURL: fmt.Sprintf("https://%s", *apiServer),
|
||||
ClientID: oauthId,
|
||||
IDToken: idToken,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
+5
-201
@@ -5,212 +5,16 @@
|
||||
package main // import "tailscale.com/cmd/hello"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/cmd/hello/helloserver"
|
||||
)
|
||||
|
||||
var (
|
||||
httpAddr = flag.String("http", ":80", "address to run an HTTP server on, or empty for none")
|
||||
httpsAddr = flag.String("https", ":443", "address to run an HTTPS server on, or empty for none")
|
||||
testIP = flag.String("test-ip", "", "if non-empty, look up IP and exit before running a server")
|
||||
)
|
||||
|
||||
//go:embed hello.tmpl.html
|
||||
var embeddedTemplate string
|
||||
|
||||
var localClient local.Client
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *testIP != "" {
|
||||
res, err := localClient.WhoIs(context.Background(), *testIP)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
e := json.NewEncoder(os.Stdout)
|
||||
e.SetIndent("", "\t")
|
||||
e.Encode(res)
|
||||
return
|
||||
s := &helloserver.Server{
|
||||
HTTPAddr: ":80",
|
||||
HTTPSAddr: ":443",
|
||||
}
|
||||
if devMode() {
|
||||
// Parse it optimistically
|
||||
var err error
|
||||
tmpl, err = template.New("home").Parse(embeddedTemplate)
|
||||
if err != nil {
|
||||
log.Printf("ignoring template error in dev mode: %v", err)
|
||||
}
|
||||
} else {
|
||||
if embeddedTemplate == "" {
|
||||
log.Fatalf("embeddedTemplate is empty; must be build with Go 1.16+")
|
||||
}
|
||||
tmpl = template.Must(template.New("home").Parse(embeddedTemplate))
|
||||
}
|
||||
|
||||
http.HandleFunc("/", root)
|
||||
log.Printf("Starting hello server.")
|
||||
|
||||
errc := make(chan error, 1)
|
||||
if *httpAddr != "" {
|
||||
log.Printf("running HTTP server on %s", *httpAddr)
|
||||
go func() {
|
||||
errc <- http.ListenAndServe(*httpAddr, nil)
|
||||
}()
|
||||
}
|
||||
if *httpsAddr != "" {
|
||||
log.Printf("running HTTPS server on %s", *httpsAddr)
|
||||
go func() {
|
||||
hs := &http.Server{
|
||||
Addr: *httpsAddr,
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
switch hi.ServerName {
|
||||
case "hello.ts.net":
|
||||
return localClient.GetCertificate(hi)
|
||||
case "hello.ipn.dev":
|
||||
c, err := tls.LoadX509KeyPair(
|
||||
"/etc/hello/hello.ipn.dev.crt",
|
||||
"/etc/hello/hello.ipn.dev.key",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
return nil, errors.New("invalid SNI name")
|
||||
},
|
||||
},
|
||||
IdleTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 20 * time.Second,
|
||||
MaxHeaderBytes: 10 << 10,
|
||||
}
|
||||
errc <- hs.ListenAndServeTLS("", "")
|
||||
}()
|
||||
}
|
||||
log.Fatal(<-errc)
|
||||
}
|
||||
|
||||
func devMode() bool { return *httpsAddr == "" && *httpAddr != "" }
|
||||
|
||||
func getTmpl() (*template.Template, error) {
|
||||
if devMode() {
|
||||
tmplData, err := os.ReadFile("hello.tmpl.html")
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("using baked-in template in dev mode; can't find hello.tmpl.html in current directory")
|
||||
return tmpl, nil
|
||||
}
|
||||
return template.New("home").Parse(string(tmplData))
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// tmpl is the template used in prod mode.
|
||||
// In dev mode it's only used if the template file doesn't exist on disk.
|
||||
// It's initialized by main after flag parsing.
|
||||
var tmpl *template.Template
|
||||
|
||||
type tmplData struct {
|
||||
DisplayName string // "Foo Barberson"
|
||||
LoginName string // "foo@bar.com"
|
||||
ProfilePicURL string // "https://..."
|
||||
MachineName string // "imac5k"
|
||||
MachineOS string // "Linux"
|
||||
IP string // "100.2.3.4"
|
||||
}
|
||||
|
||||
func tailscaleIP(who *apitype.WhoIsResponse) string {
|
||||
if who == nil {
|
||||
return ""
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
for _, nodeIP := range who.Node.Addresses {
|
||||
if nodeIP.IsSingleIP() {
|
||||
return nodeIP.Addr().String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func root(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS == nil && *httpsAddr != "" {
|
||||
host := r.Host
|
||||
if strings.Contains(r.Host, "100.101.102.103") ||
|
||||
strings.Contains(r.Host, "hello.ipn.dev") {
|
||||
host = "hello.ts.net"
|
||||
}
|
||||
http.Redirect(w, r, "https://"+host, http.StatusFound)
|
||||
return
|
||||
}
|
||||
if r.RequestURI != "/" {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if r.TLS != nil && *httpsAddr != "" && strings.Contains(r.Host, "hello.ipn.dev") {
|
||||
http.Redirect(w, r, "https://hello.ts.net", http.StatusFound)
|
||||
return
|
||||
}
|
||||
tmpl, err := getTmpl()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
http.Error(w, "template error: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
who, err := localClient.WhoIs(r.Context(), r.RemoteAddr)
|
||||
var data tmplData
|
||||
if err != nil {
|
||||
if devMode() {
|
||||
log.Printf("warning: using fake data in dev mode due to whois lookup error: %v", err)
|
||||
data = tmplData{
|
||||
DisplayName: "Taily Scalerson",
|
||||
LoginName: "taily@scaler.son",
|
||||
ProfilePicURL: "https://placekitten.com/200/200",
|
||||
MachineName: "scaled",
|
||||
MachineOS: "Linux",
|
||||
IP: "100.1.2.3",
|
||||
}
|
||||
} else {
|
||||
log.Printf("whois(%q) error: %v", r.RemoteAddr, err)
|
||||
http.Error(w, "Your Tailscale works, but we failed to look you up.", 500)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
data = tmplData{
|
||||
DisplayName: who.UserProfile.DisplayName,
|
||||
LoginName: who.UserProfile.LoginName,
|
||||
ProfilePicURL: who.UserProfile.ProfilePicURL,
|
||||
MachineName: firstLabel(who.Node.ComputedName),
|
||||
MachineOS: who.Node.Hostinfo.OS(),
|
||||
IP: tailscaleIP(who),
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
// firstLabel s up until the first period, if any.
|
||||
func firstLabel(s string) string {
|
||||
s, _, _ = strings.Cut(s, ".")
|
||||
return s
|
||||
log.Fatal(s.Run())
|
||||
}
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<title>Hello from Tailscale</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: #dad6d5;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 24rem;
|
||||
width: 95%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.px-2 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.pl-3 {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.pr-3 {
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.pt-4 {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 0.5rem;
|
||||
;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.width-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.min-width-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.border-t-1 {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.border-gray-100 {
|
||||
border-color: #f7f5f4;
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
border-color: #eeebea;
|
||||
}
|
||||
|
||||
.border-gray-300 {
|
||||
border-color: #dad6d5;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.bg-gray-0 {
|
||||
background-color: #faf9f8;
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
background-color: #f7f5f4;
|
||||
}
|
||||
|
||||
.text-green-600 {
|
||||
color: #0d4b3b;
|
||||
}
|
||||
|
||||
.text-blue-600 {
|
||||
color: #3f5db3;
|
||||
}
|
||||
|
||||
.hover\:text-blue-800:hover {
|
||||
color: #253570;
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
color: #444342;
|
||||
}
|
||||
|
||||
.text-gray-700 {
|
||||
color: #2e2d2d;
|
||||
}
|
||||
|
||||
.text-gray-800 {
|
||||
color: #232222;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.font-title {
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-regular {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-pic {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 9999px;
|
||||
background-size: cover;
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.animate .panel {
|
||||
transform: translateY(10%);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.0), 0 10px 10px -5px rgba(0, 0, 0, 0.0);
|
||||
transition: transform 1200ms ease, opacity 1200ms ease, box-shadow 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .panel-interior {
|
||||
opacity: 0.0;
|
||||
transition: opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .logo {
|
||||
transform: translateY(2rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .header-title {
|
||||
transform: translateY(1.6rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .header-text {
|
||||
transform: translateY(1.2rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .footer {
|
||||
transform: translateY(-0.5rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animating .panel {
|
||||
transform: translateY(0);
|
||||
opacity: 1.0;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.animating .panel-interior {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.animating .spinner {
|
||||
opacity: 0.0;
|
||||
}
|
||||
|
||||
.animating .logo,
|
||||
.animating .header-title,
|
||||
.animating .header-text,
|
||||
.animating .footer {
|
||||
transform: translateY(0);
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
align-items: center;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.spinner span {
|
||||
display: inline-block;
|
||||
background-color: currentColor;
|
||||
border-radius: 9999px;
|
||||
animation-name: loading-dots-blink;
|
||||
animation-duration: 1.4s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-fill-mode: both;
|
||||
width: 0.35em;
|
||||
height: 0.35em;
|
||||
margin: 0 0.15em;
|
||||
}
|
||||
|
||||
.spinner span:nth-child(2) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.spinner span:nth-child(3) {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.animate .spinner {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@keyframes loading-dots-blink {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
* {
|
||||
animation-duration: 0ms !important;
|
||||
transition-duration: 0ms !important;
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<script>
|
||||
(function() {
|
||||
var lastSeen = localStorage.getItem("lastSeen");
|
||||
if (!lastSeen) {
|
||||
document.body.classList.add("animate");
|
||||
window.addEventListener("load", function () {
|
||||
setTimeout(function () {
|
||||
document.body.classList.add("animating");
|
||||
localStorage.setItem("lastSeen", Date.now());
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<main class="text-gray-800">
|
||||
<svg class="logo mb-6" width="28" height="28" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle opacity="0.2" cx="3.4" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="3.4" cy="19.5" r="2.7" fill="currentColor" />
|
||||
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="11.5" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="19.5" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="19.5" cy="19.5" r="2.7" fill="currentColor" />
|
||||
</svg>
|
||||
<header class="mb-8 text-center">
|
||||
<h1 class="header-title font-title font-semibold mb-2">You're connected over Tailscale!</h1>
|
||||
<p class="header-text">This device is signed in as…</p>
|
||||
</header>
|
||||
<div class="panel relative bg-white rounded-lg width-full shadow-xl mb-8 p-4">
|
||||
<div class="spinner text-gray-600">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="panel-interior flex items-center width-full min-width-0 p-2 mb-4">
|
||||
<div class="profile-pic bg-gray-100" style="background-image: url({{.ProfilePicURL}});"></div>
|
||||
<div class="overflow-hidden">
|
||||
{{ with .DisplayName }}
|
||||
<h4 class="font-semibold truncate">{{.}}</h4>
|
||||
{{ end }}
|
||||
<h5 class="text-gray-600 truncate">{{.LoginName}}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="panel-interior border border-gray-200 bg-gray-0 rounded-lg p-2 pl-3 pr-3 mb-2 width-full flex justify-between items-center">
|
||||
<div class="flex items-center min-width-0">
|
||||
<svg class="text-gray-600 mr-2" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
</svg>
|
||||
<h4 class="font-semibold truncate mr-2">{{.MachineName}}</h4>
|
||||
</div>
|
||||
<h5>{{.IP}}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer text-gray-600 text-center mb-12">
|
||||
<p>Read about <a href="https://tailscale.com/kb/1017/install#advanced-features" class="text-blue-600 hover:text-blue-800"
|
||||
target="_blank">what you can do next →</a></p>
|
||||
<p>Read about <a href="https://tailscale.com/kb/1073/hello" class="text-blue-600 hover:text-blue-800"
|
||||
target="_blank">the Hello service →</a></p>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<title>Hello from Tailscale</title>
|
||||
<link type="text/css" rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/script.js" defer></script>
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<main class="text-gray-800">
|
||||
<svg class="logo mb-6" width="28" height="28" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle opacity="0.2" cx="3.4" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="3.4" cy="19.5" r="2.7" fill="currentColor" />
|
||||
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="11.5" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="19.5" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="19.5" cy="19.5" r="2.7" fill="currentColor" />
|
||||
</svg>
|
||||
<header class="mb-8 text-center">
|
||||
<h1 class="header-title font-title font-semibold mb-2">You're connected over Tailscale!</h1>
|
||||
<p class="header-text">This device is signed in as…</p>
|
||||
</header>
|
||||
<div class="panel relative bg-white rounded-lg width-full shadow-xl mb-8 p-4">
|
||||
<div class="spinner text-gray-600">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="panel-interior flex items-center width-full min-width-0 p-2 mb-4">
|
||||
<div class="profile-pic bg-gray-100">
|
||||
<img
|
||||
src="{{.ProfilePicURL}}"
|
||||
alt="Profile picture"
|
||||
class="profile-pic-img"
|
||||
>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
{{ with .DisplayName }}
|
||||
<h4 class="font-semibold truncate">{{.}}</h4>
|
||||
{{ end }}
|
||||
<h5 class="text-gray-600 truncate">{{.LoginName}}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="panel-interior border border-gray-200 bg-gray-0 rounded-lg p-2 pl-3 pr-3 mb-2 width-full flex justify-between items-center">
|
||||
<div class="flex items-center min-width-0">
|
||||
<svg class="text-gray-600 mr-2" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
</svg>
|
||||
<h4 class="font-semibold truncate mr-2">{{.MachineName}}</h4>
|
||||
</div>
|
||||
<h5>{{.IP}}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer text-gray-600 text-center mb-12">
|
||||
<p>Read about <a href="https://tailscale.com/kb/1017/install#advanced-features" class="text-blue-600 hover:text-blue-800"
|
||||
target="_blank">what you can do next →</a></p>
|
||||
<p>Read about <a href="https://tailscale.com/kb/1073/hello" class="text-blue-600 hover:text-blue-800"
|
||||
target="_blank">the Hello service →</a></p>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package helloserver implements the HTTP server behind hello.ts.net.
|
||||
package helloserver
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
//go:embed hello.tmpl.html
|
||||
var embeddedTemplate string
|
||||
|
||||
//go:embed static/*
|
||||
var staticFiles embed.FS
|
||||
|
||||
var staticHandler = http.FileServerFS(staticFiles)
|
||||
|
||||
var tmpl = template.Must(template.New("home").Parse(embeddedTemplate))
|
||||
|
||||
// Server is an HTTP server for hello.ts.net.
|
||||
//
|
||||
// The zero value is not valid; populate at least one of HTTPAddr or HTTPSAddr
|
||||
// before calling Run.
|
||||
type Server struct {
|
||||
// HTTPAddr is the address to run an HTTP server on, or empty for none.
|
||||
HTTPAddr string
|
||||
|
||||
// HTTPSAddr is the address to run an HTTPS server on, or empty for none.
|
||||
HTTPSAddr string
|
||||
|
||||
// LocalClient is used to look up the identity of incoming requests and
|
||||
// to obtain TLS certificates. If nil, the zero value of local.Client is
|
||||
// used.
|
||||
LocalClient *local.Client
|
||||
}
|
||||
|
||||
func (s *Server) localClient() *local.Client {
|
||||
if s.LocalClient != nil {
|
||||
return s.LocalClient
|
||||
}
|
||||
return &local.Client{}
|
||||
}
|
||||
|
||||
// Run starts the configured HTTP and HTTPS servers and blocks until one of
|
||||
// them returns an error.
|
||||
func (s *Server) Run() error {
|
||||
errc := make(chan error, 1)
|
||||
if s.HTTPAddr != "" {
|
||||
log.Printf("running HTTP server on %s", s.HTTPAddr)
|
||||
go func() {
|
||||
errc <- http.ListenAndServe(s.HTTPAddr, s)
|
||||
}()
|
||||
}
|
||||
if s.HTTPSAddr != "" {
|
||||
log.Printf("running HTTPS server on %s", s.HTTPSAddr)
|
||||
go func() {
|
||||
hs := &http.Server{
|
||||
Addr: s.HTTPSAddr,
|
||||
Handler: s,
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: s.localClient().GetCertificate,
|
||||
},
|
||||
IdleTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 20 * time.Second,
|
||||
MaxHeaderBytes: 10 << 10,
|
||||
}
|
||||
errc <- hs.ListenAndServeTLS("", "")
|
||||
}()
|
||||
}
|
||||
return <-errc
|
||||
}
|
||||
|
||||
type tmplData struct {
|
||||
DisplayName string // "Foo Barberson"
|
||||
LoginName string // "foo@bar.com"
|
||||
ProfilePicURL string // "https://..."
|
||||
MachineName string // "imac5k"
|
||||
MachineOS string // "Linux"
|
||||
IP string // "100.2.3.4"
|
||||
}
|
||||
|
||||
func tailscaleIP(who *apitype.WhoIsResponse) string {
|
||||
if who == nil {
|
||||
return ""
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
for _, nodeIP := range who.Node.Addresses {
|
||||
if nodeIP.IsSingleIP() {
|
||||
return nodeIP.Addr().String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS == nil && s.HTTPSAddr != "" {
|
||||
host := r.Host
|
||||
if strings.Contains(r.Host, "100.101.102.103") {
|
||||
host = "hello.ts.net"
|
||||
}
|
||||
http.Redirect(w, r, "https://"+host, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.RequestURI, "/static/") {
|
||||
staticHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.RequestURI != "/" {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
who, err := s.localClient().WhoIs(r.Context(), r.RemoteAddr)
|
||||
if err != nil {
|
||||
log.Printf("whois(%q) error: %v", r.RemoteAddr, err)
|
||||
http.Error(w, "Your Tailscale works, but we failed to look you up.", 500)
|
||||
return
|
||||
}
|
||||
data := tmplData{
|
||||
DisplayName: who.UserProfile.DisplayName,
|
||||
LoginName: who.UserProfile.LoginName,
|
||||
ProfilePicURL: who.UserProfile.ProfilePicURL,
|
||||
MachineName: firstLabel(who.Node.ComputedName),
|
||||
MachineOS: who.Node.Hostinfo.OS(),
|
||||
IP: tailscaleIP(who),
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
// firstLabel returns s up until the first period, if any.
|
||||
func firstLabel(s string) string {
|
||||
s, _, _ = strings.Cut(s, ".")
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
(function () {
|
||||
var lastSeen = localStorage.getItem("lastSeen");
|
||||
if (!lastSeen) {
|
||||
document.body.classList.add("animate");
|
||||
window.addEventListener("load", function () {
|
||||
setTimeout(function () {
|
||||
document.body.classList.add("animating");
|
||||
localStorage.setItem("lastSeen", Date.now());
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,366 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: #dad6d5;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 24rem;
|
||||
width: 95%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.px-2 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.pl-3 {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.pr-3 {
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.pt-4 {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 0.5rem;
|
||||
;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.width-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.min-width-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.border-t-1 {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.border-gray-100 {
|
||||
border-color: #f7f5f4;
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
border-color: #eeebea;
|
||||
}
|
||||
|
||||
.border-gray-300 {
|
||||
border-color: #dad6d5;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.bg-gray-0 {
|
||||
background-color: #faf9f8;
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
background-color: #f7f5f4;
|
||||
}
|
||||
|
||||
.text-green-600 {
|
||||
color: #0d4b3b;
|
||||
}
|
||||
|
||||
.text-blue-600 {
|
||||
color: #3f5db3;
|
||||
}
|
||||
|
||||
.hover\:text-blue-800:hover {
|
||||
color: #253570;
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
color: #444342;
|
||||
}
|
||||
|
||||
.text-gray-700 {
|
||||
color: #2e2d2d;
|
||||
}
|
||||
|
||||
.text-gray-800 {
|
||||
color: #232222;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.font-title {
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-regular {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-pic {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background-size: cover;
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-pic-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.animate .panel {
|
||||
transform: translateY(10%);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.0), 0 10px 10px -5px rgba(0, 0, 0, 0.0);
|
||||
transition: transform 1200ms ease, opacity 1200ms ease, box-shadow 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .panel-interior {
|
||||
opacity: 0.0;
|
||||
transition: opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .logo {
|
||||
transform: translateY(2rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .header-title {
|
||||
transform: translateY(1.6rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .header-text {
|
||||
transform: translateY(1.2rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .footer {
|
||||
transform: translateY(-0.5rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animating .panel {
|
||||
transform: translateY(0);
|
||||
opacity: 1.0;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.animating .panel-interior {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.animating .spinner {
|
||||
opacity: 0.0;
|
||||
}
|
||||
|
||||
.animating .logo,
|
||||
.animating .header-title,
|
||||
.animating .header-text,
|
||||
.animating .footer {
|
||||
transform: translateY(0);
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
align-items: center;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.spinner span {
|
||||
display: inline-block;
|
||||
background-color: currentColor;
|
||||
border-radius: 9999px;
|
||||
animation-name: loading-dots-blink;
|
||||
animation-duration: 1.4s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-fill-mode: both;
|
||||
width: 0.35em;
|
||||
height: 0.35em;
|
||||
margin: 0 0.15em;
|
||||
}
|
||||
|
||||
.spinner span:nth-child(2) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.spinner span:nth-child(3) {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.animate .spinner {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@keyframes loading-dots-blink {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
* {
|
||||
animation-duration: 0ms !important;
|
||||
transition-duration: 0ms !important;
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
const (
|
||||
// tsNetDomain is the domain that this DNS nameserver has registered a handler for.
|
||||
tsNetDomain = "ts.net"
|
||||
// addr is the the address that the UDP and TCP listeners will listen on.
|
||||
// addr is the address that the UDP and TCP listeners will listen on.
|
||||
addr = ":1053"
|
||||
// defaultTTL is the default TTL for DNS records in seconds.
|
||||
// Set to 0 to disable caching. Can be increased when usage patterns are better understood.
|
||||
|
||||
@@ -436,14 +436,16 @@ func exclusiveOwnerAnnotations(pg *tsapi.ProxyGroup, operatorID string, svc *tai
|
||||
}
|
||||
if svc == nil {
|
||||
c := ownerAnnotationValue{OwnerRefs: []OwnerRef{ref}}
|
||||
json, err := json.Marshal(c)
|
||||
data, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] unable to marshal Tailscale Service's owner annotation contents: %w, please report this", err)
|
||||
return nil, fmt.Errorf("failed to marshal Tailscale Service's owner annotation contents: %w", err)
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
ownerAnnotation: string(json),
|
||||
ownerAnnotation: string(data),
|
||||
}, nil
|
||||
}
|
||||
|
||||
o, err := parseOwnerAnnotation(svc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -451,15 +453,19 @@ func exclusiveOwnerAnnotations(pg *tsapi.ProxyGroup, operatorID string, svc *tai
|
||||
if o == nil || len(o.OwnerRefs) == 0 {
|
||||
return nil, fmt.Errorf("Tailscale Service %s exists, but does not contain owner annotation with owner references; not proceeding as this is likely a resource created by something other than the Tailscale Kubernetes operator", svc.Name)
|
||||
}
|
||||
|
||||
if len(o.OwnerRefs) > 1 || o.OwnerRefs[0].OperatorID != operatorID {
|
||||
return nil, fmt.Errorf("Tailscale Service %s is already owned by other operator(s) and cannot be shared across multiple clusters; configure a difference Service name to continue", svc.Name)
|
||||
}
|
||||
|
||||
if o.OwnerRefs[0].Resource == nil {
|
||||
return nil, fmt.Errorf("Tailscale Service %s exists, but does not reference an owning resource; not proceeding as this is likely a Service already owned by an Ingress", svc.Name)
|
||||
}
|
||||
|
||||
if o.OwnerRefs[0].Resource.Kind != "ProxyGroup" || o.OwnerRefs[0].Resource.UID != string(pg.UID) {
|
||||
return nil, fmt.Errorf("Tailscale Service %s is already owned by another resource: %#v; configure a difference Service name to continue", svc.Name, o.OwnerRefs[0].Resource)
|
||||
}
|
||||
|
||||
if o.OwnerRefs[0].Resource.Name != pg.Name {
|
||||
// ProxyGroup name can be updated in place.
|
||||
o.OwnerRefs[0].Resource.Name = pg.Name
|
||||
|
||||
@@ -29,6 +29,8 @@ import (
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
@@ -356,6 +358,11 @@ func validateRoutes(routes tsapi.Routes) error {
|
||||
if pfx.Masked() != pfx {
|
||||
errs = append(errs, fmt.Errorf("route %s has non-address bits set; expected %s", pfx, pfx.Masked()))
|
||||
}
|
||||
if tsaddr.IsViaPrefix(pfx) {
|
||||
if err := netutil.ValidateViaPrefix(pfx); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -145,6 +145,22 @@ func TestConnector(t *testing.T) {
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
|
||||
|
||||
// Set an invalid 4via6 route (site ID too large).
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"fd7a:115c:a1e0:b1a:1:0:a2c:0/116"}
|
||||
})
|
||||
expectReconciled(t, cr, "", "test")
|
||||
// STS should still have the previous valid route, unchanged.
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
|
||||
|
||||
// Set a valid 4via6 route.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"fd7a:115c:a1e0:b1a:0:1:a2c:0/116"}
|
||||
})
|
||||
opts.subnetRoutes = "fd7a:115c:a1e0:b1a:0:1:a2c:0/116"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
|
||||
|
||||
// Delete the Connector.
|
||||
if err = fc.Delete(context.Background(), cn); err != nil {
|
||||
t.Fatalf("error deleting Connector: %v", err)
|
||||
|
||||
+47
-114
@@ -6,84 +6,12 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
|
||||
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
|
||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
||||
github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/defaults+
|
||||
github.com/aws/aws-sdk-go-v2/aws/defaults from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/aws/middleware from github.com/aws/aws-sdk-go-v2/aws/retry+
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/query from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/restjson from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/xml from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/aws/ratelimit from github.com/aws/aws-sdk-go-v2/aws/retry
|
||||
github.com/aws/aws-sdk-go-v2/aws/retry from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client+
|
||||
github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 from github.com/aws/aws-sdk-go-v2/aws/signer/v4
|
||||
github.com/aws/aws-sdk-go-v2/aws/signer/v4 from github.com/aws/aws-sdk-go-v2/internal/auth/smithy+
|
||||
github.com/aws/aws-sdk-go-v2/aws/transport/http from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/config from tailscale.com/wif
|
||||
github.com/aws/aws-sdk-go-v2/credentials from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds
|
||||
github.com/aws/aws-sdk-go-v2/credentials/processcreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/ssocreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/stscreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds/internal/config from github.com/aws/aws-sdk-go-v2/feature/ec2/imds
|
||||
github.com/aws/aws-sdk-go-v2/internal/auth from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
|
||||
github.com/aws/aws-sdk-go-v2/internal/auth/smithy from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/context from github.com/aws/aws-sdk-go-v2/aws/retry+
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 from github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints+
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/internal/middleware from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/rand from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/aws-sdk-go-v2/internal/sdk from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/aws-sdk-go-v2/internal/sdkio from github.com/aws/aws-sdk-go-v2/credentials/processcreds
|
||||
github.com/aws/aws-sdk-go-v2/internal/shareddefaults from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/internal/strings from github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4
|
||||
github.com/aws/aws-sdk-go-v2/internal/sync/singleflight from github.com/aws/aws-sdk-go-v2/aws
|
||||
github.com/aws/aws-sdk-go-v2/internal/timeconv from github.com/aws/aws-sdk-go-v2/aws/retry
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/service/sso from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso
|
||||
github.com/aws/aws-sdk-go-v2/service/sso/types from github.com/aws/aws-sdk-go-v2/service/sso
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssooidc
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc/types from github.com/aws/aws-sdk-go-v2/service/ssooidc
|
||||
github.com/aws/aws-sdk-go-v2/service/sts from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/service/sts/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/service/sts/types from github.com/aws/aws-sdk-go-v2/credentials/stscreds+
|
||||
github.com/aws/smithy-go from github.com/aws/aws-sdk-go-v2/aws/protocol/restjson+
|
||||
github.com/aws/smithy-go/auth from github.com/aws/aws-sdk-go-v2/internal/auth+
|
||||
github.com/aws/smithy-go/auth/bearer from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/context from github.com/aws/smithy-go/auth/bearer
|
||||
github.com/aws/smithy-go/document from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/smithy-go/encoding from github.com/aws/smithy-go/encoding/json+
|
||||
github.com/aws/smithy-go/encoding/httpbinding from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
|
||||
github.com/aws/smithy-go/encoding/json from github.com/aws/aws-sdk-go-v2/service/ssooidc
|
||||
github.com/aws/smithy-go/encoding/xml from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/smithy-go/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/smithy-go/endpoints/private/rulesfn from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/smithy-go/internal/sync/singleflight from github.com/aws/smithy-go/auth/bearer
|
||||
github.com/aws/smithy-go/io from github.com/aws/aws-sdk-go-v2/feature/ec2/imds+
|
||||
github.com/aws/smithy-go/logging from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/metrics from github.com/aws/aws-sdk-go-v2/aws/retry+
|
||||
github.com/aws/smithy-go/middleware from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/private/requestcompression from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/smithy-go/ptr from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/rand from github.com/aws/aws-sdk-go-v2/aws/middleware
|
||||
github.com/aws/smithy-go/time from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/smithy-go/tracing from github.com/aws/aws-sdk-go-v2/aws/middleware+
|
||||
github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
|
||||
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
|
||||
github.com/blang/semver/v4 from k8s.io/component-base/metrics
|
||||
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus+
|
||||
github.com/coder/websocket from tailscale.com/util/eventbus
|
||||
github.com/coder/websocket/internal/errd from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/util from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
|
||||
github.com/creachadair/msync/trigger from tailscale.com/logtail
|
||||
💣 github.com/davecgh/go-spew/spew from k8s.io/apimachinery/pkg/util/dump
|
||||
W 💣 github.com/dblohm7/wingoes from tailscale.com/net/tshttpproxy+
|
||||
@@ -113,6 +41,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+
|
||||
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+
|
||||
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+
|
||||
github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck+
|
||||
github.com/go-logr/logr from github.com/go-logr/logr/slogr+
|
||||
github.com/go-logr/logr/slogr from github.com/go-logr/zapr
|
||||
github.com/go-logr/zapr from sigs.k8s.io/controller-runtime/pkg/log/zap+
|
||||
@@ -130,7 +59,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
github.com/google/gnostic-models/jsonschema from github.com/google/gnostic-models/compiler
|
||||
github.com/google/gnostic-models/openapiv2 from k8s.io/client-go/discovery+
|
||||
github.com/google/gnostic-models/openapiv3 from k8s.io/kube-openapi/pkg/handler3+
|
||||
github.com/google/uuid from github.com/prometheus-community/pro-bing+
|
||||
github.com/google/uuid from k8s.io/apimachinery/pkg/util/uuid+
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
||||
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
|
||||
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
||||
@@ -164,7 +93,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal+
|
||||
github.com/pkg/errors from github.com/evanphx/json-patch/v5+
|
||||
github.com/pmezard/go-difflib/difflib from k8s.io/apimachinery/pkg/util/diff
|
||||
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
|
||||
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil from github.com/prometheus/client_golang/prometheus/promhttp
|
||||
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil/header from github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil
|
||||
💣 github.com/prometheus/client_golang/prometheus from github.com/prometheus/client_golang/prometheus/collectors+
|
||||
@@ -180,7 +108,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
|
||||
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
|
||||
github.com/spf13/pflag from k8s.io/client-go/tools/clientcmd+
|
||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
|
||||
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
|
||||
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
|
||||
@@ -802,22 +730,23 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/envknob from tailscale.com/client/local+
|
||||
tailscale.com/envknob/featureknob from tailscale.com/client/web+
|
||||
tailscale.com/feature from tailscale.com/ipn/ipnext+
|
||||
tailscale.com/feature/acme from tailscale.com/tsnet
|
||||
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/feature/c2n from tailscale.com/tsnet
|
||||
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/feature/condregister/identityfederation from tailscale.com/tsnet
|
||||
tailscale.com/feature/condregister/netlog from tailscale.com/tsnet
|
||||
tailscale.com/feature/condregister/oauthkey from tailscale.com/tsnet
|
||||
tailscale.com/feature/condregister/portmapper from tailscale.com/tsnet
|
||||
tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet
|
||||
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
|
||||
tailscale.com/feature/netlog from tailscale.com/feature/condregister/netlog
|
||||
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
|
||||
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
|
||||
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
|
||||
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
|
||||
tailscale.com/feature/useproxy from tailscale.com/feature/condregister/useproxy
|
||||
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/feature/identityfederation+
|
||||
tailscale.com/internal/client/tailscale from tailscale.com/feature/oauthkey+
|
||||
tailscale.com/ipn from tailscale.com/client/local+
|
||||
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
|
||||
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
|
||||
@@ -826,16 +755,18 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/ipn/ipnlocal/netmapcache from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
|
||||
tailscale.com/ipn/localapi from tailscale.com/tsnet
|
||||
tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/ipn/store from tailscale.com/ipn/store/kubestore+
|
||||
tailscale.com/ipn/store/kubestore from tailscale.com/cmd/k8s-operator
|
||||
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/k8s-operator from tailscale.com/cmd/k8s-operator+
|
||||
tailscale.com/k8s-operator/api-proxy from tailscale.com/cmd/k8s-operator
|
||||
tailscale.com/k8s-operator/apis from tailscale.com/k8s-operator/apis/v1alpha1
|
||||
tailscale.com/k8s-operator/apis/v1alpha1 from tailscale.com/cmd/k8s-operator+
|
||||
tailscale.com/k8s-operator/reconciler from tailscale.com/k8s-operator/reconciler/tailnet
|
||||
tailscale.com/k8s-operator/reconciler from tailscale.com/k8s-operator/reconciler/tailnet+
|
||||
tailscale.com/k8s-operator/reconciler/peerrelay from tailscale.com/cmd/k8s-operator
|
||||
tailscale.com/k8s-operator/reconciler/proxygrouppolicy from tailscale.com/cmd/k8s-operator
|
||||
tailscale.com/k8s-operator/reconciler/tailnet from tailscale.com/cmd/k8s-operator
|
||||
tailscale.com/k8s-operator/reconciler/tailscaled from tailscale.com/k8s-operator/reconciler/peerrelay
|
||||
tailscale.com/k8s-operator/sessionrecording from tailscale.com/k8s-operator/api-proxy
|
||||
tailscale.com/k8s-operator/sessionrecording/spdy from tailscale.com/k8s-operator/sessionrecording
|
||||
tailscale.com/k8s-operator/sessionrecording/tsrecorder from tailscale.com/k8s-operator/sessionrecording+
|
||||
@@ -856,7 +787,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/metrics from tailscale.com/tsweb+
|
||||
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+
|
||||
💣 tailscale.com/net/batching from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dns/publicdns from tailscale.com/net/dns+
|
||||
tailscale.com/net/dns/resolvconffile from tailscale.com/cmd/k8s-operator+
|
||||
@@ -867,7 +797,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/memnet from tailscale.com/tsnet
|
||||
tailscale.com/net/netaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/netcheck from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
|
||||
tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/net/netknob from tailscale.com/logpolicy+
|
||||
@@ -881,12 +811,16 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
|
||||
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/proxymux from tailscale.com/tsnet
|
||||
tailscale.com/net/routecheck from tailscale.com/client/local+
|
||||
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
|
||||
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/socks5 from tailscale.com/tsnet
|
||||
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/stun from tailscale.com/ipn/localapi+
|
||||
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
|
||||
tailscale.com/net/traffic from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/tsaddr from tailscale.com/client/web+
|
||||
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
|
||||
@@ -900,7 +834,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/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/tempfork/acme from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/tempfork/acme from tailscale.com/feature/acme
|
||||
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/tempfork/httprec from tailscale.com/feature/c2n
|
||||
tailscale.com/tka from tailscale.com/client/local+
|
||||
@@ -910,7 +844,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/tstime from tailscale.com/cmd/k8s-operator+
|
||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
||||
tailscale.com/tsweb from tailscale.com/util/eventbus
|
||||
tailscale.com/tsweb from tailscale.com/util/eventbus+
|
||||
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+
|
||||
@@ -922,7 +856,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/types/lazy from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/types/logger from tailscale.com/appc+
|
||||
tailscale.com/types/logid from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/types/mapx from tailscale.com/ipn/ipnext
|
||||
tailscale.com/types/mapx from tailscale.com/ipn/ipnext+
|
||||
tailscale.com/types/netlogfunc from tailscale.com/net/tstun+
|
||||
tailscale.com/types/netlogtype from tailscale.com/wgengine/netlog
|
||||
tailscale.com/types/netmap from tailscale.com/control/controlclient+
|
||||
@@ -944,6 +878,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
LW tailscale.com/util/cmpver from tailscale.com/net/dns+
|
||||
tailscale.com/util/ctxkey from tailscale.com/client/tailscale/apitype+
|
||||
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
|
||||
tailscale.com/util/def from tailscale.com/ipn/localapi
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/appc+
|
||||
tailscale.com/util/eventbus from tailscale.com/tsd+
|
||||
@@ -966,16 +901,15 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/util/set from tailscale.com/cmd/k8s-operator+
|
||||
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/slicesx from tailscale.com/appc+
|
||||
tailscale.com/util/syspolicy from tailscale.com/feature/syspolicy
|
||||
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
|
||||
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
|
||||
tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
|
||||
tailscale.com/util/syspolicy/pkey from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/syspolicy/policyclient from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/syspolicy/ptype from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
|
||||
tailscale.com/util/syspolicy/ptype from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/syspolicy/rsop from tailscale.com/ipn/localapi
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/client/local+
|
||||
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy/rsop
|
||||
tailscale.com/util/testenv from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/truncate from tailscale.com/logtail
|
||||
tailscale.com/util/usermetric from tailscale.com/health+
|
||||
@@ -992,15 +926,13 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/wgengine/filter from tailscale.com/control/controlclient+
|
||||
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap+
|
||||
💣 tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/netlog from tailscale.com/wgengine
|
||||
tailscale.com/wgengine/netlog from tailscale.com/feature/netlog
|
||||
tailscale.com/wgengine/netstack from tailscale.com/tsnet
|
||||
tailscale.com/wgengine/netstack/gro from tailscale.com/net/tstun+
|
||||
tailscale.com/wgengine/router from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
|
||||
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine+
|
||||
tailscale.com/wgengine/wglog from tailscale.com/wgengine
|
||||
tailscale.com/wif from tailscale.com/feature/identityfederation
|
||||
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+
|
||||
@@ -1023,19 +955,20 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
|
||||
golang.org/x/net/http2 from k8s.io/apimachinery/pkg/util/net+
|
||||
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/icmp from tailscale.com/net/ping
|
||||
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/httpsfv 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/socket from golang.org/x/net/ipv4+
|
||||
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
||||
golang.org/x/net/ipv4 from github.com/prometheus-community/pro-bing+
|
||||
golang.org/x/net/ipv6 from github.com/prometheus-community/pro-bing+
|
||||
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
|
||||
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
|
||||
golang.org/x/net/proxy from tailscale.com/net/netns
|
||||
D golang.org/x/net/route from tailscale.com/net/netmon+
|
||||
golang.org/x/net/websocket from tailscale.com/k8s-operator/sessionrecording/ws
|
||||
golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials+
|
||||
golang.org/x/oauth2/clientcredentials from tailscale.com/cmd/k8s-operator+
|
||||
golang.org/x/oauth2/clientcredentials from tailscale.com/client/tailscale/v2+
|
||||
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+
|
||||
@@ -1092,22 +1025,22 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
crypto/internal/boring/bbig from crypto/ecdsa+
|
||||
crypto/internal/boring/sig from crypto/internal/boring
|
||||
crypto/internal/constanttime from crypto/internal/fips140/edwards25519+
|
||||
crypto/internal/fips140 from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140 from crypto/fips140+
|
||||
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/check from crypto/fips140+
|
||||
crypto/internal/fips140/drbg from crypto/hpke+
|
||||
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/hkdf from crypto/hkdf+
|
||||
crypto/internal/fips140/hmac from crypto/hmac+
|
||||
crypto/internal/fips140/mlkem from crypto/mlkem
|
||||
crypto/internal/fips140/nistec from crypto/elliptic+
|
||||
crypto/internal/fips140/nistec from crypto/ecdsa+
|
||||
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+
|
||||
@@ -1137,7 +1070,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
crypto/sha3 from crypto/internal/fips140hash+
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/cipher+
|
||||
crypto/tls from github.com/prometheus-community/pro-bing+
|
||||
crypto/tls from github.com/prometheus/client_golang/prometheus/promhttp+
|
||||
crypto/tls/internal/fips140tls from crypto/tls
|
||||
crypto/x509 from crypto/tls+
|
||||
D crypto/x509/internal/macos from crypto/x509
|
||||
@@ -1172,7 +1105,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
hash from compress/zlib+
|
||||
hash/adler32 from compress/zlib
|
||||
hash/crc32 from compress/gzip+
|
||||
hash/fnv from google.golang.org/protobuf/internal/detrand
|
||||
hash/fnv from google.golang.org/protobuf/internal/detrand+
|
||||
hash/maphash from go4.org/mem
|
||||
html from html/template+
|
||||
html/template from tailscale.com/util/eventbus
|
||||
@@ -1187,14 +1120,14 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
internal/filepathlite from os+
|
||||
internal/fmtsort from fmt+
|
||||
internal/goarch from crypto/internal/fips140deps/cpu+
|
||||
internal/godebug from crypto/internal/fips140deps/godebug+
|
||||
internal/godebug from crypto/ed25519+
|
||||
internal/godebugs from internal/godebug+
|
||||
internal/goexperiment from net/http/pprof+
|
||||
internal/goos from crypto/x509+
|
||||
internal/lazyregexp from go/doc
|
||||
internal/msan from internal/runtime/maps+
|
||||
internal/nettrace from net+
|
||||
internal/oserror from io/fs+
|
||||
internal/oserror from internal/syscall/windows+
|
||||
internal/poll from net+
|
||||
internal/profile from net/http/pprof
|
||||
internal/profilerecord from runtime+
|
||||
@@ -1204,9 +1137,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
internal/runtime/atomic from internal/runtime/exithook+
|
||||
L internal/runtime/cgroup from runtime
|
||||
internal/runtime/exithook from runtime
|
||||
internal/runtime/gc from runtime+
|
||||
internal/runtime/gc from internal/runtime/gc/scan+
|
||||
internal/runtime/gc/scan from runtime
|
||||
internal/runtime/maps from reflect+
|
||||
internal/runtime/maps from hash/maphash+
|
||||
internal/runtime/math from internal/runtime/maps+
|
||||
internal/runtime/pprof/label from runtime+
|
||||
internal/runtime/sys from crypto/subtle+
|
||||
@@ -1220,7 +1153,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
internal/synctest 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 from crypto/internal/fips140deps/time+
|
||||
W internal/syscall/windows/registry from mime+
|
||||
W internal/syscall/windows/sysdll from internal/syscall/windows+
|
||||
internal/testlog from os
|
||||
@@ -1228,7 +1161,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
internal/unsafeheader from internal/reflectlite+
|
||||
io from bufio+
|
||||
io/fs from crypto/x509+
|
||||
io/ioutil from github.com/godbus/dbus/v5+
|
||||
io/ioutil from github.com/google/gnostic-models/compiler+
|
||||
iter from go/ast+
|
||||
log from expvar+
|
||||
log/internal from log+
|
||||
@@ -1246,7 +1179,7 @@ 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/httptrace from github.com/prometheus-community/pro-bing+
|
||||
net/http/httptrace from github.com/prometheus/client_golang/prometheus/promhttp+
|
||||
net/http/httputil from tailscale.com/client/web+
|
||||
net/http/internal from net/http+
|
||||
net/http/internal/ascii from net/http+
|
||||
@@ -1265,7 +1198,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
regexp from github.com/davecgh/go-spew/spew+
|
||||
regexp/syntax from regexp
|
||||
runtime from crypto/internal/fips140+
|
||||
runtime/debug from github.com/coder/websocket/internal/xsync+
|
||||
runtime/debug from github.com/klauspost/compress/zstd+
|
||||
runtime/metrics from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/pprof from net/http/pprof+
|
||||
runtime/trace from net/http/pprof
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
/recorder.yaml
|
||||
/tailnet.yaml
|
||||
/proxygrouppolicy.yaml
|
||||
/peerrelay.yaml
|
||||
|
||||
@@ -6,6 +6,9 @@ kind: Deployment
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- if .Values.annotations }}
|
||||
annotations: {{- toYaml .Values.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
@@ -78,6 +81,10 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: OPERATOR_SERVICE_ACCOUNT_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: spec.serviceAccountName
|
||||
- name: OPERATOR_LOGIN_SERVER
|
||||
value: {{ .Values.loginServer }}
|
||||
- name: OPERATOR_INGRESS_CLASS_NAME
|
||||
@@ -117,6 +124,8 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.uid
|
||||
- name: OPERATOR_SHARED_ACME_ACCOUNT_KEY
|
||||
value: {{ .Values.operatorConfig.sharedACMEAccountKey | quote }}
|
||||
{{- with .Values.operatorConfig.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -146,3 +155,6 @@ spec:
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.operatorConfig.priorityClassName }}
|
||||
priorityClassName: {{ . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -40,6 +40,9 @@ rules:
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["tailnets", "tailnets/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["peerrelays", "peerrelays/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["proxygrouppolicies", "proxygrouppolicies/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
@@ -76,6 +79,10 @@ rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets", "serviceaccounts", "configmaps"]
|
||||
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["serviceaccounts/token"]
|
||||
resourceNames: ["operator"]
|
||||
verbs: ["create"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get","list","watch", "update"]
|
||||
|
||||
@@ -62,6 +62,9 @@ operatorConfig:
|
||||
|
||||
resources: {}
|
||||
|
||||
# Specifies annotations for deployment
|
||||
annotations: {}
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
@@ -72,6 +75,8 @@ operatorConfig:
|
||||
|
||||
affinity: {}
|
||||
|
||||
priorityClassName: ""
|
||||
|
||||
podSecurityContext: {}
|
||||
|
||||
securityContext: {}
|
||||
@@ -82,6 +87,13 @@ operatorConfig:
|
||||
# - name: EXTRA_VAR2
|
||||
# value: "value2"
|
||||
|
||||
# Default for the tailscale.com/share-acme-account annotation on new
|
||||
# ProxyGroups. When true, the operator provisions a shared per-tailnet
|
||||
# ACME account key Secret and configures proxies to use it, preserving
|
||||
# Let's Encrypt's ARI "replaces" renewal exemption across pod restarts
|
||||
# and ProxyGroup recreation. See #18251.
|
||||
sharedACMEAccountKey: false
|
||||
|
||||
# In the case that you already have a tailscale ingressclass in your cluster (or vcluster), you can disable the creation here
|
||||
ingressClass:
|
||||
# Allows for customization of the ingress class name used by the operator to identify ingresses to reconcile. This does
|
||||
|
||||
@@ -104,6 +104,884 @@ spec:
|
||||
description: Pod configuration.
|
||||
type: object
|
||||
properties:
|
||||
affinity:
|
||||
description: If specified, applies affinity rules to the pods deployed by the DNSConfig resource.
|
||||
type: object
|
||||
properties:
|
||||
nodeAffinity:
|
||||
description: Describes node affinity scheduling rules for the pod.
|
||||
type: object
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
The scheduler will prefer to schedule pods to nodes that satisfy
|
||||
the affinity expressions specified by this field, but it may choose
|
||||
a node that violates one or more of the expressions. The node that is
|
||||
most preferred is the one with the greatest sum of weights, i.e.
|
||||
for each node that meets all of the scheduling requirements (resource
|
||||
request, requiredDuringScheduling affinity expressions, etc.),
|
||||
compute a sum by iterating through the elements of this field and adding
|
||||
"weight" to the sum if the node matches the corresponding matchExpressions; the
|
||||
node(s) with the highest sum are the most preferred.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
An empty preferred scheduling term matches all objects with implicit weight 0
|
||||
(i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).
|
||||
type: object
|
||||
required:
|
||||
- preference
|
||||
- weight
|
||||
properties:
|
||||
preference:
|
||||
description: A node selector term, associated with the corresponding weight.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: A list of node selector requirements by node's labels.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A node selector requirement is a selector that contains values, a key, and an operator
|
||||
that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
Represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
An array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. If the operator is Gt or Lt, the values
|
||||
array must have a single element, which will be interpreted as an integer.
|
||||
This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchFields:
|
||||
description: A list of node selector requirements by node's fields.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A node selector requirement is a selector that contains values, a key, and an operator
|
||||
that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
Represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
An array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. If the operator is Gt or Lt, the values
|
||||
array must have a single element, which will be interpreted as an integer.
|
||||
This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-map-type: atomic
|
||||
weight:
|
||||
description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.
|
||||
type: integer
|
||||
format: int32
|
||||
x-kubernetes-list-type: atomic
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
If the affinity requirements specified by this field are not met at
|
||||
scheduling time, the pod will not be scheduled onto the node.
|
||||
If the affinity requirements specified by this field cease to be met
|
||||
at some point during pod execution (e.g. due to an update), the system
|
||||
may or may not try to eventually evict the pod from its node.
|
||||
type: object
|
||||
required:
|
||||
- nodeSelectorTerms
|
||||
properties:
|
||||
nodeSelectorTerms:
|
||||
description: Required. A list of node selector terms. The terms are ORed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A null or empty node selector term matches no objects. The requirements of
|
||||
them are ANDed.
|
||||
The TopologySelectorTerm type implements a subset of the NodeSelectorTerm.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: A list of node selector requirements by node's labels.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A node selector requirement is a selector that contains values, a key, and an operator
|
||||
that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
Represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
An array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. If the operator is Gt or Lt, the values
|
||||
array must have a single element, which will be interpreted as an integer.
|
||||
This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchFields:
|
||||
description: A list of node selector requirements by node's fields.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A node selector requirement is a selector that contains values, a key, and an operator
|
||||
that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
Represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
An array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. If the operator is Gt or Lt, the values
|
||||
array must have a single element, which will be interpreted as an integer.
|
||||
This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-map-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-map-type: atomic
|
||||
podAffinity:
|
||||
description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).
|
||||
type: object
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
The scheduler will prefer to schedule pods to nodes that satisfy
|
||||
the affinity expressions specified by this field, but it may choose
|
||||
a node that violates one or more of the expressions. The node that is
|
||||
most preferred is the one with the greatest sum of weights, i.e.
|
||||
for each node that meets all of the scheduling requirements (resource
|
||||
request, requiredDuringScheduling affinity expressions, etc.),
|
||||
compute a sum by iterating through the elements of this field and adding
|
||||
"weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the
|
||||
node(s) with the highest sum are the most preferred.
|
||||
type: array
|
||||
items:
|
||||
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
|
||||
type: object
|
||||
required:
|
||||
- podAffinityTerm
|
||||
- weight
|
||||
properties:
|
||||
podAffinityTerm:
|
||||
description: Required. A pod affinity term, associated with the corresponding weight.
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: |-
|
||||
A label query over a set of resources, in this case pods.
|
||||
If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: |-
|
||||
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: |-
|
||||
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: |-
|
||||
A label query over the set of namespaces that the term applies to.
|
||||
The term is applied to the union of the namespaces selected by this field
|
||||
and the ones listed in the namespaces field.
|
||||
null selector and null or empty namespaces list means "this pod's namespace".
|
||||
An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: |-
|
||||
namespaces specifies a static list of namespace names that the term applies to.
|
||||
The term is applied to the union of the namespaces listed in this field
|
||||
and the ones selected by namespaceSelector.
|
||||
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
topologyKey:
|
||||
description: |-
|
||||
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||
selected pods is running.
|
||||
Empty topologyKey is not allowed.
|
||||
type: string
|
||||
weight:
|
||||
description: |-
|
||||
weight associated with matching the corresponding podAffinityTerm,
|
||||
in the range 1-100.
|
||||
type: integer
|
||||
format: int32
|
||||
x-kubernetes-list-type: atomic
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
If the affinity requirements specified by this field are not met at
|
||||
scheduling time, the pod will not be scheduled onto the node.
|
||||
If the affinity requirements specified by this field cease to be met
|
||||
at some point during pod execution (e.g. due to a pod label update), the
|
||||
system may or may not try to eventually evict the pod from its node.
|
||||
When there are multiple elements, the lists of nodes corresponding to each
|
||||
podAffinityTerm are intersected, i.e. all terms must be satisfied.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
Defines a set of pods (namely those matching the labelSelector
|
||||
relative to the given namespace(s)) that this pod should be
|
||||
co-located (affinity) or not co-located (anti-affinity) with,
|
||||
where co-located is defined as running on a node whose value of
|
||||
the label with key <topologyKey> matches that of any node on which
|
||||
a pod of the set of pods is running
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: |-
|
||||
A label query over a set of resources, in this case pods.
|
||||
If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: |-
|
||||
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: |-
|
||||
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: |-
|
||||
A label query over the set of namespaces that the term applies to.
|
||||
The term is applied to the union of the namespaces selected by this field
|
||||
and the ones listed in the namespaces field.
|
||||
null selector and null or empty namespaces list means "this pod's namespace".
|
||||
An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: |-
|
||||
namespaces specifies a static list of namespace names that the term applies to.
|
||||
The term is applied to the union of the namespaces listed in this field
|
||||
and the ones selected by namespaceSelector.
|
||||
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
topologyKey:
|
||||
description: |-
|
||||
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||
selected pods is running.
|
||||
Empty topologyKey is not allowed.
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
podAntiAffinity:
|
||||
description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).
|
||||
type: object
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
The scheduler will prefer to schedule pods to nodes that satisfy
|
||||
the anti-affinity expressions specified by this field, but it may choose
|
||||
a node that violates one or more of the expressions. The node that is
|
||||
most preferred is the one with the greatest sum of weights, i.e.
|
||||
for each node that meets all of the scheduling requirements (resource
|
||||
request, requiredDuringScheduling anti-affinity expressions, etc.),
|
||||
compute a sum by iterating through the elements of this field and subtracting
|
||||
"weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the
|
||||
node(s) with the highest sum are the most preferred.
|
||||
type: array
|
||||
items:
|
||||
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
|
||||
type: object
|
||||
required:
|
||||
- podAffinityTerm
|
||||
- weight
|
||||
properties:
|
||||
podAffinityTerm:
|
||||
description: Required. A pod affinity term, associated with the corresponding weight.
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: |-
|
||||
A label query over a set of resources, in this case pods.
|
||||
If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: |-
|
||||
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: |-
|
||||
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: |-
|
||||
A label query over the set of namespaces that the term applies to.
|
||||
The term is applied to the union of the namespaces selected by this field
|
||||
and the ones listed in the namespaces field.
|
||||
null selector and null or empty namespaces list means "this pod's namespace".
|
||||
An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: |-
|
||||
namespaces specifies a static list of namespace names that the term applies to.
|
||||
The term is applied to the union of the namespaces listed in this field
|
||||
and the ones selected by namespaceSelector.
|
||||
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
topologyKey:
|
||||
description: |-
|
||||
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||
selected pods is running.
|
||||
Empty topologyKey is not allowed.
|
||||
type: string
|
||||
weight:
|
||||
description: |-
|
||||
weight associated with matching the corresponding podAffinityTerm,
|
||||
in the range 1-100.
|
||||
type: integer
|
||||
format: int32
|
||||
x-kubernetes-list-type: atomic
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
If the anti-affinity requirements specified by this field are not met at
|
||||
scheduling time, the pod will not be scheduled onto the node.
|
||||
If the anti-affinity requirements specified by this field cease to be met
|
||||
at some point during pod execution (e.g. due to a pod label update), the
|
||||
system may or may not try to eventually evict the pod from its node.
|
||||
When there are multiple elements, the lists of nodes corresponding to each
|
||||
podAffinityTerm are intersected, i.e. all terms must be satisfied.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
Defines a set of pods (namely those matching the labelSelector
|
||||
relative to the given namespace(s)) that this pod should be
|
||||
co-located (affinity) or not co-located (anti-affinity) with,
|
||||
where co-located is defined as running on a node whose value of
|
||||
the label with key <topologyKey> matches that of any node on which
|
||||
a pod of the set of pods is running
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: |-
|
||||
A label query over a set of resources, in this case pods.
|
||||
If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: |-
|
||||
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: |-
|
||||
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: |-
|
||||
A label query over the set of namespaces that the term applies to.
|
||||
The term is applied to the union of the namespaces selected by this field
|
||||
and the ones listed in the namespaces field.
|
||||
null selector and null or empty namespaces list means "this pod's namespace".
|
||||
An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: |-
|
||||
namespaces specifies a static list of namespace names that the term applies to.
|
||||
The term is applied to the union of the namespaces listed in this field
|
||||
and the ones selected by namespaceSelector.
|
||||
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
topologyKey:
|
||||
description: |-
|
||||
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||
selected pods is running.
|
||||
Empty topologyKey is not allowed.
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
nodeSelector:
|
||||
description: If specified, applies node selector rules to the pods deployed by the DNSConfig resource.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
tolerations:
|
||||
description: If specified, applies tolerations to the pods deployed by the DNSConfig resource.
|
||||
type: array
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.17.0
|
||||
name: peerrelays.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
names:
|
||||
kind: PeerRelay
|
||||
listKind: PeerRelayList
|
||||
plural: peerrelays
|
||||
shortNames:
|
||||
- pr
|
||||
singular: peerrelay
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- jsonPath: .metadata.creationTimestamp
|
||||
name: Age
|
||||
type: date
|
||||
- description: Status of the deployed PeerRelay resources.
|
||||
jsonPath: .status.conditions[?(@.type == "PeerRelayReady")].reason
|
||||
name: Status
|
||||
type: string
|
||||
- description: Public addresses the peer relay replicas are reachable on.
|
||||
jsonPath: .status.endpoints[*].address
|
||||
name: Endpoints
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
required:
|
||||
- metadata
|
||||
- spec
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: |-
|
||||
Spec describes the desired state of the PeerRelay.
|
||||
More info:
|
||||
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
|
||||
type: object
|
||||
properties:
|
||||
aws:
|
||||
description: |-
|
||||
AWS contains configuration for pinning each replica to a specific AWS Elastic IP and subnet. Only meaningful
|
||||
when running on EKS with the AWS Load Balancer Controller. When set, the per-replica values override any
|
||||
aws-load-balancer-eip-allocations or aws-load-balancer-subnets values supplied via spec.service.annotations.
|
||||
type: object
|
||||
required:
|
||||
- elasticIPs
|
||||
properties:
|
||||
elasticIPs:
|
||||
description: |-
|
||||
ElasticIPs pins each replica to a specific AWS EIP allocation and subnet. Only meaningful when Network Load
|
||||
Balancers are provisioned by the AWS Load Balancer Controller. ElasticIPs supplies one allocation-subnet pair
|
||||
per replica: replica N uses ElasticIPs[N]. The list must be at least as long as spec.replicas so every replica
|
||||
has a distinct EIP; extra entries are permitted so that scale-up doesn't immediately trip validation.
|
||||
|
||||
When set, the reconciler stamps
|
||||
service.beta.kubernetes.io/aws-load-balancer-eip-allocations and
|
||||
service.beta.kubernetes.io/aws-load-balancer-subnets on each per-replica Service, overriding any values in
|
||||
spec.service.annotations.
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
description: PeerRelayAWSElasticIP pairs an EIP allocation with the subnet in the same AZ.
|
||||
type: object
|
||||
required:
|
||||
- allocationID
|
||||
- subnetID
|
||||
properties:
|
||||
allocationID:
|
||||
description: |-
|
||||
AllocationID is the AWS EIP allocation ID (e.g. eipalloc-0123abcd) whose public IP this replica is reachable
|
||||
on. Stamped as service.beta.kubernetes.io/aws-load-balancer-eip-allocations on the replica's Service.
|
||||
type: string
|
||||
pattern: ^eipalloc-[0-9a-f]+$
|
||||
subnetID:
|
||||
description: |-
|
||||
SubnetID is the AWS subnet in the same availability zone as AllocationID (e.g. subnet-0123abcd). Stamped as
|
||||
service.beta.kubernetes.io/aws-load-balancer-subnets on the replica's Service so the NLB is provisioned in
|
||||
the same AZ as the EIP.
|
||||
type: string
|
||||
pattern: ^subnet-[0-9a-f]+$
|
||||
x-kubernetes-list-type: atomic
|
||||
hostnamePrefix:
|
||||
description: |-
|
||||
HostnamePrefix specifies the hostname prefix for each
|
||||
replica. Each device will have the integer number
|
||||
from its StatefulSet pod appended to this prefix to form the full hostname.
|
||||
HostnamePrefix can contain lower case letters, numbers and dashes, it
|
||||
must not start with a dash and must be between 1 and 62 characters long.
|
||||
type: string
|
||||
pattern: ^[a-z0-9][a-z0-9-]{0,61}$
|
||||
proxyClass:
|
||||
description: |-
|
||||
ProxyClass is the name of the ProxyClass custom resource that
|
||||
contains configuration options that should be applied to the
|
||||
resources created for this PeerRelay. If unset, the operator will
|
||||
create resources with the default configuration.
|
||||
type: string
|
||||
replicas:
|
||||
description: |-
|
||||
Replicas specifies how many devices to create. Set this to enable
|
||||
high availability for peer relays.
|
||||
https://tailscale.com/kb/1115/high-availability. Defaults to 1.
|
||||
type: integer
|
||||
format: int32
|
||||
default: 1
|
||||
minimum: 0
|
||||
service:
|
||||
description: Service contains configuration values to modify the LoadBalancer service used to expose the peer relay.
|
||||
type: object
|
||||
properties:
|
||||
annotations:
|
||||
description: |-
|
||||
Annotations to apply to the LoadBalancer service. Any annotations that conflict with those used by known
|
||||
cloud providers to ensure IP addresses rather than DNS names are ignored.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
tags:
|
||||
description: |-
|
||||
Tags that the Tailscale node will be tagged with.
|
||||
Defaults to [tag:k8s].
|
||||
To autoapprove the device defined by a PeerRelay,
|
||||
you can configure Tailscale ACLs to give these tags the necessary
|
||||
permissions.
|
||||
See https://tailscale.com/kb/1337/acl-syntax#autoapprovers.
|
||||
If you specify custom tags here, you must also make the operator an owner of these tags.
|
||||
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
|
||||
Tags cannot be changed once a PeerRelay node has been created.
|
||||
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
|
||||
tailnet:
|
||||
description: |-
|
||||
Tailnet specifies the tailnet this PeerRelay should join. If blank, the default tailnet is used. When set, this
|
||||
name must match that of a valid Tailnet resource. This field is immutable and cannot be changed once set.
|
||||
type: string
|
||||
x-kubernetes-validations:
|
||||
- rule: self == oldSelf
|
||||
message: PeerRelay tailnet is immutable
|
||||
x-kubernetes-validations:
|
||||
- rule: '!has(self.aws) || !has(self.aws.elasticIPs) || self.aws.elasticIPs.size() >= self.replicas'
|
||||
message: spec.aws.elasticIPs must contain at least one entry per replica
|
||||
status:
|
||||
description: |-
|
||||
Status describes the status of the PeerRelay. This is set
|
||||
and managed by the Tailscale operator.
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
type: array
|
||||
items:
|
||||
description: Condition contains details for one aspect of the current state of this API Resource.
|
||||
type: object
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
type: string
|
||||
format: date-time
|
||||
message:
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
type: string
|
||||
maxLength: 32768
|
||||
observedGeneration:
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
reason:
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
type: string
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
status:
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
type: string
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type:
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
type: string
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
endpoints:
|
||||
description: |-
|
||||
Endpoints lists the public address:port pairs each peer relay replica is reachable on. There is one entry
|
||||
per replica whose LoadBalancer Service has been assigned a public address; entries appear as the underlying
|
||||
cloud provisions each Service.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- address
|
||||
- port
|
||||
- replica
|
||||
properties:
|
||||
address:
|
||||
description: |-
|
||||
Address is the public IP or hostname the cloud has allocated for this replica's LoadBalancer Service.
|
||||
Peers reach this relay by connecting to Address:Port over UDP.
|
||||
type: string
|
||||
port:
|
||||
description: Port is the UDP port the peer relay listens on.
|
||||
type: integer
|
||||
format: int32
|
||||
replica:
|
||||
description: Replica is the zero-based index of the peer relay replica this endpoint targets.
|
||||
type: integer
|
||||
format: int32
|
||||
x-kubernetes-list-map-keys:
|
||||
- replica
|
||||
x-kubernetes-list-type: map
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
@@ -58,15 +58,18 @@ spec:
|
||||
- credentials
|
||||
properties:
|
||||
credentials:
|
||||
description: Denotes the location of the OAuth credentials to use for authenticating with this Tailnet.
|
||||
description: Denotes the location of the credentials to use for authenticating with this Tailnet.
|
||||
type: object
|
||||
required:
|
||||
- secretName
|
||||
properties:
|
||||
secretName:
|
||||
description: |-
|
||||
The name of the secret containing the OAuth credentials. This secret must contain two fields "client_id" and
|
||||
"client_secret".
|
||||
The name of the secret containing the credentials used to authenticate with this Tailnet. The secret must always
|
||||
contain a "client_id" field. To authenticate with a static OAuth client, also set "client_secret". To authenticate
|
||||
via workload identity federation, set "audience" to the audience value expected by the Tailscale OAuth
|
||||
client; the operator will mint a ServiceAccount token for itself with that audience and exchange it for an API
|
||||
token. "client_secret" and "audience" are mutually exclusive.
|
||||
type: string
|
||||
loginUrl:
|
||||
description: URL of the control plane to be used by all resources managed by the operator using this Tailnet.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import (
|
||||
"k8s.io/utils/net"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/util/mak"
|
||||
@@ -106,6 +107,7 @@ func (dnsRR *dnsRecordsReconciler) Reconcile(ctx context.Context, req reconcile.
|
||||
if err := dnsRR.maybeProvision(ctx, proxySvc, logger); err != nil {
|
||||
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
|
||||
logger.Infof("optimistic lock error, retrying: %s", err)
|
||||
return reconcile.Result{RequeueAfter: shortRequeue}, nil
|
||||
} else {
|
||||
return reconcile.Result{}, err
|
||||
}
|
||||
@@ -281,19 +283,26 @@ func (dnsRR *dnsRecordsReconciler) fqdnForDNSRecord(ctx context.Context, proxySv
|
||||
if err := dnsRR.Get(ctx, parentName, ing); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(ing.Status.LoadBalancer.Ingress) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return ing.Status.LoadBalancer.Ingress[0].Hostname, nil
|
||||
}
|
||||
|
||||
if isManagedByType(proxySvc, serviceTypeSvc) {
|
||||
svc := new(corev1.Service)
|
||||
if err := dnsRR.Get(ctx, parentName, svc); apierrors.IsNotFound(err) {
|
||||
logger.Infof("[unexpected] parent Service for egress proxy %s not found", proxySvc.Name)
|
||||
var svc corev1.Service
|
||||
|
||||
err := dnsRR.Get(ctx, parentName, &svc)
|
||||
switch {
|
||||
case apierrors.IsNotFound(err):
|
||||
logger.Warnf("parent Service for egress proxy %q not found", proxySvc.Name)
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
case err != nil:
|
||||
return "", err
|
||||
}
|
||||
|
||||
return svc.Annotations[AnnotationTailnetTargetFQDN], nil
|
||||
}
|
||||
return "", nil
|
||||
@@ -303,28 +312,31 @@ func (dnsRR *dnsRecordsReconciler) fqdnForDNSRecord(ctx context.Context, proxySv
|
||||
// ConfigMap. At this point the in-cluster ts.net nameserver is expected to be
|
||||
// successfully created together with the ConfigMap.
|
||||
func (dnsRR *dnsRecordsReconciler) updateDNSConfig(ctx context.Context, update func(*operatorutils.Records)) error {
|
||||
cm := &corev1.ConfigMap{}
|
||||
err := dnsRR.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: dnsRR.tsNamespace}, cm)
|
||||
if apierrors.IsNotFound(err) {
|
||||
dnsRR.logger.Info("[unexpected] dnsrecords ConfigMap not found in cluster. Not updating DNS records. Please open an issue and attach operator logs.")
|
||||
var cm corev1.ConfigMap
|
||||
err := dnsRR.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: dnsRR.tsNamespace}, &cm)
|
||||
switch {
|
||||
case apierrors.IsNotFound(err):
|
||||
dnsRR.logger.Warn("dnsrecords ConfigMap not found in cluster. Not updating DNS records. Please open an issue and attach operator logs.")
|
||||
return nil
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to retrieve dnsrecords ConfigMap: %w", err)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving dnsrecords ConfigMap: %w", err)
|
||||
}
|
||||
|
||||
dnsRecords := operatorutils.Records{Version: operatorutils.Alpha1Version, IP4: map[string][]string{}}
|
||||
if cm.Data != nil && cm.Data[operatorutils.DNSRecordsCMKey] != "" {
|
||||
if err := json.Unmarshal([]byte(cm.Data[operatorutils.DNSRecordsCMKey]), &dnsRecords); err != nil {
|
||||
if err = json.Unmarshal([]byte(cm.Data[operatorutils.DNSRecordsCMKey]), &dnsRecords); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
update(&dnsRecords)
|
||||
dnsRecordsBs, err := json.Marshal(dnsRecords)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling DNS records: %w", err)
|
||||
}
|
||||
|
||||
mak.Set(&cm.Data, operatorutils.DNSRecordsCMKey, string(dnsRecordsBs))
|
||||
return dnsRR.Update(ctx, cm)
|
||||
return dnsRR.Update(ctx, &cm)
|
||||
}
|
||||
|
||||
// isSvcForFQDNEgressProxy returns true if the Service is a headless Service
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user