Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
721194dfb5 | ||
|
|
81e37f8812 | ||
|
|
cada6936b9 | ||
|
|
78c4511a3d | ||
|
|
3a9f6f463a | ||
|
|
7bfc64c379 | ||
|
|
e7270026f7 | ||
|
|
4618ee1496 | ||
|
|
915dca44fe | ||
|
|
6e83d5291b | ||
|
|
21d0f11d85 | ||
|
|
0df765eb60 | ||
|
|
52cae45f81 | ||
|
|
7fd2507611 | ||
|
|
8514045909 | ||
|
|
7f5983eaab | ||
|
|
143581c955 | ||
|
|
d9efc3bae2 | ||
|
|
9e36a7f27f | ||
|
|
8277fc0f1d | ||
|
|
e32520659d | ||
|
|
e8eb9d71c2 | ||
|
|
c4ff4c4835 | ||
|
|
68ecc4b033 | ||
|
|
9f96b7434c | ||
|
|
b04b4f7751 | ||
|
|
f961db8925 | ||
|
|
fde5f11895 | ||
|
|
756ba1d5ec | ||
|
|
68670f938b |
+2
-60
@@ -1,60 +1,2 @@
|
||||
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
|
||||
go.mod filter=go-mod
|
||||
*.go diff=golang
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
# Install a more recent Go that understands modern go.mod content.
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # zizmor: ignore[cache-poisoning] v6.3.0
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # 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@0782b76014f15f24e22a438f30f308df42899ba1 # 1.3.0
|
||||
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
- name: Scan source code for known vulnerabilities
|
||||
run: PATH=$PWD/tool/:$PATH "$(./tool/go env GOPATH)/bin/govulncheck" -test ./...
|
||||
|
||||
@@ -37,6 +37,8 @@ jobs:
|
||||
- "elementary/docker:stable"
|
||||
- "elementary/docker:unstable"
|
||||
- "parrotsec/core:latest"
|
||||
- "kalilinux/kali-rolling"
|
||||
- "kalilinux/kali-dev"
|
||||
- "oraclelinux:9"
|
||||
- "oraclelinux:8"
|
||||
- "fedora:latest"
|
||||
@@ -59,9 +61,6 @@ 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" }
|
||||
@@ -69,7 +68,7 @@ jobs:
|
||||
- { image: "fedora:latest", deps: "curl", version: "1.80.0" }
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ matrix.image }} # zizmor: ignore[unpinned-images]
|
||||
image: ${{ matrix.image }}
|
||||
options: --user root
|
||||
steps:
|
||||
- name: install dependencies (pacman)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Run a single natlab smoke test on every PR. The full natlab suite
|
||||
# is opt-in and lives in .github/workflows/natlab-test.yml.
|
||||
# Run some natlab integration tests.
|
||||
# See https://github.com/tailscale/tailscale/issues/13038
|
||||
name: "natlab-basic"
|
||||
name: "natlab-integrationtest"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
@@ -18,28 +17,17 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
jobs:
|
||||
EasyEasy:
|
||||
natlab-integrationtest:
|
||||
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/natlab/vmtest --run-vm-tests
|
||||
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/integration/nat --run-vm-tests
|
||||
@@ -1,182 +0,0 @@
|
||||
# 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
|
||||
@@ -1,45 +0,0 @@
|
||||
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,6 +15,8 @@ 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
|
||||
@@ -22,8 +24,6 @@ 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 }}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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 in various Docker containers.
|
||||
# These tests can also be run locally via `make sshintegrationtest`.
|
||||
# Run the ssh integration tests with `make sshintegrationtest`.
|
||||
# These tests can also be running locally.
|
||||
name: "ssh-integrationtest"
|
||||
|
||||
concurrency:
|
||||
@@ -15,25 +15,9 @@ 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: Build test binaries
|
||||
- name: Run SSH integration tests
|
||||
run: |
|
||||
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
|
||||
make sshintegrationtest
|
||||
+32
-22
@@ -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 # zizmor: ignore[cache-poisoning] v5.0.4
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # 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,7 +261,6 @@ 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 }}
|
||||
@@ -269,10 +268,9 @@ 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='^$' $(for x in $(git grep -l '^func Benchmark' | xargs dirname | sort | uniq); do echo "./$x"; done)
|
||||
run: ./tool/go test ./... -bench=. -benchtime=1x -run="^$"
|
||||
env:
|
||||
NOPWSHDEBUG: "true" # to quiet tool/gocross/gocross-wrapper.ps1 in CI
|
||||
|
||||
@@ -345,7 +343,7 @@ jobs:
|
||||
needs: gomod-cache
|
||||
runs-on: ubuntu-24.04
|
||||
container:
|
||||
image: golang:latest # zizmor: ignore[unpinned-images]
|
||||
image: golang:latest
|
||||
options: --privileged
|
||||
steps:
|
||||
- name: checkout
|
||||
@@ -363,7 +361,31 @@ jobs:
|
||||
run: chown -R $(id -u):$(id -g) $PWD
|
||||
- name: privileged tests
|
||||
working-directory: src
|
||||
run: ./tool/go test $(./tool/go run ./tool/listpkgs --has-root-tests)
|
||||
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"
|
||||
|
||||
cross: # cross-compile checks, build only.
|
||||
needs: gomod-cache
|
||||
@@ -620,13 +642,6 @@ 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
|
||||
@@ -772,14 +787,6 @@ 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
|
||||
@@ -888,6 +895,7 @@ jobs:
|
||||
- test
|
||||
- windows
|
||||
- macos
|
||||
- vm
|
||||
- cross
|
||||
- ios
|
||||
- wasm
|
||||
@@ -933,6 +941,7 @@ jobs:
|
||||
- test
|
||||
- windows
|
||||
- macos
|
||||
- vm
|
||||
- cross
|
||||
- ios
|
||||
- wasm
|
||||
@@ -982,6 +991,7 @@ 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 updateflakes
|
||||
run: ./tool/go run ./tool/updateflakes
|
||||
- name: Run update-flakes
|
||||
run: ./update-flake.sh
|
||||
|
||||
- name: Get access token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
@@ -33,9 +33,6 @@ 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
|
||||
@@ -44,8 +41,8 @@ jobs:
|
||||
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
committer: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
branch: flakes
|
||||
commit-message: "flakehashes.json: update SRI hash for go.mod changes"
|
||||
title: "flakehashes.json: update SRI hash for go.mod changes"
|
||||
commit-message: "go.mod.sri: update SRI hash for go.mod changes"
|
||||
title: "go.mod.sri: update SRI hash for go.mod changes"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
|
||||
@@ -29,9 +29,6 @@ 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,17 +14,15 @@ on:
|
||||
- main
|
||||
- "release-branch/*"
|
||||
paths:
|
||||
- .github/workflows/vet.yml
|
||||
- "**.go"
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/vet.yml
|
||||
- "**.go"
|
||||
|
||||
jobs:
|
||||
vet:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
runs-on: [ self-hosted, linux ]
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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
|
||||
+1
-6
@@ -1,15 +1,12 @@
|
||||
# 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
|
||||
@@ -58,5 +55,3 @@ client/web/build/assets
|
||||
|
||||
# Ignore syncthing state directory.
|
||||
/.stfolder
|
||||
fbstatus
|
||||
gafpush
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
# 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
@@ -1,84 +0,0 @@
|
||||
# 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"
|
||||
+1
-7
@@ -1,7 +1 @@
|
||||
# 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.
|
||||
/tailcfg/ @tailscale/control-protocol-owners
|
||||
|
||||
@@ -10,7 +10,7 @@ vet: ## Run go vet
|
||||
|
||||
tidy: ## Run go mod tidy and update nix flake hashes
|
||||
./tool/go mod tidy
|
||||
./tool/go run ./tool/updateflakes
|
||||
./update-flake.sh
|
||||
|
||||
lint: ## Run golangci-lint
|
||||
./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run
|
||||
@@ -137,66 +137,15 @@ 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, 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
|
||||
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
|
||||
|
||||
.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.103.0
|
||||
1.97.0
|
||||
|
||||
@@ -736,7 +736,6 @@ 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
|
||||
|
||||
+39
-23
@@ -5,20 +5,18 @@ package appc
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/ipn/ipnext"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/appctype"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
|
||||
|
||||
func isPeerEligibleConnector(peer tailcfg.NodeView) bool {
|
||||
func isEligibleConnector(peer tailcfg.NodeView) bool {
|
||||
if !peer.Valid() || !peer.Hostinfo().Valid() {
|
||||
return false
|
||||
}
|
||||
@@ -41,7 +39,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 !isPeerEligibleConnector(n) {
|
||||
if !isEligibleConnector(n) {
|
||||
return false
|
||||
}
|
||||
for _, t := range n.Tags().All() {
|
||||
@@ -55,32 +53,50 @@ func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.Nod
|
||||
return matches
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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
|
||||
if !hasCap(AppConnectorsExperimentalAttrName) {
|
||||
return nil
|
||||
return m
|
||||
}
|
||||
apps, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorAttr](self.CapMap(), AppConnectorsExperimentalAttrName)
|
||||
if err != nil {
|
||||
return nil
|
||||
return m
|
||||
}
|
||||
appNamesByDomain := map[string]string{}
|
||||
tagToDomain := make(map[string][]string)
|
||||
for _, app := range apps {
|
||||
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
|
||||
for _, tag := range app.Connectors {
|
||||
tagToDomain[tag] = append(tagToDomain[tag], app.Domains...)
|
||||
}
|
||||
}
|
||||
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}}
|
||||
// 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)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
+63
-82
@@ -5,18 +5,17 @@ package appc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"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 TestAppDNSRoutes(t *testing.T) {
|
||||
func TestPickSplitDNSPeers(t *testing.T) {
|
||||
getBytesForAttr := func(name string, domains []string, tags []string) []byte {
|
||||
attr := appctype.AppConnectorAttr{
|
||||
Name: name,
|
||||
@@ -33,105 +32,83 @@ func TestAppDNSRoutes(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"})
|
||||
|
||||
resolver := func(appName string) []*dnstype.Resolver {
|
||||
return []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName), UseWithExitNode: true}}
|
||||
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()
|
||||
}
|
||||
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
|
||||
hasCap bool
|
||||
want map[string][]tailcfg.NodeView
|
||||
peers []tailcfg.NodeView
|
||||
config []tailcfg.RawMessage
|
||||
want map[string][]*dnstype.Resolver
|
||||
}{
|
||||
{
|
||||
name: "no-capability", // hasCap false should return nil regardless of config.
|
||||
hasCap: false,
|
||||
name: "empty",
|
||||
},
|
||||
{
|
||||
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,
|
||||
name: "bad-config", // bad config should return a nil map rather than error.
|
||||
config: []tailcfg.RawMessage{tailcfg.RawMessage(`hey`)},
|
||||
},
|
||||
{
|
||||
name: "single-app",
|
||||
hasCap: true,
|
||||
name: "no-peers",
|
||||
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
|
||||
want: map[string][]*dnstype.Resolver{
|
||||
"example.com": resolver("app1"),
|
||||
},
|
||||
{
|
||||
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(),
|
||||
},
|
||||
},
|
||||
{
|
||||
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: "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: "multi-app-no-overlap",
|
||||
hasCap: true,
|
||||
name: "matching-tagged-connector-peers",
|
||||
config: []tailcfg.RawMessage{
|
||||
tailcfg.RawMessage(appOneBytes),
|
||||
tailcfg.RawMessage(appTwoBytes),
|
||||
tailcfg.RawMessage(appThreeBytes),
|
||||
tailcfg.RawMessage(appFourBytes),
|
||||
},
|
||||
want: map[string][]*dnstype.Resolver{
|
||||
"example.com": resolver("app1"),
|
||||
"a.example.com": resolver("app2"),
|
||||
peers: []tailcfg.NodeView{
|
||||
nvp1,
|
||||
nvp2,
|
||||
nvp3,
|
||||
nvp4,
|
||||
makeNodeView(5, "p5", nil),
|
||||
},
|
||||
},
|
||||
{
|
||||
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"),
|
||||
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},
|
||||
},
|
||||
},
|
||||
} {
|
||||
@@ -143,11 +120,15 @@ func TestAppDNSRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
selfView := selfNode.View()
|
||||
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)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,13 +51,6 @@ 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"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// 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,55 +10,13 @@ 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.
|
||||
@@ -85,8 +43,6 @@ 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)
|
||||
@@ -96,8 +52,6 @@ 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)
|
||||
@@ -111,18 +65,10 @@ 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,9 +50,6 @@ 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 {
|
||||
|
||||
+8
-309
@@ -2,12 +2,6 @@
|
||||
// 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 (
|
||||
@@ -141,9 +135,6 @@ 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() {
|
||||
@@ -289,7 +280,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, res.Header}
|
||||
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
|
||||
}
|
||||
return slurp, res.Header, nil
|
||||
}
|
||||
@@ -297,7 +288,6 @@ func (lc *Client) sendWithHeaders(
|
||||
type httpStatusError struct {
|
||||
error
|
||||
HTTPStatus int
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
func (lc *Client) get200(ctx context.Context, path string) ([]byte, error) {
|
||||
@@ -326,8 +316,6 @@ 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 {
|
||||
@@ -339,39 +327,6 @@ 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")
|
||||
@@ -379,8 +334,6 @@ 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 {
|
||||
@@ -396,8 +349,6 @@ 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 {
|
||||
@@ -474,9 +425,6 @@ 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 {
|
||||
@@ -493,18 +441,12 @@ 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].
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change between releases.
|
||||
// as a [eventbus.DebugTopics]
|
||||
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")
|
||||
}
|
||||
@@ -513,9 +455,6 @@ 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",
|
||||
@@ -584,8 +523,6 @@ 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 != "" {
|
||||
@@ -631,17 +568,12 @@ 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change or removal between releases.
|
||||
// These are development tools and subject to change or removal over time.
|
||||
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 {
|
||||
@@ -652,10 +584,7 @@ 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change or removal between releases.
|
||||
// These are development tools and subject to change or removal over time.
|
||||
func (lc *Client) DebugActionBody(ctx context.Context, action string, rbody io.Reader) error {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, rbody)
|
||||
if err != nil {
|
||||
@@ -665,10 +594,7 @@ 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.
|
||||
//
|
||||
// API maturity: this method is not considered a stable API and is
|
||||
// subject to change or removal between releases.
|
||||
// These are development tools and subject to change or removal over time.
|
||||
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 {
|
||||
@@ -681,27 +607,6 @@ 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)
|
||||
@@ -731,9 +636,6 @@ 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
|
||||
@@ -762,8 +664,6 @@ 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, "")
|
||||
}
|
||||
@@ -774,8 +674,6 @@ 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")
|
||||
}
|
||||
@@ -880,9 +778,6 @@ 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
|
||||
@@ -906,9 +801,6 @@ 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 {
|
||||
@@ -958,9 +850,6 @@ 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 {
|
||||
@@ -978,8 +867,6 @@ 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 {
|
||||
@@ -990,9 +877,6 @@ 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
|
||||
@@ -1026,26 +910,7 @@ func (lc *Client) QueryDNS(ctx context.Context, name string, queryType string) (
|
||||
return res.Bytes, res.Resolvers, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// StartLoginInteractive starts an interactive login.
|
||||
func (lc *Client) StartLoginInteractive(ctx context.Context) error {
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil)
|
||||
return err
|
||||
@@ -1070,8 +935,6 @@ 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)
|
||||
}
|
||||
@@ -1083,8 +946,6 @@ 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{
|
||||
@@ -1111,19 +972,6 @@ 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
|
||||
@@ -1149,10 +997,6 @@ 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)
|
||||
@@ -1165,66 +1009,6 @@ 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.
|
||||
@@ -1261,8 +1045,6 @@ 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 {
|
||||
@@ -1358,18 +1140,13 @@ func (lc *Client) ReloadConfig(ctx context.Context) (ok bool, err error) {
|
||||
|
||||
// SwitchToEmptyProfile creates and switches to a new unnamed profile. The new
|
||||
// profile is not assigned an ID until it is persisted after a successful login.
|
||||
// In order to login to the new profile, the user must call
|
||||
// [Client.StartLoginInteractive].
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
// In order to login to the new profile, the user must call LoginInteractive.
|
||||
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
|
||||
@@ -1404,11 +1181,6 @@ 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)
|
||||
@@ -1419,9 +1191,6 @@ 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 {
|
||||
@@ -1433,9 +1202,6 @@ 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)
|
||||
@@ -1444,9 +1210,6 @@ 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 {
|
||||
@@ -1459,9 +1222,6 @@ 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 {
|
||||
@@ -1488,16 +1248,9 @@ 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="+string(m),
|
||||
"http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+fmt.Sprint(mask),
|
||||
nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1521,8 +1274,6 @@ 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 {
|
||||
@@ -1539,8 +1290,6 @@ 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
|
||||
@@ -1549,9 +1298,6 @@ 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
|
||||
@@ -1560,9 +1306,6 @@ 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
|
||||
@@ -1570,9 +1313,6 @@ 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,
|
||||
@@ -1584,9 +1324,6 @@ 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,
|
||||
@@ -1599,9 +1336,6 @@ 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 {
|
||||
@@ -1658,25 +1392,8 @@ 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 {
|
||||
@@ -1693,19 +1410,11 @@ 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 {
|
||||
@@ -1713,13 +1422,3 @@ 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,57 +61,6 @@ 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{
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// 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,9 +17,6 @@ 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 {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// 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,9 +13,6 @@ 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 {
|
||||
|
||||
+29
-94
@@ -18,22 +18,17 @@ import (
|
||||
"tailscale.com/types/tkatype"
|
||||
)
|
||||
|
||||
// TailnetLockStatus fetches information about the tailnet key authority, if one is configured.
|
||||
func (lc *Client) TailnetLockStatus(ctx context.Context) (*ipnstate.TailnetLockStatus, error) {
|
||||
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
|
||||
func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
|
||||
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: %w", err)
|
||||
}
|
||||
return decodeJSON[*ipnstate.TailnetLockStatus](body)
|
||||
return decodeJSON[*ipnstate.NetworkLockStatus](body)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// NetworkLockInit initializes the tailnet key authority.
|
||||
func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.NetworkLockStatus, error) {
|
||||
var b bytes.Buffer
|
||||
type initRequest struct {
|
||||
Keys []tka.Key
|
||||
@@ -49,17 +44,12 @@ func (lc *Client) TailnetLockInit(ctx context.Context, keys []tka.Key, disableme
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: %w", err)
|
||||
}
|
||||
return decodeJSON[*ipnstate.TailnetLockStatus](body)
|
||||
return decodeJSON[*ipnstate.NetworkLockStatus](body)
|
||||
}
|
||||
|
||||
// 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
|
||||
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
|
||||
// enable unattended bringup in the locked tailnet.
|
||||
func (lc *Client) TailnetLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
|
||||
func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
|
||||
encodedPrivate, err := tkaKey.MarshalText()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -81,13 +71,8 @@ func (lc *Client) TailnetLockWrapPreauthKey(ctx context.Context, preauthKey stri
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// NetworkLockModify adds and/or removes key(s) to the tailnet key authority.
|
||||
func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
|
||||
var b bytes.Buffer
|
||||
type modifyRequest struct {
|
||||
AddKeys []tka.Key
|
||||
@@ -104,14 +89,9 @@ func (lc *Client) TailnetLockModify(ctx context.Context, addKeys, removeKeys []t
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// NetworkLockSign signs the specified node-key and transmits that signature to the control plane.
|
||||
// rotationPublic, if specified, must be an ed25519 public key.
|
||||
func (lc *Client) TailnetLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
|
||||
func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
|
||||
var b bytes.Buffer
|
||||
type signRequest struct {
|
||||
NodeKey key.NodePublic
|
||||
@@ -128,13 +108,8 @@ func (lc *Client) TailnetLockSign(ctx context.Context, nodeKey key.NodePublic, r
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
|
||||
func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/affected-sigs", 200, bytes.NewReader(keyID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: %w", err)
|
||||
@@ -142,29 +117,19 @@ func (lc *Client) TailnetLockAffectedSigs(ctx context.Context, keyID tkatype.Key
|
||||
return decodeJSON[[]tkatype.MarshaledSignature](body)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
|
||||
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
|
||||
v := url.Values{}
|
||||
v.Set("limit", fmt.Sprint(maxEntries))
|
||||
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/log?"+v.Encode(), 200, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
return decodeJSON[[]ipnstate.TailnetLockUpdate](body)
|
||||
return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
|
||||
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
|
||||
// This endpoint expects an empty JSON stanza as the payload.
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil {
|
||||
@@ -177,14 +142,9 @@ func (lc *Client) TailnetLockForceLocalDisable(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: use [Client.TailnetLockForceLocalDisable] instead.
|
||||
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
|
||||
return lc.TailnetLockForceLocalDisable(ctx)
|
||||
}
|
||||
|
||||
// TailnetLockVerifySigningDeeplink verifies the tailnet lock deeplink contained
|
||||
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
|
||||
// in url and returns information extracted from it.
|
||||
func (lc *Client) TailnetLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
|
||||
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
|
||||
vr := struct {
|
||||
URL string
|
||||
}{url}
|
||||
@@ -197,13 +157,8 @@ func (lc *Client) TailnetLockVerifySigningDeeplink(ctx context.Context, url stri
|
||||
return decodeJSON[*tka.DeeplinkValidationResult](body)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
vr := struct {
|
||||
Keys []tkatype.KeyID
|
||||
ForkFrom string
|
||||
@@ -217,13 +172,8 @@ func (lc *Client) TailnetLockGenRecoveryAUM(ctx context.Context, removeKeys []tk
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
r := bytes.NewReader(aum.Serialize())
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
|
||||
if err != nil {
|
||||
@@ -233,13 +183,8 @@ func (lc *Client) TailnetLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
|
||||
func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
|
||||
r := bytes.NewReader(aum.Serialize())
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
|
||||
if err != nil {
|
||||
@@ -248,20 +193,10 @@ func (lc *Client) TailnetLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// NetworkLockDisable shuts down network-lock across the tailnet.
|
||||
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
|
||||
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
|
||||
return fmt.Errorf("error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: use [Client.TailnetLockDisable] instead.
|
||||
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
|
||||
return lc.TailnetLockDisable(ctx, secret)
|
||||
}
|
||||
|
||||
+4
-42
@@ -11,7 +11,6 @@ import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -205,49 +204,12 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
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
|
||||
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}
|
||||
)
|
||||
|
||||
// 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,6 +3,7 @@
|
||||
|
||||
//go:build cgo || !darwin
|
||||
|
||||
// Package systray provides a minimal Tailscale systray application.
|
||||
package systray
|
||||
|
||||
import (
|
||||
|
||||
+34
-68
@@ -69,11 +69,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -177,6 +172,10 @@ 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()
|
||||
@@ -293,23 +292,21 @@ func (menu *Menu) rebuild() {
|
||||
accounts := systray.AddMenuItem(account, "")
|
||||
setRemoteIcon(accounts, menu.curProfile.UserProfile.ProfilePicURL)
|
||||
time.Sleep(newMenuDelay)
|
||||
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:
|
||||
}
|
||||
})
|
||||
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:
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,27 +352,16 @@ func (menu *Menu) rebuild() {
|
||||
|
||||
// profileTitle returns the title string for a profile menu item.
|
||||
func profileTitle(profile ipn.LoginProfile) string {
|
||||
tailnet := ""
|
||||
title := profile.Name
|
||||
if profile.NetworkProfile.DomainName != "" {
|
||||
tailnet = profile.NetworkProfile.DisplayNameOrDefault()
|
||||
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()
|
||||
}
|
||||
}
|
||||
// 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 + ")"
|
||||
return title
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -635,9 +621,11 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
||||
title += strings.Split(sugg.Name, ".")[0]
|
||||
}
|
||||
menu.exitNodes.AddSeparator()
|
||||
active := recommendedIsActive(status, sugg.ID, sugg.Location.CountryCode(), sugg.Location.City())
|
||||
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", active)
|
||||
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
|
||||
setExitNodeOnClick(rm, sugg.ID)
|
||||
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
|
||||
rm.Check()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,11 +647,13 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
||||
if !ps.Online {
|
||||
name += " (offline)"
|
||||
}
|
||||
active := status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID
|
||||
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", active)
|
||||
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", false)
|
||||
if !ps.Online {
|
||||
sm.Disable()
|
||||
}
|
||||
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
|
||||
sm.Check()
|
||||
}
|
||||
setExitNodeOnClick(sm, ps.ID)
|
||||
}
|
||||
}
|
||||
@@ -753,30 +743,6 @@ 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 {
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
// 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:",omitzero"`
|
||||
Location tailcfg.LocationView `json:",omitempty"`
|
||||
}
|
||||
|
||||
// DNSOSConfig mimics dns.OSConfig without forcing us to import the entire dns package
|
||||
@@ -104,20 +104,3 @@ 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"`
|
||||
Devices KeyDeviceCapabilities `json:"devices,omitempty"`
|
||||
}
|
||||
|
||||
// KeyDeviceCapabilities are the device-related capabilities of a Key.
|
||||
|
||||
+1
-2
@@ -199,8 +199,7 @@ func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(controlURL.Host, ".tailscale.com") ||
|
||||
controlURL.Host == "control.tailscale" // for natlab tests
|
||||
return strings.HasSuffix(controlURL.Host, ".tailscale.com")
|
||||
}
|
||||
|
||||
// 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 is offline
|
||||
offline, // selected exit node node is offline
|
||||
] = useMemo(
|
||||
() => [
|
||||
selected.ID === noExitNode.ID,
|
||||
|
||||
+120
-79
@@ -35,10 +35,8 @@ 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"
|
||||
@@ -529,40 +527,45 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
var contextKeyPeer = ctxkey.New("peer-capabilities", peerCapabilities{})
|
||||
// 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 }
|
||||
|
||||
func (s *Server) setPeer(r *http.Request) (*http.Request, error) {
|
||||
func (a *apiHandler[data]) getPeer() (peerCapabilities, 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 := s.lc.StatusWithoutPeers(r.Context())
|
||||
status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -570,11 +573,56 @@ func (s *Server) setPeer(r *http.Request) (*http.Request, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.WithContext(contextKeyPeer.WithValue(r.Context(), peer)), nil
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
func (s *Server) getPeer(ctx context.Context) peerCapabilities {
|
||||
return contextKeyPeer.Value(ctx)
|
||||
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)
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
@@ -589,44 +637,67 @@ 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:
|
||||
s.serveGetNodeData(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveGetNodeData)
|
||||
return
|
||||
case path == "/exit-nodes" && r.Method == httpm.GET:
|
||||
s.serveGetExitNodes(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveGetExitNodes)
|
||||
return
|
||||
case path == "/routes" && r.Method == httpm.POST:
|
||||
handleJSON[postRoutesRequest](s.servePostRoutes)(w, r)
|
||||
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)
|
||||
return
|
||||
case path == "/device-details-click" && r.Method == httpm.POST:
|
||||
s.serveDeviceDetailsClick(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveDeviceDetailsClick)
|
||||
return
|
||||
case path == "/local/v0/logout" && r.Method == httpm.POST:
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
||||
return peer.canEdit(capFeatureAccount)
|
||||
}
|
||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
|
||||
handleJSON[maskedPrefs](s.serveUpdatePrefs)(w, r)
|
||||
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)
|
||||
return
|
||||
case path == "/local/v0/update/check" && r.Method == httpm.GET:
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/update/check" && r.Method == httpm.POST:
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
||||
return peer.canEdit(capFeatureAccount)
|
||||
}
|
||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
@@ -1051,11 +1122,6 @@ 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{
|
||||
@@ -1074,17 +1140,6 @@ 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
|
||||
@@ -1098,14 +1153,13 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
|
||||
}
|
||||
currNonExitRoutes = append(currNonExitRoutes, r.String())
|
||||
}
|
||||
// For each group of fields not being set, preserve the current prefs.
|
||||
if !data.SetExitNode {
|
||||
// Set non-edited fields to their current values.
|
||||
if data.SetExitNode {
|
||||
data.AdvertiseRoutes = currNonExitRoutes
|
||||
} else if data.SetRoutes {
|
||||
data.AdvertiseExitNode = currAdvertisingExitNode
|
||||
data.UseExitNode = prefs.ExitNodeID
|
||||
}
|
||||
if !data.SetRoutes {
|
||||
data.AdvertiseRoutes = currNonExitRoutes
|
||||
}
|
||||
|
||||
// Calculate routes.
|
||||
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
||||
@@ -1282,19 +1336,6 @@ 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 {
|
||||
|
||||
+2
-148
@@ -191,7 +191,7 @@ func TestServeAPI(t *testing.T) {
|
||||
reqBody: "{\"setExitNode\":true}",
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "SetExitNode not allowed",
|
||||
wantResponse: "not allowed",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
@@ -204,7 +204,7 @@ func TestServeAPI(t *testing.T) {
|
||||
reqContentType: "application/json",
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "RunSSHSet not allowed",
|
||||
wantResponse: "not allowed",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
@@ -1604,149 +1604,3 @@ 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,7 +11,6 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -38,25 +37,6 @@ 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"
|
||||
@@ -217,17 +197,6 @@ 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:
|
||||
@@ -361,7 +330,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
|
||||
}
|
||||
@@ -895,56 +864,6 @@ 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 {
|
||||
@@ -1305,7 +1224,7 @@ func LatestTailscaleVersion(track string) (string, error) {
|
||||
track = CurrentTrack
|
||||
}
|
||||
|
||||
latest, err := LatestPackages(track)
|
||||
latest, err := latestPackages(track)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1317,11 +1236,8 @@ func LatestTailscaleVersion(track string) (string, error) {
|
||||
ver = latest.MacZipsVersion
|
||||
case "linux":
|
||||
ver = latest.TarballsVersion
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
if distro.Get() == distro.Synology {
|
||||
ver = latest.SPKsVersion
|
||||
case distro.Gokrazy:
|
||||
ver = latest.GAFsVersion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1331,8 +1247,7 @@ func LatestTailscaleVersion(track string) (string, error) {
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
// TrackPackages is the JSON shape served at <pkgs>/<track>/?mode=json.
|
||||
type TrackPackages struct {
|
||||
type trackPackages struct {
|
||||
Version string
|
||||
Tarballs map[string]string
|
||||
TarballsVersion string
|
||||
@@ -1340,8 +1255,6 @@ 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
|
||||
@@ -1350,16 +1263,14 @@ type TrackPackages struct {
|
||||
|
||||
var tailscaleHTTPEndpoint = "https://pkgs.tailscale.com"
|
||||
|
||||
// LatestPackages fetches the package manifest served at
|
||||
// <pkgs>/<track>/?mode=json for the current runtime.GOOS.
|
||||
func LatestPackages(track string) (*TrackPackages, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
// 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 makeCmdTailscaleCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
srcExe, err := findCmdTailscale()
|
||||
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
f, err := os.Open(srcExe)
|
||||
f, err := os.Open(selfExe)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -59,25 +59,7 @@ func makeCmdTailscaleCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
f2.Close()
|
||||
return "", "", err
|
||||
}
|
||||
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
|
||||
return selfExe, f2.Name(), f2.Close()
|
||||
}
|
||||
|
||||
func markTempFileWindows(name string) error {
|
||||
@@ -177,14 +159,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"))
|
||||
_, cmdTailscaleCopy, err := makeCmdTailscaleCopy()
|
||||
_, selfCopy, err := makeSelfCopy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(cmdTailscaleCopy)
|
||||
defer os.Remove(selfCopy)
|
||||
up.Logf("running tailscale.exe copy for final install...")
|
||||
|
||||
cmd := exec.Command(cmdTailscaleCopy, "update")
|
||||
cmd := exec.Command(selfCopy, "update")
|
||||
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winVersionEnv+"="+ver)
|
||||
cmd.Stdout = up.Stderr
|
||||
cmd.Stderr = up.Stderr
|
||||
|
||||
@@ -56,11 +56,9 @@ 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 (
|
||||
@@ -331,7 +329,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 := netutil.NewDefaultTransport()
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.Proxy = feature.HookProxyFromEnvironment.GetOrNil()
|
||||
defer tr.CloseIdleConnections()
|
||||
hc := &http.Client{
|
||||
@@ -374,10 +372,7 @@ func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]
|
||||
return nil, 0, err
|
||||
}
|
||||
defer of.Close()
|
||||
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)
|
||||
})
|
||||
pw := &progressWriter{total: res.ContentLength, logf: c.logf}
|
||||
h := NewPackageHash()
|
||||
n, err := io.Copy(io.MultiWriter(of, h, pw), io.LimitReader(dlRes.Body, limit))
|
||||
if err != nil {
|
||||
@@ -392,10 +387,31 @@ 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 {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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,32 +143,19 @@ 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,
|
||||
AsyncPutTimeout: asyncPutTimeout,
|
||||
AsyncPutMaxConcurrent: 10,
|
||||
BaseURL: *srvURL,
|
||||
Disk: c.disk,
|
||||
HTTPClient: httpClient(srvHost, *srvHostDial),
|
||||
AccessToken: *token,
|
||||
Verbose: *verbose,
|
||||
BestEffortHTTP: true,
|
||||
}
|
||||
}
|
||||
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("cigocacher: closing; %d gets (%d hits, %d misses, %d errors); %d puts (%d errors)",
|
||||
log.Printf("gocacheprog: 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()
|
||||
@@ -351,23 +338,3 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-51
@@ -143,9 +143,25 @@ 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)
|
||||
writeSliceElemClone(writef, ft.Elem(),
|
||||
fmt.Sprintf("src.%s[i]", fname),
|
||||
fmt.Sprintf("dst.%s[i]", 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)
|
||||
}
|
||||
writef("}")
|
||||
writef("}")
|
||||
} else {
|
||||
@@ -169,32 +185,15 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("}")
|
||||
case *types.Map:
|
||||
elem := ft.Elem()
|
||||
if sliceType, isSlice := elem.Underlying().(*types.Slice); isSlice {
|
||||
if sliceType, isSlice := elem.(*types.Slice); isSlice {
|
||||
n := it.QualifiedName(sliceType.Elem())
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
|
||||
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("\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
|
||||
@@ -243,31 +242,6 @@ 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,7 +7,6 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/cmd/cloner/clonerex"
|
||||
)
|
||||
|
||||
@@ -183,46 +182,6 @@ 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{
|
||||
@@ -283,13 +242,3 @@ 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,13 +1,11 @@
|
||||
// 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,MapSlicePointerContainer,MapWithNamedSliceValues
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer
|
||||
|
||||
// Package clonerex is an example package for the cloner tool.
|
||||
package clonerex
|
||||
|
||||
import "maps"
|
||||
|
||||
type SliceContainer struct {
|
||||
Slice []*int
|
||||
}
|
||||
@@ -51,7 +49,9 @@ func (m NamedMap) Clone() NamedMap {
|
||||
return nil
|
||||
}
|
||||
m2 := make(NamedMap, len(m))
|
||||
maps.Copy(m2, m)
|
||||
for k, v := range m {
|
||||
m2[k] = v
|
||||
}
|
||||
return m2
|
||||
}
|
||||
|
||||
@@ -60,24 +60,8 @@ 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,64 +176,9 @@ 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,MapSlicePointerContainer,MapWithNamedSliceValues.
|
||||
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer.
|
||||
func Clone(dst, src any) bool {
|
||||
switch src := src.(type) {
|
||||
case *SliceContainer:
|
||||
@@ -281,24 +226,6 @@ 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,12 +22,11 @@ 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"
|
||||
@@ -55,10 +54,9 @@ type egressProxy struct {
|
||||
|
||||
tsClient *local.Client // never nil
|
||||
|
||||
netmapChan chan netmapState // chan to receive netmap state updates on
|
||||
netmapChan chan ipn.Notify // chan to receive netmap updates on
|
||||
|
||||
podIPv4 string // empty if Pod does not have IPv4 address
|
||||
podIPv6 string // empty if Pod does not have IPv6 address
|
||||
podIPv4 string // never empty string, currently only IPv4 is supported
|
||||
|
||||
// tailnetFQDNs is the egress service FQDN to tailnet IP mappings that
|
||||
// were last used to configure firewall rules for this proxy.
|
||||
@@ -88,7 +86,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, nm netmapState, opts egressProxyRunOpts) error {
|
||||
func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRunOpts) error {
|
||||
ep.configure(opts)
|
||||
var tickChan <-chan time.Time
|
||||
var eventChan <-chan fsnotify.Event
|
||||
@@ -107,7 +105,7 @@ func (ep *egressProxy) run(ctx context.Context, nm netmapState, opts egressProxy
|
||||
eventChan = w.Events
|
||||
}
|
||||
|
||||
if err := ep.sync(ctx, nm); err != nil {
|
||||
if err := ep.sync(ctx, n); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
@@ -118,14 +116,14 @@ func (ep *egressProxy) run(ctx context.Context, nm netmapState, opts egressProxy
|
||||
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 nm = <-ep.netmapChan:
|
||||
shouldResync := ep.shouldResync(nm)
|
||||
case n = <-ep.netmapChan:
|
||||
shouldResync := ep.shouldResync(n)
|
||||
if !shouldResync {
|
||||
continue
|
||||
}
|
||||
log.Printf("netmap change detected, ensuring firewall config is up to date...")
|
||||
}
|
||||
if err := ep.sync(ctx, nm); err != nil {
|
||||
if err := ep.sync(ctx, n); err != nil {
|
||||
return fmt.Errorf("error syncing egress service config: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -137,9 +135,8 @@ type egressProxyRunOpts struct {
|
||||
kc kubeclient.Client
|
||||
tsClient *local.Client
|
||||
stateSecret string
|
||||
netmapChan chan netmapState
|
||||
netmapChan chan ipn.Notify
|
||||
podIPv4 string
|
||||
podIPv6 string
|
||||
tailnetAddrs []netip.Prefix
|
||||
}
|
||||
|
||||
@@ -152,7 +149,6 @@ 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
|
||||
@@ -168,7 +164,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, nm netmapState) error {
|
||||
func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
|
||||
cfgs, err := ep.getConfigs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving egress service configs: %w", err)
|
||||
@@ -177,27 +173,28 @@ func (ep *egressProxy) sync(ctx context.Context, nm netmapState) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving current egress proxy status: %w", err)
|
||||
}
|
||||
newStatus, err := ep.syncEgressConfigs(cfgs, status, nm)
|
||||
newStatus, err := ep.syncEgressConfigs(cfgs, status, n)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error syncing egress service configs: %w", err)
|
||||
}
|
||||
if !servicesStatusIsEqual(newStatus, status) {
|
||||
if err := ep.setStatus(ctx, newStatus, nm); err != nil {
|
||||
if err := ep.setStatus(ctx, newStatus, n); err != nil {
|
||||
return fmt.Errorf("error setting egress proxy status: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addrsHaveChanged returns true if the provided netmap 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())
|
||||
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
|
||||
// Netmap must not be nil.
|
||||
func (ep *egressProxy) addrsHaveChanged(n ipn.Notify) bool {
|
||||
return !reflect.DeepEqual(ep.tailnetAddrs, n.NetMap.SelfNode.Addresses())
|
||||
}
|
||||
|
||||
// syncEgressConfigs adds and deletes firewall rules to match the desired
|
||||
// configuration. It uses the provided status to determine what is currently
|
||||
// applied and updates the status after a successful sync.
|
||||
func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *egressservices.Status, nm netmapState) (*egressservices.Status, error) {
|
||||
func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *egressservices.Status, n ipn.Notify) (*egressservices.Status, error) {
|
||||
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -215,8 +212,8 @@ func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *eg
|
||||
// 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, nm)
|
||||
for svcName, cfg := range *cfgs {
|
||||
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error determining tailnet target IPs: %w", err)
|
||||
}
|
||||
@@ -231,12 +228,12 @@ func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *eg
|
||||
if len(rulesToDelete) != 0 {
|
||||
mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete)
|
||||
}
|
||||
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(nm) {
|
||||
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(n) {
|
||||
// For each tailnet target, set up SNAT from the local tailnet device address of the matching
|
||||
// family.
|
||||
for _, t := range tailnetTargetIPs {
|
||||
var local netip.Addr
|
||||
for _, pfx := range nm.self.Addresses().All() {
|
||||
for _, pfx := range n.NetMap.SelfNode.Addresses().All() {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
@@ -252,9 +249,6 @@ func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *eg
|
||||
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.
|
||||
@@ -358,7 +352,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
|
||||
}
|
||||
@@ -373,7 +367,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs egressservices.Configs, st
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -385,7 +379,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs egressservices.Configs, st
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -397,7 +391,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
|
||||
}
|
||||
@@ -421,7 +415,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 status.PodIPv4 == ep.podIPv4 && status.PodIPv6 == ep.podIPv6 {
|
||||
if reflect.DeepEqual(status.PodIPv4, ep.podIPv4) {
|
||||
return status, nil
|
||||
}
|
||||
return nil, nil
|
||||
@@ -429,13 +423,12 @@ 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, nm netmapState) error {
|
||||
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, n ipn.Notify) error {
|
||||
// Pod IP is used to determine if a stored status applies to THIS proxy Pod.
|
||||
if status == nil {
|
||||
status = &egressservices.Status{}
|
||||
}
|
||||
status.PodIPv4 = ep.podIPv4
|
||||
status.PodIPv6 = ep.podIPv6
|
||||
secret, err := ep.kc.GetSecret(ctx, ep.stateSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving state Secret: %w", err)
|
||||
@@ -453,7 +446,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 = nm.self.Addresses().AsSlice()
|
||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -463,7 +456,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, nm netmapState) (addrs []netip.Addr, err error) {
|
||||
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.Notify) (addrs []netip.Addr, err error) {
|
||||
if svc.TailnetTarget.IP != "" {
|
||||
addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
|
||||
if err != nil {
|
||||
@@ -479,11 +472,11 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm netm
|
||||
if svc.TailnetTarget.FQDN == "" {
|
||||
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
|
||||
}
|
||||
if !nm.self.Valid() {
|
||||
log.Printf("netmap state is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
|
||||
if n.NetMap == nil {
|
||||
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
|
||||
return addrs, nil
|
||||
}
|
||||
egressAddrs, err := resolveTailnetFQDN(nm, svc.TailnetTarget.FQDN)
|
||||
egressAddrs, err := resolveTailnetFQDN(n.NetMap, svc.TailnetTarget.FQDN)
|
||||
if err != nil {
|
||||
log.Printf("error fetching backend addresses for %q: %v", svc.TailnetTarget.FQDN, err)
|
||||
return addrs, nil
|
||||
@@ -507,26 +500,26 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm netm
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// shouldResync parses netmap state update and returns true if the update contains
|
||||
// shouldResync parses netmap update and returns true if the update contains
|
||||
// changes for which the egress proxy's firewall should be reconfigured.
|
||||
func (ep *egressProxy) shouldResync(nm netmapState) bool {
|
||||
if !nm.self.Valid() {
|
||||
func (ep *egressProxy) shouldResync(n ipn.Notify) bool {
|
||||
if n.NetMap == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If proxy's tailnet addresses have changed, resync.
|
||||
if !views.SliceEqual(nm.self.Addresses(), views.SliceOf(ep.tailnetAddrs)) {
|
||||
if !reflect.DeepEqual(n.NetMap.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
|
||||
log.Printf("node addresses have changed, trigger egress config resync")
|
||||
ep.tailnetAddrs = nm.self.Addresses().AsSlice()
|
||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
return true
|
||||
}
|
||||
|
||||
// If the IPs for any of the egress services configured via FQDN have
|
||||
// changed, resync.
|
||||
for fqdn, ips := range ep.targetFQDNs {
|
||||
for nn := range nm.peers() {
|
||||
for _, nn := range n.NetMap.Peers {
|
||||
if equalFQDNs(nn.Name(), fqdn) {
|
||||
if !views.SliceEqual(views.SliceOf(ips), nn.Addresses()) {
|
||||
if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) {
|
||||
log.Printf("backend addresses for egress target %q have changed old IPs %v, new IPs %v trigger egress config resync", nn.Name(), ips, nn.Addresses().AsSlice())
|
||||
return true
|
||||
}
|
||||
@@ -609,8 +602,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 {
|
||||
@@ -626,8 +619,6 @@ func servicesStatusIsEqual(st, st1 *egressservices.Status) bool {
|
||||
}
|
||||
st.PodIPv4 = ""
|
||||
st1.PodIPv4 = ""
|
||||
st.PodIPv6 = ""
|
||||
st1.PodIPv6 = ""
|
||||
return reflect.DeepEqual(*st, *st1)
|
||||
}
|
||||
|
||||
@@ -666,42 +657,37 @@ 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 {
|
||||
select {
|
||||
case <-ctx.Done(): // kubelet's HTTP request timeout
|
||||
if ctx.Err() != nil { // kubelet's HTTP request timeout
|
||||
log.Printf("Cluster traffic for %s did not stop being routed to this Pod.", svc)
|
||||
return
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -715,9 +701,9 @@ func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs egressse
|
||||
|
||||
// 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, podIPHeader string, repeat int, client httpClient) (bool, error) {
|
||||
func lookupPodRoute(ctx context.Context, hep, podIP string, repeat int, client httpClient) (bool, error) {
|
||||
for range repeat {
|
||||
f, err := lookup(ctx, hep, podIP, podIPHeader, client)
|
||||
f, err := lookup(ctx, hep, podIP, client)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -729,7 +715,7 @@ func lookupPodRoute(ctx context.Context, hep, podIP, podIPHeader string, repeat
|
||||
}
|
||||
|
||||
// lookup calls the healthcheck endpoint and returns true if the response contains the podIP header.
|
||||
func lookup(ctx context.Context, hep, podIP, podIPHeader string, client httpClient) (bool, error) {
|
||||
func lookup(ctx context.Context, hep, podIP string, client httpClient) (bool, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.GET, hep, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error creating new HTTP request: %v", err)
|
||||
@@ -744,7 +730,7 @@ func lookup(ctx context.Context, hep, podIP, podIPHeader string, client httpClie
|
||||
return true, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
gotIP := resp.Header.Get(podIPHeader)
|
||||
gotIP := resp.Header.Get(kubetypes.PodIPv4Header)
|
||||
return strings.EqualFold(podIP, gotIP), nil
|
||||
}
|
||||
|
||||
@@ -773,17 +759,3 @@ 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,7 +15,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
@@ -256,13 +255,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,
|
||||
}
|
||||
}
|
||||
@@ -270,8 +269,7 @@ func TestWaitTillSafeToShutdown(t *testing.T) {
|
||||
}
|
||||
|
||||
ep := &egressProxy{
|
||||
podIPv4: podIP,
|
||||
shortSleep: time.Millisecond,
|
||||
podIPv4: podIP,
|
||||
client: &mockHTTPClient{
|
||||
podIP: podIP,
|
||||
anotherIP: anotherIP,
|
||||
|
||||
@@ -265,13 +265,7 @@ 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)
|
||||
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
|
||||
return nfr.EnsureDNATRuleForSvc(serviceName, tsIP, clusterIP)
|
||||
}
|
||||
|
||||
// ensureIngressRulesDeleted takes a map of Tailscale Services and rules and ensures that the firewall rules are deleted.
|
||||
|
||||
@@ -7,7 +7,6 @@ package main
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/kube/ingressservices"
|
||||
@@ -23,7 +22,6 @@ 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",
|
||||
@@ -37,7 +35,6 @@ 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",
|
||||
@@ -55,11 +52,6 @@ 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",
|
||||
@@ -73,10 +65,6 @@ 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",
|
||||
@@ -90,7 +78,6 @@ 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",
|
||||
@@ -107,7 +94,6 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
TailscaleServiceIP netip.Addr
|
||||
ClusterIP netip.Addr
|
||||
}{},
|
||||
wantClampedAddrs: nil, // no rules added, no clamping
|
||||
},
|
||||
{
|
||||
name: "add_remove_modify",
|
||||
@@ -131,10 +117,6 @@ 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",
|
||||
@@ -170,17 +152,12 @@ 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) {
|
||||
nfr := linuxfw.NewFakeNetfilterRunner()
|
||||
var nfr linuxfw.NetfilterRunner = linuxfw.NewFakeNetfilterRunner()
|
||||
|
||||
ep := &ingressProxy{
|
||||
nfr: nfr,
|
||||
@@ -193,7 +170,8 @@ func TestSyncIngressConfigs(t *testing.T) {
|
||||
t.Fatalf("syncIngressConfigs failed: %v", err)
|
||||
}
|
||||
|
||||
gotServices := nfr.GetServiceState()
|
||||
fake := nfr.(*linuxfw.FakeNetfilterRunner)
|
||||
gotServices := fake.GetServiceState()
|
||||
if len(gotServices) != len(tt.wantServices) {
|
||||
t.Errorf("got %d services, want %d", len(gotServices), len(tt.wantServices))
|
||||
}
|
||||
@@ -210,20 +188,6 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+86
-29
@@ -21,7 +21,6 @@ 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"
|
||||
@@ -33,6 +32,7 @@ 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,9 +127,6 @@ 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 {
|
||||
@@ -142,7 +139,12 @@ func (kc *kubeClient) resetContainerbootState(ctx context.Context, podUID string
|
||||
|
||||
s := &kubeapi.Secret{
|
||||
Data: map[string][]byte{
|
||||
kubetypes.KeyCapVer: fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion),
|
||||
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.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -167,18 +169,47 @@ func (kc *kubeClient) setAndWaitForAuthKeyReissue(ctx context.Context, client *l
|
||||
return fmt.Errorf("error disconnecting from control: %w", err)
|
||||
}
|
||||
|
||||
err = authkey.SetReissueAuthKey(ctx, kc.Client, kc.stateSecret, tailscaledConfigAuthKey, authkey.TailscaleContainerFieldManager)
|
||||
err = kc.setReissueAuthKey(ctx, tailscaledConfigAuthKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set reissue_authkey in Kubernetes Secret: %w", err)
|
||||
}
|
||||
|
||||
clearFn := func(ctx context.Context) error {
|
||||
return authkey.ClearReissueAuthKey(ctx, kc.Client, kc.stateSecret, authkey.TailscaleContainerFieldManager)
|
||||
err = kc.waitForAuthKeyReissue(ctx, cfg.TailscaledConfigFilePath, tailscaledConfigAuthKey, 10*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to receive new auth key: %w", err)
|
||||
}
|
||||
|
||||
getAuthKey := func() string { return authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath) }
|
||||
tailscaledCfgDir := filepath.Dir(cfg.TailscaledConfigFilePath)
|
||||
var notify <-chan struct{}
|
||||
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
|
||||
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 {
|
||||
@@ -186,28 +217,54 @@ func (kc *kubeClient) setAndWaitForAuthKeyReissue(ctx context.Context, client *l
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// still keep polling if using fsnotify, for logging and in case fsnotify fails
|
||||
pt := time.NewTicker(pollInterval)
|
||||
defer pt.Stop()
|
||||
pollTicker = pt.C
|
||||
|
||||
return nil
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// waitForConsistentState waits for tailscaled to finish writing state if it
|
||||
|
||||
@@ -257,8 +257,12 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
authkey: "new-authkey",
|
||||
initial: map[string][]byte{},
|
||||
expected: map[string][]byte{
|
||||
kubetypes.KeyCapVer: capver,
|
||||
kubetypes.KeyPodUID: []byte("1234"),
|
||||
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,
|
||||
@@ -267,7 +271,11 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
"empty_initial_no_pod_uid": {
|
||||
initial: map[string][]byte{},
|
||||
expected: map[string][]byte{
|
||||
kubetypes.KeyCapVer: capver,
|
||||
kubetypes.KeyCapVer: capver,
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -295,6 +303,9 @@ 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,
|
||||
@@ -310,6 +321,9 @@ 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,
|
||||
@@ -324,6 +338,9 @@ 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,
|
||||
@@ -338,6 +355,9 @@ 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,
|
||||
|
||||
+264
-444
@@ -120,7 +120,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"iter"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
@@ -136,15 +135,12 @@ 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/ipnstate"
|
||||
"tailscale.com/ipn/conffile"
|
||||
kubeutils "tailscale.com/k8s-operator"
|
||||
"tailscale.com/kube/authkey"
|
||||
healthz "tailscale.com/kube/health"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
klc "tailscale.com/kube/localclient"
|
||||
@@ -152,170 +148,21 @@ import (
|
||||
"tailscale.com/kube/services"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/types/netmap"
|
||||
"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 def.Bool(os.Getenv("TS_TEST_FAKE_NETFILTER"), false) {
|
||||
if defaultBool("TS_TEST_FAKE_NETFILTER", false) {
|
||||
return linuxfw.NewFakeIPTablesRunner(), nil
|
||||
}
|
||||
return linuxfw.New(logf, "")
|
||||
}
|
||||
|
||||
func getAutoAdvertiseBool() bool {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultBool("TS_EXPERIMENTAL_SERVICE_AUTO_ADVERTISEMENT", true)
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -362,7 +209,7 @@ func run() error {
|
||||
|
||||
var tailscaledConfigAuthkey string
|
||||
if isOneStepConfig(cfg) {
|
||||
tailscaledConfigAuthkey = authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath)
|
||||
tailscaledConfigAuthkey = authkeyFromTailscaledConfig(cfg.TailscaledConfigFilePath)
|
||||
}
|
||||
|
||||
var kc *kubeClient
|
||||
@@ -424,7 +271,7 @@ func run() error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
log.Printf("Running healthcheck endpoint at %s/healthz", cfg.HealthCheckAddrPort)
|
||||
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, cfg.PodIPv6, log.Printf)
|
||||
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, log.Printf)
|
||||
|
||||
close := runHTTPServer(mux, cfg.HealthCheckAddrPort)
|
||||
defer close()
|
||||
@@ -440,7 +287,7 @@ func run() error {
|
||||
|
||||
if cfg.localHealthEnabled() {
|
||||
log.Printf("Running healthcheck endpoint at %s/healthz", cfg.LocalAddrPort)
|
||||
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, cfg.PodIPv6, log.Printf)
|
||||
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, log.Printf)
|
||||
}
|
||||
|
||||
if cfg.egressSvcsTerminateEPEnabled() {
|
||||
@@ -458,7 +305,7 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
w, err := client.WatchIPNBus(bootCtx, containerbootWatchMask|ipn.NotifyInitialPrefs|ipn.NotifyInitialHealthState)
|
||||
w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState|ipn.NotifyInitialHealthState|ipn.NotifyRateLimit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to watch tailscaled for updates: %w", err)
|
||||
}
|
||||
@@ -498,7 +345,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, containerbootWatchMask)
|
||||
w, err = client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState|ipn.NotifyRateLimit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rewatching tailscaled for updates after auth: %w", err)
|
||||
}
|
||||
@@ -518,8 +365,8 @@ authLoop:
|
||||
return fmt.Errorf("failed to read from tailscaled: %w", err)
|
||||
}
|
||||
|
||||
if state, ok := notifyState(n); ok {
|
||||
switch state {
|
||||
if n.State != nil {
|
||||
switch *n.State {
|
||||
case ipn.NeedsLogin:
|
||||
if isOneStepConfig(cfg) {
|
||||
// This could happen if this is the first time tailscaled was run for this
|
||||
@@ -527,7 +374,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(ctx, client, cfg, tailscaledConfigAuthkey)
|
||||
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get a reissued authkey: %w", err)
|
||||
}
|
||||
@@ -555,7 +402,7 @@ authLoop:
|
||||
// deadline to continue monitoring for changes.
|
||||
break authLoop
|
||||
default:
|
||||
log.Printf("tailscaled in state %q, waiting", state)
|
||||
log.Printf("tailscaled in state %q, waiting", *n.State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,7 +414,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(ctx, client, cfg, tailscaledConfigAuthkey)
|
||||
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get a reissued authkey: %w", err)
|
||||
}
|
||||
@@ -610,7 +457,7 @@ authLoop:
|
||||
}
|
||||
}
|
||||
|
||||
w, err = client.WatchIPNBus(ctx, containerbootWatchMask)
|
||||
w, err = client.WatchIPNBus(ctx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState|ipn.NotifyRateLimit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rewatching tailscaled for updates after auth: %w", err)
|
||||
}
|
||||
@@ -689,7 +536,7 @@ authLoop:
|
||||
failedResolveAttempts++
|
||||
}
|
||||
|
||||
var egressSvcsNotify chan netmapState
|
||||
var egressSvcsNotify chan ipn.Notify
|
||||
notifyChan := make(chan ipn.Notify)
|
||||
errChan := make(chan error)
|
||||
go func() {
|
||||
@@ -703,12 +550,10 @@ authLoop:
|
||||
}
|
||||
}
|
||||
}()
|
||||
var nmState netmapState
|
||||
var wg sync.WaitGroup
|
||||
|
||||
runLoop:
|
||||
for {
|
||||
var processNetmap bool
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Although killTailscaled() is deferred earlier, if we
|
||||
@@ -722,17 +567,244 @@ runLoop:
|
||||
case err := <-cfgWatchErrChan:
|
||||
return fmt.Errorf("failed to watch tailscaled config: %w", err)
|
||||
case n := <-notifyChan:
|
||||
nmState = nmState.processNotify(ctx, client, n)
|
||||
if state, ok := notifyState(n); ok && state != ipn.Running {
|
||||
// TODO: (ChaosInTheCRD) Add node removed check when supported by ipn
|
||||
if n.State != nil && *n.State != ipn.Running {
|
||||
// Something's gone wrong and we've left the authenticated state.
|
||||
// Our container image never recovered gracefully from this, and the
|
||||
// control flow required to make it work now is hard. So, just crash
|
||||
// the container and rely on the container runtime to restart us,
|
||||
// whereupon we'll go through initial auth again.
|
||||
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", state)
|
||||
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", *n.State)
|
||||
}
|
||||
if n.InitialStatus != nil || n.SelfChange != nil || len(n.PeersChanged) != 0 || len(n.PeersRemoved) != 0 || len(n.PeerChangedPatch) != 0 {
|
||||
processNetmap = true
|
||||
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()
|
||||
}
|
||||
}
|
||||
case <-tc:
|
||||
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
|
||||
@@ -752,253 +824,11 @@ 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()
|
||||
|
||||
@@ -1134,52 +964,34 @@ 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, 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) {
|
||||
// can be either a peer device or a Tailscale Service.
|
||||
func resolveTailnetFQDN(nm *netmap.NetworkMap, 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.
|
||||
var ret []netip.Prefix
|
||||
for p := range nm.peers() {
|
||||
for _, p := range nm.Peers {
|
||||
if strings.EqualFold(p.Name(), dnsFQDN.WithTrailingDot()) {
|
||||
ret = p.Addresses().AsSlice()
|
||||
break
|
||||
return p.Addresses().AsSlice(), nil
|
||||
}
|
||||
}
|
||||
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, service or 4via6 address %q; it either does not exist, or not reachable because of ACLs", 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)
|
||||
}
|
||||
|
||||
// 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 netmapState, fqdn dnsname.FQDN) []netip.Prefix {
|
||||
func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Prefix {
|
||||
var extraRecords []tailcfg.DNSRecord
|
||||
for _, rec := range nm.dnsExtraRecords.All() {
|
||||
for _, rec := range nm.DNS.ExtraRecords {
|
||||
recFQDN, err := dnsname.ToFQDN(rec.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -1201,7 +1013,7 @@ func serviceIPsFromNetMap(nm netmapState, fqdn dnsname.FQDN) []netip.Prefix {
|
||||
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)
|
||||
@@ -1212,3 +1024,11 @@ func serviceIPsFromNetMap(nm netmapState, fqdn dnsname.FQDN) []netip.Prefix {
|
||||
|
||||
return prefixes
|
||||
}
|
||||
|
||||
func authkeyFromTailscaledConfig(path string) string {
|
||||
if cfg, err := conffile.Load(path); err == nil && cfg.Parsed.AuthKey != nil {
|
||||
return *cfg.Parsed.AuthKey
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
+37
-150
@@ -7,7 +7,6 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -33,30 +32,24 @@ 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/key"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -110,10 +103,12 @@ func TestContainerBoot(t *testing.T) {
|
||||
}
|
||||
runningNotify := &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
SelfChange: &tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
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(),
|
||||
},
|
||||
}
|
||||
type testCase struct {
|
||||
@@ -386,16 +381,18 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
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")},
|
||||
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(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -632,10 +629,12 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
SelfChange: &tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("newID"),
|
||||
Name: "new-name.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("newID"),
|
||||
Name: "new-name.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
}).View(),
|
||||
},
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
@@ -1094,16 +1093,18 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
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")},
|
||||
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(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1119,23 +1120,6 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -1290,12 +1274,6 @@ 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)
|
||||
@@ -1524,43 +1502,6 @@ 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":
|
||||
@@ -1948,57 +1889,3 @@ 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,6 +24,7 @@ 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
|
||||
@@ -141,6 +142,13 @@ 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,7 +6,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/util/def"
|
||||
)
|
||||
|
||||
// settings is all the configuration for containerboot.
|
||||
@@ -91,50 +89,47 @@ type settings struct {
|
||||
|
||||
func configFromEnv() (*settings, error) {
|
||||
cfg := &settings{
|
||||
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"),
|
||||
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", ""),
|
||||
Routes: defaultEnvStringPointer("TS_ROUTES"),
|
||||
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"),
|
||||
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
|
||||
ProxyTargetIP: defaultEnv("TS_DEST_IP", ""),
|
||||
ProxyTargetDNSName: defaultEnv("TS_EXPERIMENTAL_DEST_DNS_NAME", ""),
|
||||
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
|
||||
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
|
||||
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
|
||||
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
|
||||
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
|
||||
UserspaceMode: def.Bool(os.Getenv("TS_USERSPACE"), true),
|
||||
StateDir: os.Getenv("TS_STATE_DIR"),
|
||||
UserspaceMode: defaultBool("TS_USERSPACE", true),
|
||||
StateDir: defaultEnv("TS_STATE_DIR", ""),
|
||||
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
|
||||
KubeSecret: func() string {
|
||||
if os.Getenv("KUBERNETES_SERVICE_HOST") == "" {
|
||||
return os.Getenv("TS_KUBE_SECRET")
|
||||
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
|
||||
return defaultEnv("TS_KUBE_SECRET", "tailscale")
|
||||
}
|
||||
// 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")
|
||||
return defaultEnv("TS_KUBE_SECRET", "")
|
||||
}(),
|
||||
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"), "/"),
|
||||
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
|
||||
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
|
||||
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
TailscaledConfigFilePath: tailscaledConfigFilePath(),
|
||||
AllowProxyingClusterTrafficViaIngress: 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"),
|
||||
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", ""),
|
||||
}
|
||||
|
||||
podIPs, ok := os.LookupEnv("POD_IPS")
|
||||
@@ -158,7 +153,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 := def.Bool(os.Getenv("TS_EXPERIMENTAL_CERT_SHARE"), false)
|
||||
isInCertShareMode := defaultBool("TS_EXPERIMENTAL_CERT_SHARE", false)
|
||||
if isInCertShareMode {
|
||||
cfg.CertShareMode = "ro"
|
||||
podName := os.Getenv("POD_NAME")
|
||||
@@ -459,6 +454,15 @@ 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.
|
||||
@@ -480,3 +484,23 @@ 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,7 +7,6 @@ package main
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -229,78 +228,6 @@ 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,15 +150,7 @@ func tailscaleUp(ctx context.Context, cfg *settings) error {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
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 fmt.Errorf("tailscale up failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -188,11 +180,7 @@ func tailscaleSet(ctx context.Context, cfg *settings) error {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
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 fmt.Errorf("tailscale set failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -41,28 +41,8 @@ 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)
|
||||
@@ -120,8 +100,7 @@ func TestUnpublishedDNS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func resetMetrics(tb testing.TB) {
|
||||
tstest.AssertNotParallel(tb)
|
||||
func resetMetrics() {
|
||||
publishedDNSHits.Set(0)
|
||||
publishedDNSMisses.Set(0)
|
||||
unpublishedDNSHits.Set(0)
|
||||
@@ -135,7 +114,8 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
pub := &dnsEntryMap{
|
||||
IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}},
|
||||
}
|
||||
setDNSCache(t, pub)
|
||||
dnsCache.Store(pub)
|
||||
dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`))
|
||||
|
||||
unpublishedDNSCache.Store(&dnsEntryMap{
|
||||
IPs: map[string][]net.IP{
|
||||
@@ -151,7 +131,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(t)
|
||||
resetMetrics()
|
||||
ips := getBootstrapDNS(t, q)
|
||||
|
||||
// Expected our public map to be returned on a cache miss
|
||||
@@ -169,7 +149,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
|
||||
// Verify that we do get a valid response and metric.
|
||||
t.Run("CacheHit", func(t *testing.T) {
|
||||
resetMetrics(t)
|
||||
resetMetrics()
|
||||
ips := getBootstrapDNS(t, "controlplane.tailscale.com")
|
||||
want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
|
||||
if !reflect.DeepEqual(ips, want) {
|
||||
@@ -186,10 +166,8 @@ 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(t)
|
||||
resetMetrics()
|
||||
for _, q := range d {
|
||||
_ = getBootstrapDNS(t, q)
|
||||
}
|
||||
|
||||
+11
-27
@@ -23,7 +23,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
@@ -36,34 +35,21 @@ 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. The
|
||||
// *tls.Certificate values it returns may be shared and cached, so
|
||||
// callers must not mutate them.
|
||||
// The returned Config must have a GetCertificate function set and that
|
||||
// function must return a unique *tls.Certificate for each call. The
|
||||
// returned *tls.Certificate will be mutated by the caller to append to the
|
||||
// (*tls.Certificate).Certificate field.
|
||||
TLSConfig() *tls.Config
|
||||
// HTTPHandler handle ACME related request, if any.
|
||||
HTTPHandler(fallback http.Handler) http.Handler
|
||||
}
|
||||
|
||||
func certProviderByCertMode(mode, dir, hostname string, ipCerts bool, eabKID, eabKey, email string) (certProvider, error) {
|
||||
func certProviderByCertMode(mode, dir, hostname, 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),
|
||||
@@ -96,9 +82,6 @@ func certProviderByCertMode(mode, dir, hostname string, ipCerts bool, eabKID, ea
|
||||
} 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)
|
||||
@@ -174,11 +157,12 @@ 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 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
|
||||
// Return a shallow copy of the cert so the caller can append to its
|
||||
// Certificate field.
|
||||
certCopy := new(tls.Certificate)
|
||||
*certCopy = *m.cert
|
||||
certCopy.Certificate = certCopy.Certificate[:len(certCopy.Certificate):len(certCopy.Certificate)]
|
||||
return certCopy, nil
|
||||
}
|
||||
|
||||
func (m *manualCertManager) HTTPHandler(fallback http.Handler) http.Handler {
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestCertIP(t *testing.T) {
|
||||
t.Fatalf("Error closing key.pem: %v", err)
|
||||
}
|
||||
|
||||
cp, err := certProviderByCertMode("manual", dir, hostname, false, "", "", "")
|
||||
cp, err := certProviderByCertMode("manual", dir, hostname, "", "", "")
|
||||
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", false, "", "", "test@example.com")
|
||||
_, err := certProviderByCertMode("gcp", dir, "test.example.com", "", "", "test@example.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when EAB credentials are missing")
|
||||
}
|
||||
|
||||
// Missing email
|
||||
_, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk", "")
|
||||
_, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when email is missing")
|
||||
}
|
||||
|
||||
// Invalid base64
|
||||
_, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "not-valid!", "test@example.com")
|
||||
_, err = certProviderByCertMode("gcp", dir, "test.example.com", "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", false, "kid", "dGVzdC1rZXk", "test@example.com")
|
||||
cp, err := certProviderByCertMode("gcp", dir, "test.example.com", "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", false, "kid", "dGVzdC1rZXk=", "test@example.com")
|
||||
cp, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk=", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("base64: %v", err)
|
||||
}
|
||||
|
||||
+17
-23
@@ -6,9 +6,10 @@ 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/derp/derpserver+
|
||||
github.com/coder/websocket from tailscale.com/cmd/derper+
|
||||
github.com/coder/websocket/internal/errd from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/util from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
|
||||
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
|
||||
@@ -19,8 +20,6 @@ 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
|
||||
@@ -91,7 +90,6 @@ 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
|
||||
@@ -108,23 +106,19 @@ 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/derp/derpserver
|
||||
tailscale.com/net/wsconn from tailscale.com/cmd/derper
|
||||
tailscale.com/paths from tailscale.com/client/local
|
||||
💣 tailscale.com/safesocket from tailscale.com/client/local
|
||||
tailscale.com/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+
|
||||
@@ -140,7 +134,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
|
||||
@@ -169,7 +163,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+
|
||||
@@ -249,22 +243,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/fips140+
|
||||
crypto/internal/fips140 from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/aes from crypto/aes+
|
||||
crypto/internal/fips140/aes/gcm from crypto/cipher+
|
||||
crypto/internal/fips140/alias from crypto/cipher+
|
||||
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/check from crypto/fips140+
|
||||
crypto/internal/fips140/drbg from crypto/hpke+
|
||||
crypto/internal/fips140/check from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
|
||||
crypto/internal/fips140/ecdh from crypto/ecdh
|
||||
crypto/internal/fips140/ecdsa from crypto/ecdsa
|
||||
crypto/internal/fips140/ed25519 from crypto/ed25519
|
||||
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
|
||||
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/fips140/hkdf from crypto/hkdf+
|
||||
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
|
||||
crypto/internal/fips140/hmac from crypto/hmac+
|
||||
crypto/internal/fips140/mlkem from crypto/mlkem
|
||||
crypto/internal/fips140/nistec from crypto/ecdsa+
|
||||
crypto/internal/fips140/nistec from crypto/elliptic+
|
||||
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
|
||||
crypto/internal/fips140/rsa from crypto/rsa
|
||||
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
|
||||
@@ -315,8 +309,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+
|
||||
@@ -330,13 +324,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/ed25519+
|
||||
internal/godebug from crypto/internal/fips140deps/godebug+
|
||||
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 internal/syscall/windows+
|
||||
internal/oserror from io/fs+
|
||||
internal/poll from net+
|
||||
internal/profile from net/http/pprof
|
||||
internal/profilerecord from runtime+
|
||||
@@ -346,9 +340,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 internal/runtime/gc/scan+
|
||||
internal/runtime/gc from runtime+
|
||||
internal/runtime/gc/scan from runtime
|
||||
internal/runtime/maps from hash/maphash+
|
||||
internal/runtime/maps from reflect+
|
||||
internal/runtime/math from internal/runtime/maps+
|
||||
internal/runtime/pprof/label from runtime+
|
||||
internal/runtime/sys from crypto/subtle+
|
||||
@@ -362,7 +356,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/fips140deps/time+
|
||||
W internal/syscall/windows from crypto/internal/sysrand+
|
||||
W internal/syscall/windows/registry from mime+
|
||||
W internal/syscall/windows/sysdll from internal/syscall/windows+
|
||||
internal/testlog from os
|
||||
|
||||
+20
-32
@@ -62,11 +62,10 @@ 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. 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)")
|
||||
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")
|
||||
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")
|
||||
@@ -88,7 +87,8 @@ 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")
|
||||
|
||||
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.")
|
||||
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)")
|
||||
|
||||
// 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,11 +195,12 @@ func main() {
|
||||
s.SetVerifyClientURL(*verifyClientURL)
|
||||
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
|
||||
s.SetTCPWriteTimeout(*tcpWriteTimeout)
|
||||
if *rateConfigPath != "" {
|
||||
if err := s.LoadAndApplyRateConfig(*rateConfigPath); err != nil {
|
||||
log.Fatalf("derper: loading rate config: %v", err)
|
||||
if *perClientRateLimit > 0 {
|
||||
burst := *perClientRateBurst
|
||||
if burst < 1 {
|
||||
burst = *perClientRateLimit * 2
|
||||
}
|
||||
go watchRateConfig(ctx, s, *rateConfigPath)
|
||||
s.SetPerClientRateLimit(*perClientRateLimit, burst)
|
||||
}
|
||||
|
||||
var meshKey string
|
||||
@@ -253,7 +254,7 @@ func main() {
|
||||
if err := startMesh(s); err != nil {
|
||||
log.Fatalf("startMesh: %v", err)
|
||||
}
|
||||
expvar.Publish("derp", s.ExpVar(*rateConfigPath != ""))
|
||||
expvar.Publish("derp", s.ExpVar())
|
||||
|
||||
handleHome, ok := getHomeHandler(*flagHome)
|
||||
if !ok {
|
||||
@@ -263,7 +264,7 @@ func main() {
|
||||
mux := http.NewServeMux()
|
||||
if *runDERP {
|
||||
derpHandler := derpserver.Handler(s)
|
||||
derpHandler = derpserver.AddWebSocketSupport(s, derpHandler)
|
||||
derpHandler = addWebSocketSupport(s, derpHandler)
|
||||
mux.Handle("/derp", derpHandler)
|
||||
} else {
|
||||
mux.Handle("/derp", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -350,12 +351,20 @@ func main() {
|
||||
if serveTLS {
|
||||
log.Printf("derper: serving on %s with TLS", *addr)
|
||||
var certManager certProvider
|
||||
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeIPCerts, *acmeEABKid, *acmeEABKey, *acmeEmail)
|
||||
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeEABKid, *acmeEABKey, *acmeEmail)
|
||||
if err != nil {
|
||||
log.Fatalf("derper: can not start cert provider: %v", err)
|
||||
}
|
||||
httpsrv.TLSConfig = certManager.TLSConfig()
|
||||
s.ModifyTLSConfigToAddMetaCert(httpsrv.TLSConfig)
|
||||
getCert := httpsrv.TLSConfig.GetCertificate
|
||||
httpsrv.TLSConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cert, err := getCert(hi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert.Certificate = append(cert.Certificate, s.MetaCert())
|
||||
return cert, nil
|
||||
}
|
||||
// Disable TLS 1.0 and 1.1, which are obsolete and have security issues.
|
||||
httpsrv.TLSConfig.MinVersion = tls.VersionTLS12
|
||||
httpsrv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -427,27 +436,6 @@ 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 {
|
||||
|
||||
@@ -1,497 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package derpserver
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -11,20 +11,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"tailscale.com/derp/derpserver"
|
||||
"tailscale.com/net/wsconn"
|
||||
)
|
||||
|
||||
var counterWebSocketAccepts = expvar.NewInt("derp_websocket_accepts")
|
||||
|
||||
// AddWebSocketSupport returns an http.Handler wrapping base that adds
|
||||
// WebSocket-DERP support. WebSocket-DERP requests (those with an Upgrade:
|
||||
// websocket header and a "derp" Sec-WebSocket-Protocol value) are
|
||||
// handled here; all other requests pass through to base.
|
||||
//
|
||||
// The browser-side Tailscale client (cmd/tsconnect/wasm) can only reach DERP
|
||||
// via WebSocket, so any DERP server intended to be reachable from browsers
|
||||
// must wrap derpserver.Handler with this function.
|
||||
func AddWebSocketSupport(s *Server, base http.Handler) http.Handler {
|
||||
// addWebSocketSupport returns a Handle wrapping base that adds WebSocket server support.
|
||||
func addWebSocketSupport(s *derpserver.Server, base http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
up := strings.ToLower(r.Header.Get("Upgrade"))
|
||||
|
||||
@@ -1,821 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// 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.
|
Before 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/identityfederation"
|
||||
_ "tailscale.com/feature/condregister/identityfederation"
|
||||
"tailscale.com/internal/client/tailscale"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
@@ -255,11 +255,7 @@ 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(), tailscale.ExchangeJWTForTokenWIFArgs{
|
||||
BaseURL: fmt.Sprintf("https://%s", *apiServer),
|
||||
ClientID: oauthId,
|
||||
IDToken: idToken,
|
||||
})
|
||||
apiKeyEnv, err = exchangeJWTForToken(context.Background(), fmt.Sprintf("https://%s", *apiServer), oauthId, idToken)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
+201
-5
@@ -5,16 +5,212 @@
|
||||
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/cmd/hello/helloserver"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
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() {
|
||||
s := &helloserver.Server{
|
||||
HTTPAddr: ":80",
|
||||
HTTPSAddr: ":443",
|
||||
flag.Parse()
|
||||
if *testIP != "" {
|
||||
res, err := localClient.WhoIs(context.Background(), *testIP)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
e := json.NewEncoder(os.Stdout)
|
||||
e.SetIndent("", "\t")
|
||||
e.Encode(res)
|
||||
return
|
||||
}
|
||||
if devMode() {
|
||||
// Parse it optimistically
|
||||
var err error
|
||||
tmpl, err = template.New("home").Parse(embeddedTemplate)
|
||||
if err != nil {
|
||||
log.Printf("ignoring template error in dev mode: %v", err)
|
||||
}
|
||||
} else {
|
||||
if embeddedTemplate == "" {
|
||||
log.Fatalf("embeddedTemplate is empty; must be build with Go 1.16+")
|
||||
}
|
||||
tmpl = template.Must(template.New("home").Parse(embeddedTemplate))
|
||||
}
|
||||
|
||||
http.HandleFunc("/", root)
|
||||
log.Printf("Starting hello server.")
|
||||
log.Fatal(s.Run())
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
<!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>
|
||||
@@ -1,71 +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>
|
||||
<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>
|
||||
@@ -1,157 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
(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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1,366 +0,0 @@
|
||||
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 address that the UDP and TCP listeners will listen on.
|
||||
// addr is the 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,16 +436,14 @@ func exclusiveOwnerAnnotations(pg *tsapi.ProxyGroup, operatorID string, svc *tai
|
||||
}
|
||||
if svc == nil {
|
||||
c := ownerAnnotationValue{OwnerRefs: []OwnerRef{ref}}
|
||||
data, err := json.Marshal(c)
|
||||
json, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal Tailscale Service's owner annotation contents: %w", err)
|
||||
return nil, fmt.Errorf("[unexpected] unable to marshal Tailscale Service's owner annotation contents: %w, please report this", err)
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
ownerAnnotation: string(data),
|
||||
ownerAnnotation: string(json),
|
||||
}, nil
|
||||
}
|
||||
|
||||
o, err := parseOwnerAnnotation(svc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -453,19 +451,15 @@ 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,8 +29,6 @@ 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"
|
||||
@@ -358,11 +356,6 @@ 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,22 +145,6 @@ 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)
|
||||
|
||||
+114
-47
@@ -6,12 +6,84 @@ 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+
|
||||
@@ -41,7 +113,6 @@ 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+
|
||||
@@ -59,7 +130,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 k8s.io/apimachinery/pkg/util/uuid+
|
||||
github.com/google/uuid from github.com/prometheus-community/pro-bing+
|
||||
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
|
||||
@@ -93,6 +164,7 @@ 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+
|
||||
@@ -108,7 +180,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+
|
||||
DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
|
||||
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
|
||||
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
|
||||
@@ -730,23 +802,22 @@ 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/netlog from tailscale.com/tsnet
|
||||
tailscale.com/feature/condregister/identityfederation 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/netlog from tailscale.com/feature/condregister/netlog
|
||||
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
|
||||
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
|
||||
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
|
||||
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
|
||||
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
|
||||
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/oauthkey+
|
||||
tailscale.com/internal/client/tailscale from tailscale.com/feature/identityfederation+
|
||||
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+
|
||||
@@ -755,18 +826,16 @@ 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/store/kubestore+
|
||||
tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+
|
||||
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/peerrelay from tailscale.com/cmd/k8s-operator
|
||||
tailscale.com/k8s-operator/reconciler from tailscale.com/k8s-operator/reconciler/tailnet
|
||||
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+
|
||||
@@ -787,6 +856,7 @@ 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+
|
||||
@@ -797,7 +867,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/wgengine/magicsock
|
||||
tailscale.com/net/netcheck from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
|
||||
tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/net/netknob from tailscale.com/logpolicy+
|
||||
@@ -811,16 +881,12 @@ 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
|
||||
@@ -834,7 +900,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/feature/acme
|
||||
tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/tempfork/httprec from tailscale.com/feature/c2n
|
||||
tailscale.com/tka from tailscale.com/client/local+
|
||||
@@ -844,7 +910,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+
|
||||
@@ -856,7 +922,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+
|
||||
@@ -878,7 +944,6 @@ 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+
|
||||
@@ -901,15 +966,16 @@ 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/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/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/testenv from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/truncate from tailscale.com/logtail
|
||||
tailscale.com/util/usermetric from tailscale.com/health+
|
||||
@@ -926,13 +992,15 @@ 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/feature/netlog
|
||||
tailscale.com/wgengine/netlog from tailscale.com/wgengine
|
||||
tailscale.com/wgengine/netstack from tailscale.com/tsnet
|
||||
tailscale.com/wgengine/netstack/gro from tailscale.com/net/tstun+
|
||||
tailscale.com/wgengine/router from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
|
||||
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine+
|
||||
tailscale.com/wgengine/wglog from tailscale.com/wgengine
|
||||
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+
|
||||
@@ -955,20 +1023,19 @@ 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 tailscale.com/net/ping
|
||||
golang.org/x/net/icmp from github.com/prometheus-community/pro-bing+
|
||||
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
|
||||
golang.org/x/net/internal/httpcommon from golang.org/x/net/http2
|
||||
golang.org/x/net/internal/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/ipv4+
|
||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
||||
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
||||
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
|
||||
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
|
||||
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/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/client/tailscale/v2+
|
||||
golang.org/x/oauth2/clientcredentials from tailscale.com/cmd/k8s-operator+
|
||||
golang.org/x/oauth2/internal from golang.org/x/oauth2+
|
||||
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
|
||||
golang.org/x/sys/cpu from github.com/tailscale/certstore+
|
||||
@@ -1025,22 +1092,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/fips140+
|
||||
crypto/internal/fips140 from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/aes from crypto/aes+
|
||||
crypto/internal/fips140/aes/gcm from crypto/cipher+
|
||||
crypto/internal/fips140/alias from crypto/cipher+
|
||||
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
|
||||
crypto/internal/fips140/check from crypto/fips140+
|
||||
crypto/internal/fips140/drbg from crypto/hpke+
|
||||
crypto/internal/fips140/check from crypto/internal/fips140/aes+
|
||||
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
|
||||
crypto/internal/fips140/ecdh from crypto/ecdh
|
||||
crypto/internal/fips140/ecdsa from crypto/ecdsa
|
||||
crypto/internal/fips140/ed25519 from crypto/ed25519
|
||||
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
|
||||
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
|
||||
crypto/internal/fips140/hkdf from crypto/hkdf+
|
||||
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
|
||||
crypto/internal/fips140/hmac from crypto/hmac+
|
||||
crypto/internal/fips140/mlkem from crypto/mlkem
|
||||
crypto/internal/fips140/nistec from crypto/ecdsa+
|
||||
crypto/internal/fips140/nistec from crypto/elliptic+
|
||||
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
|
||||
crypto/internal/fips140/rsa from crypto/rsa
|
||||
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
|
||||
@@ -1070,7 +1137,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/client_golang/prometheus/promhttp+
|
||||
crypto/tls from github.com/prometheus-community/pro-bing+
|
||||
crypto/tls/internal/fips140tls from crypto/tls
|
||||
crypto/x509 from crypto/tls+
|
||||
D crypto/x509/internal/macos from crypto/x509
|
||||
@@ -1105,7 +1172,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
|
||||
@@ -1120,14 +1187,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/ed25519+
|
||||
internal/godebug from crypto/internal/fips140deps/godebug+
|
||||
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 internal/syscall/windows+
|
||||
internal/oserror from io/fs+
|
||||
internal/poll from net+
|
||||
internal/profile from net/http/pprof
|
||||
internal/profilerecord from runtime+
|
||||
@@ -1137,9 +1204,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 internal/runtime/gc/scan+
|
||||
internal/runtime/gc from runtime+
|
||||
internal/runtime/gc/scan from runtime
|
||||
internal/runtime/maps from hash/maphash+
|
||||
internal/runtime/maps from reflect+
|
||||
internal/runtime/math from internal/runtime/maps+
|
||||
internal/runtime/pprof/label from runtime+
|
||||
internal/runtime/sys from crypto/subtle+
|
||||
@@ -1153,7 +1220,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/fips140deps/time+
|
||||
W internal/syscall/windows from crypto/internal/sysrand+
|
||||
W internal/syscall/windows/registry from mime+
|
||||
W internal/syscall/windows/sysdll from internal/syscall/windows+
|
||||
internal/testlog from os
|
||||
@@ -1161,7 +1228,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/google/gnostic-models/compiler+
|
||||
io/ioutil from github.com/godbus/dbus/v5+
|
||||
iter from go/ast+
|
||||
log from expvar+
|
||||
log/internal from log+
|
||||
@@ -1179,7 +1246,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/client_golang/prometheus/promhttp+
|
||||
net/http/httptrace from github.com/prometheus-community/pro-bing+
|
||||
net/http/httputil from tailscale.com/client/web+
|
||||
net/http/internal from net/http+
|
||||
net/http/internal/ascii from net/http+
|
||||
@@ -1198,7 +1265,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/klauspost/compress/zstd+
|
||||
runtime/debug from github.com/coder/websocket/internal/xsync+
|
||||
runtime/metrics from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/pprof from net/http/pprof+
|
||||
runtime/trace from net/http/pprof
|
||||
|
||||
@@ -10,4 +10,3 @@
|
||||
/recorder.yaml
|
||||
/tailnet.yaml
|
||||
/proxygrouppolicy.yaml
|
||||
/peerrelay.yaml
|
||||
|
||||
@@ -6,9 +6,6 @@ kind: Deployment
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- if .Values.annotations }}
|
||||
annotations: {{- toYaml .Values.annotations | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
@@ -81,10 +78,6 @@ 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
|
||||
@@ -124,8 +117,6 @@ 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 }}
|
||||
@@ -155,6 +146,3 @@ spec:
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.operatorConfig.priorityClassName }}
|
||||
priorityClassName: {{ . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -40,9 +40,6 @@ 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"]
|
||||
@@ -79,10 +76,6 @@ 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,9 +62,6 @@ operatorConfig:
|
||||
|
||||
resources: {}
|
||||
|
||||
# Specifies annotations for deployment
|
||||
annotations: {}
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
@@ -75,8 +72,6 @@ operatorConfig:
|
||||
|
||||
affinity: {}
|
||||
|
||||
priorityClassName: ""
|
||||
|
||||
podSecurityContext: {}
|
||||
|
||||
securityContext: {}
|
||||
@@ -87,13 +82,6 @@ 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,884 +104,6 @@ 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
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
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,18 +58,15 @@ spec:
|
||||
- credentials
|
||||
properties:
|
||||
credentials:
|
||||
description: Denotes the location of the credentials to use for authenticating with this Tailnet.
|
||||
description: Denotes the location of the OAuth credentials to use for authenticating with this Tailnet.
|
||||
type: object
|
||||
required:
|
||||
- secretName
|
||||
properties:
|
||||
secretName:
|
||||
description: |-
|
||||
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.
|
||||
The name of the secret containing the OAuth credentials. This secret must contain two fields "client_id" and
|
||||
"client_secret".
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user