Compare commits
216 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b338dd6a8 | |||
| 5d1bf80597 | |||
| 894ff5d8ee | |||
| 0cb432ed84 | |||
| c355618e73 | |||
| 1d3562b314 | |||
| ef1bb5ac16 | |||
| fa49009eee | |||
| 93440604e0 | |||
| 9437a634e6 | |||
| 4eb977413a | |||
| 8203edc099 | |||
| 2a06fb66d0 | |||
| 48919f708b | |||
| e7415e6393 | |||
| dc323b1351 | |||
| 4d68493144 | |||
| 41286c2b56 | |||
| 32f984f54c | |||
| bb47ea2c6b | |||
| 3a6261b79b | |||
| e4e59a2af0 | |||
| 6467f0d067 | |||
| 6b729795c3 | |||
| 72578de033 | |||
| ad8ead9c94 | |||
| 9f48567bf1 | |||
| 120bfcf1cc | |||
| 758ebe9839 | |||
| f4c5613156 | |||
| e062b46984 | |||
| 4eec4423b4 | |||
| d72cde1a6b | |||
| ead5ce65a3 | |||
| 2f45a6a9d8 | |||
| 82346f3882 | |||
| 469d356ed8 | |||
| ee2378b141 | |||
| 24eb157448 | |||
| d6ffc0d986 | |||
| 495d3acc7b | |||
| 76248a68b2 | |||
| 33b9579c21 | |||
| 76712b32d9 | |||
| 0def0f19bd | |||
| 87a74c3aa2 | |||
| daddb14b8f | |||
| d06cc56987 | |||
| 15bb10dbce | |||
| b74eeda055 | |||
| c721189cef | |||
| f844c8bc32 | |||
| 872d79089e | |||
| aa21b0c008 | |||
| eac531da8e | |||
| 883d4fd2cd | |||
| 81569e891f | |||
| 9bb7ca6116 | |||
| 0cf899610c | |||
| ca2317439d | |||
| ce76f44df2 | |||
| 29122506be | |||
| 290a6cc03c | |||
| bdf3419e7d | |||
| 78126c5d9f | |||
| ee10f9881c | |||
| 3ced30b0b6 | |||
| f15a4f4416 | |||
| bbcb8650d4 | |||
| 4c3ed5ab32 | |||
| ff9c3f0e00 | |||
| 89a78dc9b7 | |||
| cac94f51cc | |||
| a6c5d23742 | |||
| 9f343fdc0c | |||
| 822299642b | |||
| 159cf8707a | |||
| 92179b1fc7 | |||
| 644c3224e9 | |||
| 815bb291c9 | |||
| f343b496c3 | |||
| b313bffbe7 | |||
| 978b6a81b2 | |||
| c0a9728fe2 | |||
| 0e9f9e2bd8 | |||
| 15cba0a3f6 | |||
| 22ff402da9 | |||
| 1cd8bcc827 | |||
| 70f0b261b6 | |||
| 01d0bdd253 | |||
| be7cce74ba | |||
| fd6ae2fad4 | |||
| 02ffe5baa8 | |||
| 7b53550fe6 | |||
| a29e42135b | |||
| 4cec06b8f2 | |||
| 78627c132f | |||
| 1841a93ab2 | |||
| bb91bb842c | |||
| 40088602c9 | |||
| b2d4ba04b6 | |||
| ec7b11d986 | |||
| 4b8e0ede6d | |||
| da0a277565 | |||
| f7f8b0a0a5 | |||
| 88cb6f58f8 | |||
| 33714211c8 | |||
| b9eac14ef9 | |||
| 0ac09721df | |||
| cb239808a6 | |||
| 7735b15de3 | |||
| 384b7fb561 | |||
| 2d85f37f39 | |||
| 325f52c654 | |||
| d0ae993334 | |||
| c0e6ffed0d | |||
| 5c1738fd56 | |||
| 10b63f27ce | |||
| ad5436af0d | |||
| 33342aec32 | |||
| 0e10a3f580 | |||
| 649781df84 | |||
| a70629eae3 | |||
| 346d6bb04c | |||
| 64bb40b45b | |||
| 7477a6ee47 | |||
| 3a05c450ce | |||
| f3b2f9b0ef | |||
| 873b8b8e2e | |||
| d64ed4af89 | |||
| 4195e34f79 | |||
| 323198b348 | |||
| 1b40911611 | |||
| 006d7e180e | |||
| 306fab796c | |||
| aa740cb393 | |||
| ad9e6c1925 | |||
| ee76a7d3f8 | |||
| a7d8aeb8ae | |||
| 311dd3839d | |||
| f289f7e77c | |||
| 81fbcc1ac8 | |||
| 36f094ea3b | |||
| 12813dee02 | |||
| d7916d4369 | |||
| 19544b4b81 | |||
| 04415b8177 | |||
| 1669b0d3d4 | |||
| 1e68a11721 | |||
| 5b06e32f33 | |||
| 4a832d8d0f | |||
| ffae275d4d | |||
| ec86f0ff93 | |||
| dfc2667f8f | |||
| cf76202aa3 | |||
| cb5a53c424 | |||
| 618dfd4081 | |||
| 514d7d28e7 | |||
| 1fbb834dc3 | |||
| 8dda62cc24 | |||
| b239e92eb6 | |||
| d52ae45e9b | |||
| 47ecbe5845 | |||
| 00a08ea86d | |||
| c2da563fef | |||
| 50d7176333 | |||
| 69572c7435 | |||
| 1dc08f4d41 | |||
| 4f47c3c93d | |||
| d3ba1480f5 | |||
| b39ee0445d | |||
| eea39eaf52 | |||
| acc43356c6 | |||
| 1e4934659b | |||
| 958bcda5bf | |||
| d8190e0de5 | |||
| 5eb0b4be31 | |||
| dbf468740b | |||
| 61c95f409c | |||
| effbe67fe3 | |||
| 6301a6ce4b | |||
| 5834058269 | |||
| 943b426038 | |||
| a0a8fae856 | |||
| 621dc9cf1b | |||
| 6aa10576c9 | |||
| 49eb1b5d26 | |||
| 27f1d4c15d | |||
| 0afaa29503 | |||
| 75819aeed0 | |||
| ab74ea0a67 | |||
| 9fbe4b3ed2 | |||
| 13d5370951 | |||
| a97850f7e2 | |||
| 7dcb378875 | |||
| dbd19e4b65 | |||
| 50b8cfbde2 | |||
| 6500d3c3f8 | |||
| 9dfe7875fd | |||
| 5a7ef4a533 | |||
| 4ce1643929 | |||
| e2fa9ff140 | |||
| cfed69f3ed | |||
| 929ad51be0 | |||
| 21880457eb | |||
| aa9a76cf30 | |||
| d5341fd60c | |||
| 4fcce6000d | |||
| 674f866ecc | |||
| 0e8ae9d60c | |||
| cf59a6fb23 | |||
| ca5db865b4 | |||
| b4c0d67f8b | |||
| 5e81840b57 | |||
| 399f048332 | |||
| 1ff369a261 |
@@ -37,8 +37,6 @@ jobs:
|
|||||||
- "elementary/docker:stable"
|
- "elementary/docker:stable"
|
||||||
- "elementary/docker:unstable"
|
- "elementary/docker:unstable"
|
||||||
- "parrotsec/core:latest"
|
- "parrotsec/core:latest"
|
||||||
- "kalilinux/kali-rolling"
|
|
||||||
- "kalilinux/kali-dev"
|
|
||||||
- "oraclelinux:9"
|
- "oraclelinux:9"
|
||||||
- "oraclelinux:8"
|
- "oraclelinux:8"
|
||||||
- "fedora:latest"
|
- "fedora:latest"
|
||||||
@@ -61,6 +59,9 @@ jobs:
|
|||||||
- { image: "debian:stable-slim", deps: "curl" }
|
- { image: "debian:stable-slim", deps: "curl" }
|
||||||
- { image: "ubuntu:24.04", deps: "curl" }
|
- { image: "ubuntu:24.04", deps: "curl" }
|
||||||
- { image: "fedora:latest", 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.
|
# Test TAILSCALE_VERSION pinning on a subset of distros.
|
||||||
# Skip Alpine as community repos don't reliably keep old versions.
|
# Skip Alpine as community repos don't reliably keep old versions.
|
||||||
- { image: "debian:stable-slim", deps: "curl", version: "1.80.0" }
|
- { image: "debian:stable-slim", deps: "curl", version: "1.80.0" }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Run some natlab integration tests.
|
# Run a single natlab smoke test on every PR. The full natlab suite
|
||||||
|
# is opt-in and lives in .github/workflows/natlab-test.yml.
|
||||||
# See https://github.com/tailscale/tailscale/issues/13038
|
# See https://github.com/tailscale/tailscale/issues/13038
|
||||||
name: "natlab-integrationtest"
|
name: "natlab-basic"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||||
@@ -17,17 +18,28 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- "main"
|
- "main"
|
||||||
jobs:
|
jobs:
|
||||||
natlab-integrationtest:
|
EasyEasy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
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
|
- name: Install qemu
|
||||||
run: |
|
run: |
|
||||||
sudo rm -f /var/lib/man-db/auto-update
|
sudo rm -f /var/lib/man-db/auto-update
|
||||||
sudo apt-get -y update
|
sudo apt-get -y update
|
||||||
sudo apt-get -y remove man-db
|
sudo apt-get -y remove man-db
|
||||||
sudo apt-get install -y qemu-system-x86 qemu-utils
|
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
|
- name: Run natlab integration tests
|
||||||
run: |
|
run: |
|
||||||
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/integration/nat --run-vm-tests
|
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/natlab/vmtest --run-vm-tests
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Run the full natlab/vmtest opt-in test suite. These tests boot QEMU VMs
|
||||||
|
# (gokrazy, Ubuntu, FreeBSD) and exercise vnet-driven networking scenarios.
|
||||||
|
# They are gated behind --run-vm-tests because they need KVM and are slow.
|
||||||
|
#
|
||||||
|
# This workflow runs:
|
||||||
|
# - on demand (workflow_dispatch)
|
||||||
|
# - on PRs that carry the "run-natlab-tests" label
|
||||||
|
# - on main, every 12 hours, via cron
|
||||||
|
#
|
||||||
|
# Layout:
|
||||||
|
# - "prepare" builds the gokrazy VM image, downloads the cloud images
|
||||||
|
# (Ubuntu, FreeBSD), and discovers every Test* function in the two
|
||||||
|
# opt-in packages.
|
||||||
|
# - "test" is a per-TestFoo matrix that depends on prepare. Each matrix
|
||||||
|
# job restores the shared caches and runs a single test. Adding a new
|
||||||
|
# TestFoo automatically gets its own job — no workflow edits needed.
|
||||||
|
#
|
||||||
|
# A separate workflow (.github/workflows/natlab-basic.yml) runs a single
|
||||||
|
# canary natlab test on every PR; this one runs the full suite.
|
||||||
|
name: "natlab-test"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
types: [labeled, synchronize, reopened]
|
||||||
|
schedule:
|
||||||
|
# Every 12 hours, off-the-hour to avoid GitHub's :00 cron-stampede window.
|
||||||
|
- cron: "23 3,15 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# prepare warms the per-workflow-run caches (gokrazy image, cloud VM
|
||||||
|
# images) and emits the dynamic matrix of test names. By doing the work
|
||||||
|
# once here, the matrix test jobs never race to rebuild or re-download
|
||||||
|
# the same artifacts on a cold cache.
|
||||||
|
prepare:
|
||||||
|
if: |
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
github.event_name == 'schedule' ||
|
||||||
|
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-natlab-tests'))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
outputs:
|
||||||
|
matrix: ${{ steps.list.outputs.matrix }}
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
# The cloud VM image cache is keyed only on images.go (image URLs and
|
||||||
|
# SHAs), so it survives across workflow runs and is invalidated only
|
||||||
|
# when a new image source is added.
|
||||||
|
- name: Cache cloud VM images
|
||||||
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/tailscale/vmtest/images
|
||||||
|
key: natlab-vmimages-${{ hashFiles('tstest/natlab/vmtest/images.go') }}
|
||||||
|
|
||||||
|
# The gokrazy VM image is keyed by github.sha. That means we rebuild
|
||||||
|
# it once per commit but matrix test jobs in the same run all share
|
||||||
|
# the result. Per-PR re-runs of the same sha (e.g. a rerun-failed)
|
||||||
|
# also get the cache.
|
||||||
|
- name: Cache gokrazy VM image
|
||||||
|
id: gokrazy-cache
|
||||||
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||||
|
with:
|
||||||
|
path: gokrazy/natlabapp.qcow2
|
||||||
|
key: natlab-gokrazy-${{ github.sha }}
|
||||||
|
|
||||||
|
# qemu-utils provides qemu-img, which the gokrazy Makefile uses to
|
||||||
|
# convert natlabapp.img to qcow2. Only install if we need it (cache
|
||||||
|
# miss); the test matrix jobs install qemu separately for the runtime.
|
||||||
|
- name: Install qemu-utils
|
||||||
|
if: steps.gokrazy-cache.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
sudo rm -f /var/lib/man-db/auto-update
|
||||||
|
sudo apt-get -y update
|
||||||
|
sudo apt-get -y remove man-db
|
||||||
|
sudo apt-get install -y qemu-utils
|
||||||
|
|
||||||
|
- name: Download cloud VM images
|
||||||
|
# natlabprep is idempotent: it checks the cache before downloading.
|
||||||
|
run: |
|
||||||
|
./tool/go run ./tstest/natlab/vmtest/cmd/natlabprep
|
||||||
|
|
||||||
|
- name: Build gokrazy VM image
|
||||||
|
if: steps.gokrazy-cache.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
make -C gokrazy natlab
|
||||||
|
|
||||||
|
- name: Discover tests
|
||||||
|
id: list
|
||||||
|
# Grep the test files directly rather than invoking `go test -list`
|
||||||
|
# so we don't pay the cost of compiling the test binaries here. The
|
||||||
|
# only test functions in these packages use the canonical
|
||||||
|
# `func TestFoo(t *testing.T)` signature.
|
||||||
|
#
|
||||||
|
# exclude is the set of tests that need special invocation
|
||||||
|
# (extra flags, a specific environment) and don't fit the
|
||||||
|
# single-test-per-matrix-job model. They stay runnable locally.
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
exclude='^(TestGrid)$'
|
||||||
|
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" \
|
||||||
|
| sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/' \
|
||||||
|
| grep -vE "$exclude" \
|
||||||
|
| 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 tailscale/gokrazy-kernel 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 gokrazy-kernel module
|
||||||
|
run: |
|
||||||
|
./tool/go mod download github.com/tailscale/gokrazy-kernel
|
||||||
|
|
||||||
|
- 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,5 +1,5 @@
|
|||||||
# Run the ssh integration tests with `make sshintegrationtest`.
|
# Run the ssh integration tests in various Docker containers.
|
||||||
# These tests can also be running locally.
|
# These tests can also be run locally via `make sshintegrationtest`.
|
||||||
name: "ssh-integrationtest"
|
name: "ssh-integrationtest"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -15,9 +15,25 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
ssh-integrationtest:
|
ssh-integrationtest:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Run SSH integration tests
|
- name: Build test binaries
|
||||||
run: |
|
run: |
|
||||||
make sshintegrationtest
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test
|
||||||
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled
|
||||||
|
- name: Run SSH integration tests (${{ matrix.base }})
|
||||||
|
run: |
|
||||||
|
docker build --build-arg="BASE=${{ matrix.base }}" -t "${{ matrix.tag }}" ssh/tailssh/testcontainers
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ jobs:
|
|||||||
run: chown -R $(id -u):$(id -g) $PWD
|
run: chown -R $(id -u):$(id -g) $PWD
|
||||||
- name: privileged tests
|
- name: privileged tests
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: ./tool/go test ./util/linuxfw ./derp/xdp
|
run: ./tool/go test $(./tool/go run ./tool/listpkgs --has-root-tests)
|
||||||
|
|
||||||
vm:
|
vm:
|
||||||
needs: gomod-cache
|
needs: gomod-cache
|
||||||
@@ -787,6 +787,14 @@ jobs:
|
|||||||
echo
|
echo
|
||||||
echo
|
echo
|
||||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
|
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:
|
make_tidy:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ jobs:
|
|||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Run update-flakes
|
- name: Run updateflakes
|
||||||
run: ./update-flake.sh
|
run: ./tool/go run ./tool/updateflakes
|
||||||
|
|
||||||
- name: Get access token
|
- name: Get access token
|
||||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||||
@@ -41,8 +41,8 @@ jobs:
|
|||||||
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||||
committer: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
committer: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||||
branch: flakes
|
branch: flakes
|
||||||
commit-message: "go.mod.sri: update SRI hash for go.mod changes"
|
commit-message: "flakehashes.json: update SRI hash for go.mod changes"
|
||||||
title: "go.mod.sri: update SRI hash for go.mod changes"
|
title: "flakehashes.json: update SRI hash for go.mod changes"
|
||||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||||
signoff: true
|
signoff: true
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
|
|||||||
+4
-1
@@ -1,12 +1,15 @@
|
|||||||
# Binaries for programs and plugins
|
# Binaries for programs and plugins
|
||||||
*~
|
*~
|
||||||
*.tmp
|
*.tmp
|
||||||
*.exe
|
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
*.spk
|
*.spk
|
||||||
|
|
||||||
|
*.exe
|
||||||
|
# tool/go.exe is built specially and committed.
|
||||||
|
!/tool/go.exe
|
||||||
|
|
||||||
cmd/tailscale/tailscale
|
cmd/tailscale/tailscale
|
||||||
cmd/tailscaled/tailscaled
|
cmd/tailscaled/tailscaled
|
||||||
ssh/tailssh/testcontainers/tailscaled
|
ssh/tailssh/testcontainers/tailscaled
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ vet: ## Run go vet
|
|||||||
|
|
||||||
tidy: ## Run go mod tidy and update nix flake hashes
|
tidy: ## Run go mod tidy and update nix flake hashes
|
||||||
./tool/go mod tidy
|
./tool/go mod tidy
|
||||||
./update-flake.sh
|
./tool/go run ./tool/updateflakes
|
||||||
|
|
||||||
lint: ## Run golangci-lint
|
lint: ## Run golangci-lint
|
||||||
./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run
|
./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run
|
||||||
@@ -137,10 +137,12 @@ publishdevproxy: check-image-repo ## Build and publish k8s-proxy image to locati
|
|||||||
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
|
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 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 && \
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
|
||||||
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
|
echo "Testing on ubuntu:focal, ubuntu:jammy, ubuntu:noble, alpine:latest (in parallel)" && \
|
||||||
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
|
docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers & \
|
||||||
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
|
docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers & \
|
||||||
echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers
|
docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers & \
|
||||||
|
docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers & \
|
||||||
|
wait
|
||||||
|
|
||||||
.PHONY: generate
|
.PHONY: generate
|
||||||
generate: ## Generate code
|
generate: ## Generate code
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.97.0
|
1.99.0
|
||||||
|
|||||||
@@ -736,6 +736,7 @@ func TestRateLogger(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouteStoreMetrics(t *testing.T) {
|
func TestRouteStoreMetrics(t *testing.T) {
|
||||||
|
clientmetric.ResetForTest(t)
|
||||||
metricStoreRoutes(1, 1)
|
metricStoreRoutes(1, 1)
|
||||||
metricStoreRoutes(1, 1) // the 1 buckets value should be 2
|
metricStoreRoutes(1, 1) // the 1 buckets value should be 2
|
||||||
metricStoreRoutes(5, 5) // the 5 buckets value should be 1
|
metricStoreRoutes(5, 5) // the 5 buckets value should be 1
|
||||||
|
|||||||
+29
-7
@@ -6,6 +6,7 @@ package appc
|
|||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"tailscale.com/ipn/ipnext"
|
"tailscale.com/ipn/ipnext"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
|
|
||||||
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
|
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
|
||||||
|
|
||||||
func isEligibleConnector(peer tailcfg.NodeView) bool {
|
func isPeerEligibleConnector(peer tailcfg.NodeView) bool {
|
||||||
if !peer.Valid() || !peer.Hostinfo().Valid() {
|
if !peer.Valid() || !peer.Hostinfo().Valid() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -39,7 +40,7 @@ func sortByPreference(ns []tailcfg.NodeView) {
|
|||||||
func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.NodeView {
|
func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.NodeView {
|
||||||
appTagsSet := set.SetOf(app.Connectors)
|
appTagsSet := set.SetOf(app.Connectors)
|
||||||
matches := nb.AppendMatchingPeers(nil, func(n tailcfg.NodeView) bool {
|
matches := nb.AppendMatchingPeers(nil, func(n tailcfg.NodeView) bool {
|
||||||
if !isEligibleConnector(n) {
|
if !isPeerEligibleConnector(n) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, t := range n.Tags().All() {
|
for _, t := range n.Tags().All() {
|
||||||
@@ -55,7 +56,7 @@ func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.Nod
|
|||||||
|
|
||||||
// PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers
|
// PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers
|
||||||
// want to be connectors for which domains.
|
// 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 {
|
func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView, peers map[tailcfg.NodeID]tailcfg.NodeView, isSelfEligibleConnector bool) map[string][]tailcfg.NodeView {
|
||||||
var m map[string][]tailcfg.NodeView
|
var m map[string][]tailcfg.NodeView
|
||||||
if !hasCap(AppConnectorsExperimentalAttrName) {
|
if !hasCap(AppConnectorsExperimentalAttrName) {
|
||||||
return m
|
return m
|
||||||
@@ -64,22 +65,43 @@ func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
tagToDomain := make(map[string][]string)
|
|
||||||
|
// We strip the leading *. from any domains because the OS treats all domains
|
||||||
|
// that we pass to it as wildcard domains, and the OS would treat the * character
|
||||||
|
// as a literal domain component instead of treating it as a wildcard.
|
||||||
|
// We also use a Set to deduplicate the domains we pass to the OS in case removing
|
||||||
|
// the *. prefix resulted in duplicate entries.
|
||||||
|
tagToDomain := make(map[string]set.Set[string])
|
||||||
|
selfTags := set.SetOf(self.Tags().AsSlice())
|
||||||
|
selfRoutedDomains := set.Set[string]{}
|
||||||
for _, app := range apps {
|
for _, app := range apps {
|
||||||
|
domains := make(set.Set[string])
|
||||||
|
for _, domain := range app.Domains {
|
||||||
|
domains.Add(strings.ToLower(strings.TrimPrefix(domain, "*.")))
|
||||||
|
}
|
||||||
for _, tag := range app.Connectors {
|
for _, tag := range app.Connectors {
|
||||||
tagToDomain[tag] = append(tagToDomain[tag], app.Domains...)
|
if tagToDomain[tag] == nil {
|
||||||
|
tagToDomain[tag] = set.Set[string]{}
|
||||||
|
}
|
||||||
|
tagToDomain[tag].AddSet(domains)
|
||||||
|
if isSelfEligibleConnector && selfTags.Contains(tag) {
|
||||||
|
selfRoutedDomains.AddSet(domains)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NodeIDs are Comparable, and we have a map of NodeID to NodeView anyway, so
|
// 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.
|
// use a Set of NodeIDs to deduplicate, and populate into a []NodeView later.
|
||||||
var work map[string]set.Set[tailcfg.NodeID]
|
var work map[string]set.Set[tailcfg.NodeID]
|
||||||
for _, peer := range peers {
|
for _, peer := range peers {
|
||||||
if !isEligibleConnector(peer) {
|
if !isPeerEligibleConnector(peer) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, t := range peer.Tags().All() {
|
for _, t := range peer.Tags().All() {
|
||||||
domains := tagToDomain[t]
|
domains := tagToDomain[t]
|
||||||
for _, domain := range domains {
|
for domain := range domains {
|
||||||
|
if selfRoutedDomains.Contains(domain) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if work[domain] == nil {
|
if work[domain] == nil {
|
||||||
mak.Set(&work, domain, set.Set[tailcfg.NodeID]{})
|
mak.Set(&work, domain, set.Set[tailcfg.NodeID]{})
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-2
@@ -32,6 +32,8 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
|||||||
appTwoBytes := getBytesForAttr("app2", []string{"a.example.com"}, []string{"tag:two"})
|
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"})
|
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"})
|
appFourBytes := getBytesForAttr("app4", []string{"woo.b.example.com", "c.example.com"}, []string{"tag:four1", "tag:four2"})
|
||||||
|
appFiveBytes := getBytesForAttr("app5", []string{"*.example.com", "example.com"}, []string{"tag:one"})
|
||||||
|
appSixBytes := getBytesForAttr("app6", []string{"*.Example.com", "EXAMPLE.com", "EXAMPLE.COM"}, []string{"tag:one"})
|
||||||
|
|
||||||
makeNodeView := func(id tailcfg.NodeID, name string, tags []string) tailcfg.NodeView {
|
makeNodeView := func(id tailcfg.NodeID, name string, tags []string) tailcfg.NodeView {
|
||||||
return (&tailcfg.Node{
|
return (&tailcfg.Node{
|
||||||
@@ -48,9 +50,11 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range []struct {
|
for _, tt := range []struct {
|
||||||
name string
|
name string
|
||||||
want map[string][]tailcfg.NodeView
|
|
||||||
peers []tailcfg.NodeView
|
peers []tailcfg.NodeView
|
||||||
config []tailcfg.RawMessage
|
config []tailcfg.RawMessage
|
||||||
|
isEligibleConnector bool
|
||||||
|
selfTags []string
|
||||||
|
want map[string][]tailcfg.NodeView
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty",
|
name: "empty",
|
||||||
@@ -111,6 +115,128 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
|||||||
"c.example.com": {nvp2, nvp4},
|
"c.example.com": {nvp2, nvp4},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "self-connector-exclude-self-domains",
|
||||||
|
config: []tailcfg.RawMessage{
|
||||||
|
tailcfg.RawMessage(appOneBytes),
|
||||||
|
tailcfg.RawMessage(appTwoBytes),
|
||||||
|
tailcfg.RawMessage(appThreeBytes),
|
||||||
|
tailcfg.RawMessage(appFourBytes),
|
||||||
|
},
|
||||||
|
peers: []tailcfg.NodeView{
|
||||||
|
nvp1,
|
||||||
|
nvp2,
|
||||||
|
nvp3,
|
||||||
|
nvp4,
|
||||||
|
},
|
||||||
|
isEligibleConnector: true,
|
||||||
|
selfTags: []string{"tag:three1"},
|
||||||
|
want: map[string][]tailcfg.NodeView{
|
||||||
|
// woo.b.example.com and hoo.b.example.com are covered
|
||||||
|
// by tag:three1, and so is this self-node.
|
||||||
|
// So those domains should not be routed to peers.
|
||||||
|
// woo.b.example.com is also covered by another tag,
|
||||||
|
// but still not included since this connector can route to it.
|
||||||
|
"example.com": {nvp1},
|
||||||
|
"a.example.com": {nvp3, nvp4},
|
||||||
|
"c.example.com": {nvp2, nvp4},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "self-eligible-connector-no-matching-tag-include-all-domains",
|
||||||
|
config: []tailcfg.RawMessage{
|
||||||
|
tailcfg.RawMessage(appOneBytes),
|
||||||
|
tailcfg.RawMessage(appTwoBytes),
|
||||||
|
tailcfg.RawMessage(appThreeBytes),
|
||||||
|
tailcfg.RawMessage(appFourBytes),
|
||||||
|
},
|
||||||
|
peers: []tailcfg.NodeView{
|
||||||
|
nvp1,
|
||||||
|
nvp2,
|
||||||
|
nvp3,
|
||||||
|
nvp4,
|
||||||
|
},
|
||||||
|
isEligibleConnector: true,
|
||||||
|
selfTags: []string{"tag:unrelated"},
|
||||||
|
want: map[string][]tailcfg.NodeView{
|
||||||
|
// Self has prefs set but no tags matching any app,
|
||||||
|
// so no domains are self-routed and all appear.
|
||||||
|
"example.com": {nvp1},
|
||||||
|
"a.example.com": {nvp3, nvp4},
|
||||||
|
"woo.b.example.com": {nvp2, nvp3, nvp4},
|
||||||
|
"hoo.b.example.com": {nvp3, nvp4},
|
||||||
|
"c.example.com": {nvp2, nvp4},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "self-not-eligible-connector-but-tagged-include-all-domains",
|
||||||
|
config: []tailcfg.RawMessage{
|
||||||
|
tailcfg.RawMessage(appOneBytes),
|
||||||
|
tailcfg.RawMessage(appTwoBytes),
|
||||||
|
tailcfg.RawMessage(appThreeBytes),
|
||||||
|
tailcfg.RawMessage(appFourBytes),
|
||||||
|
},
|
||||||
|
peers: []tailcfg.NodeView{
|
||||||
|
nvp1,
|
||||||
|
nvp2,
|
||||||
|
nvp3,
|
||||||
|
nvp4,
|
||||||
|
},
|
||||||
|
selfTags: []string{"tag:three1"},
|
||||||
|
want: map[string][]tailcfg.NodeView{
|
||||||
|
// Even though this self node has a tag for an app
|
||||||
|
// the prefs don't advertise as connector, so
|
||||||
|
// should still route through other connectors.
|
||||||
|
"example.com": {nvp1},
|
||||||
|
"a.example.com": {nvp3, nvp4},
|
||||||
|
"woo.b.example.com": {nvp2, nvp3, nvp4},
|
||||||
|
"hoo.b.example.com": {nvp3, nvp4},
|
||||||
|
"c.example.com": {nvp2, nvp4},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcards-are-stripped-and-deduped",
|
||||||
|
config: []tailcfg.RawMessage{
|
||||||
|
tailcfg.RawMessage(appOneBytes),
|
||||||
|
tailcfg.RawMessage(appFiveBytes),
|
||||||
|
},
|
||||||
|
peers: []tailcfg.NodeView{
|
||||||
|
nvp1,
|
||||||
|
},
|
||||||
|
want: map[string][]tailcfg.NodeView{
|
||||||
|
// All the domains should be normalized to example.com
|
||||||
|
"example.com": {nvp1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "domains-are-normalized-and-deduped",
|
||||||
|
config: []tailcfg.RawMessage{
|
||||||
|
tailcfg.RawMessage(appSixBytes),
|
||||||
|
},
|
||||||
|
peers: []tailcfg.NodeView{
|
||||||
|
nvp1,
|
||||||
|
},
|
||||||
|
want: map[string][]tailcfg.NodeView{
|
||||||
|
// All the domains should be normalized to example.com
|
||||||
|
"example.com": {nvp1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sub-domains-and-top-domains-do-not-collide",
|
||||||
|
config: []tailcfg.RawMessage{
|
||||||
|
tailcfg.RawMessage(appTwoBytes),
|
||||||
|
tailcfg.RawMessage(appFiveBytes),
|
||||||
|
},
|
||||||
|
peers: []tailcfg.NodeView{
|
||||||
|
nvp1,
|
||||||
|
nvp3,
|
||||||
|
},
|
||||||
|
want: map[string][]tailcfg.NodeView{
|
||||||
|
// The sub.example.com should remain distinct from example.com
|
||||||
|
"example.com": {nvp1},
|
||||||
|
"a.example.com": {nvp3},
|
||||||
|
},
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
selfNode := &tailcfg.Node{}
|
selfNode := &tailcfg.Node{}
|
||||||
@@ -119,6 +245,7 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
|||||||
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): tt.config,
|
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): tt.config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
selfNode.Tags = append(selfNode.Tags, tt.selfTags...)
|
||||||
selfView := selfNode.View()
|
selfView := selfNode.View()
|
||||||
peers := map[tailcfg.NodeID]tailcfg.NodeView{}
|
peers := map[tailcfg.NodeID]tailcfg.NodeView{}
|
||||||
for _, p := range tt.peers {
|
for _, p := range tt.peers {
|
||||||
@@ -126,7 +253,8 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool {
|
got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool {
|
||||||
return true
|
return true
|
||||||
}, selfView, peers)
|
}, selfView, peers, tt.isEligibleConnector)
|
||||||
|
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
t.Fatalf("got %v, want %v", got, tt.want)
|
t.Fatalf("got %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package tailscaleroot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"tailscale.com/util/cibuild"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTsgoRevInCacheKey verifies that the Tailscale Go toolchain's git
|
||||||
|
// revision (from go.toolchain.rev) is blended into Go build cache keys.
|
||||||
|
// Without this, bumping the toolchain to a new commit that doesn't change
|
||||||
|
// the Go version number would silently reuse stale cached build artifacts.
|
||||||
|
//
|
||||||
|
// See https://github.com/tailscale/tailscale/issues/36589.
|
||||||
|
func TestTsgoRevInCacheKey(t *testing.T) {
|
||||||
|
goRoot := goEnv(t, "GOROOT")
|
||||||
|
isTsgo := strings.Contains(goRoot, "/.cache/tsgo/")
|
||||||
|
if !cibuild.OnTailscaleCI() && !isTsgo {
|
||||||
|
t.Skip("skipping; not in Tailscale CI and not using the Tailscale Go toolchain")
|
||||||
|
}
|
||||||
|
|
||||||
|
rev := strings.TrimSpace(GoToolchainRev)
|
||||||
|
if rev == "" {
|
||||||
|
t.Fatal("go.toolchain.rev is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the small stdlib "errors" package with GODEBUG=gocachehash=1,
|
||||||
|
// which causes cmd/go to log its cache key computations to stderr.
|
||||||
|
cmd := exec.Command("go", "build", "errors")
|
||||||
|
cmd.Env = append(os.Environ(), "GODEBUG=gocachehash=1")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("go build errors failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cache key output should contain the toolchain rev alongside the
|
||||||
|
// Go version, e.g.:
|
||||||
|
// HASH[moduleIndex]: "go1.26.2 dfe2a5fd8ee2e68b08ce5ff259269f50ecadf2f4"
|
||||||
|
if !strings.Contains(string(out), rev) {
|
||||||
|
t.Errorf("go.toolchain.rev %q not found in GODEBUG=gocachehash=1 output:\n%s", rev, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func goEnv(t *testing.T, key string) string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("go", "env", key).Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("go env %s: %v", key, err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
@@ -327,6 +327,35 @@ func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsR
|
|||||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
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.
|
||||||
|
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.
|
||||||
|
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
|
// ErrPeerNotFound is returned by [Client.WhoIs], [Client.WhoIsNodeKey] and
|
||||||
// [Client.WhoIsProto] when a peer is not found.
|
// [Client.WhoIsProto] when a peer is not found.
|
||||||
var ErrPeerNotFound = errors.New("peer not found")
|
var ErrPeerNotFound = errors.New("peer not found")
|
||||||
@@ -607,6 +636,24 @@ func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, erro
|
|||||||
return x, nil
|
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 and subject to change or removal over time.
|
||||||
|
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.
|
// QueryOptionalFeatures queries the optional features supported by the Tailscale daemon.
|
||||||
func (lc *Client) QueryOptionalFeatures(ctx context.Context) (*apitype.OptionalFeatures, error) {
|
func (lc *Client) QueryOptionalFeatures(ctx context.Context) (*apitype.OptionalFeatures, error) {
|
||||||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-optional-features", 200, nil)
|
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-optional-features", 200, nil)
|
||||||
@@ -972,6 +1019,19 @@ func (lc *Client) UserDial(ctx context.Context, network, host string, port uint1
|
|||||||
if res.StatusCode != http.StatusSwitchingProtocols {
|
if res.StatusCode != http.StatusSwitchingProtocols {
|
||||||
body, _ := io.ReadAll(res.Body)
|
body, _ := io.ReadAll(res.Body)
|
||||||
res.Body.Close()
|
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)
|
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
|
// From here on, the underlying net.Conn is ours to use, but there
|
||||||
@@ -1009,6 +1069,44 @@ func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error)
|
|||||||
return &derpMap, nil
|
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.
|
||||||
|
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], in O(1) time on the daemon side. It returns an error
|
||||||
|
// if no peer with that NodeID is in the current netmap.
|
||||||
|
//
|
||||||
|
// It is intended for callers that need the latest state of a single peer
|
||||||
|
// without fetching the entire netmap.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// PingOpts contains options for the ping request.
|
// PingOpts contains options for the ping request.
|
||||||
//
|
//
|
||||||
// The zero value is valid, which means to use defaults.
|
// The zero value is valid, which means to use defaults.
|
||||||
@@ -1422,3 +1520,13 @@ func (lc *Client) GetAppConnectorRouteInfo(ctx context.Context) (appctype.RouteI
|
|||||||
}
|
}
|
||||||
return decodeJSON[appctype.RouteInfo](body)
|
return decodeJSON[appctype.RouteInfo](body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetServices returns the Services visible to this node,
|
||||||
|
// including their names, IP addresses, and ports, keyed by service name.
|
||||||
|
func (lc *Client) GetServices(ctx context.Context) (map[tailcfg.ServiceName]tailcfg.ServiceDetails, error) {
|
||||||
|
body, err := lc.get200(ctx, "/localapi/v0/services")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return decodeJSON[map[tailcfg.ServiceName]tailcfg.ServiceDetails](body)
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,57 @@ func TestWhoIsPeerNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserDialSelf(t *testing.T) {
|
||||||
|
// Start a real TCP listener that the client should dial directly
|
||||||
|
// when the server tells it to dial-self.
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Write([]byte("hello"))
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
targetAddr := ln.Addr().(*net.TCPAddr)
|
||||||
|
|
||||||
|
// Mock LocalAPI server that returns Dial-Self response.
|
||||||
|
nw := nettest.GetNetwork(t)
|
||||||
|
ts := nettest.NewHTTPServer(nw, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Dial-Self", "true")
|
||||||
|
w.Header().Set("Dial-Addr", targetAddr.String())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
lc := &Client{
|
||||||
|
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return nw.Dial(ctx, network, ts.Listener.Addr().String())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := lc.UserDial(context.Background(), "tcp", targetAddr.IP.String(), uint16(targetAddr.Port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserDial: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 5)
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read: %v", err)
|
||||||
|
}
|
||||||
|
if got := string(buf[:n]); got != "hello" {
|
||||||
|
t.Errorf("got %q, want %q", got, "hello")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeps(t *testing.T) {
|
func TestDeps(t *testing.T) {
|
||||||
deptest.DepChecker{
|
deptest.DepChecker{
|
||||||
BadDeps: map[string]string{
|
BadDeps: map[string]string{
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.Key
|
|||||||
return decodeJSON[[]tkatype.MarshaledSignature](body)
|
return decodeJSON[[]tkatype.MarshaledSignature](body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
|
// NetworkLockLog returns up to maxEntries number of changes to tailnet-lock state.
|
||||||
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
|
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
|
||||||
v := url.Values{}
|
v := url.Values{}
|
||||||
v.Set("limit", fmt.Sprint(maxEntries))
|
v.Set("limit", fmt.Sprint(maxEntries))
|
||||||
@@ -128,7 +128,7 @@ func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstat
|
|||||||
return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
|
return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
|
// NetworkLockForceLocalDisable forcibly shuts down tailnet lock on this node.
|
||||||
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
|
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
|
||||||
// This endpoint expects an empty JSON stanza as the payload.
|
// This endpoint expects an empty JSON stanza as the payload.
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
@@ -142,7 +142,7 @@ func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
|
// NetworkLockVerifySigningDeeplink verifies the tailnet lock deeplink contained
|
||||||
// in url and returns information extracted from it.
|
// in url and returns information extracted from it.
|
||||||
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
|
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
|
||||||
vr := struct {
|
vr := struct {
|
||||||
@@ -193,7 +193,7 @@ func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkLockDisable shuts down network-lock across the tailnet.
|
// NetworkLockDisable shuts down tailnet-lock across the tailnet.
|
||||||
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
|
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 {
|
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
|
||||||
return fmt.Errorf("error: %w", err)
|
return fmt.Errorf("error: %w", err)
|
||||||
|
|||||||
+41
-3
@@ -11,6 +11,7 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/png"
|
"image/png"
|
||||||
|
"log"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -204,12 +205,49 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
bg = color.NRGBA{0, 0, 0, 255}
|
black = color.NRGBA{0, 0, 0, 255}
|
||||||
fg = color.NRGBA{255, 255, 255, 255}
|
white = color.NRGBA{255, 255, 255, 255}
|
||||||
gray = color.NRGBA{255, 255, 255, 102}
|
darkGray = color.NRGBA{102, 102, 102, 255}
|
||||||
|
lightGray = color.NRGBA{153, 153, 153, 255}
|
||||||
red = color.NRGBA{229, 111, 74, 255}
|
red = color.NRGBA{229, 111, 74, 255}
|
||||||
|
transparent = color.NRGBA{}
|
||||||
|
|
||||||
|
// default values to dark theme
|
||||||
|
bg = black
|
||||||
|
fg = white
|
||||||
|
gray = darkGray
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SetTheme sets the color theme of the systray icon.
|
||||||
|
//
|
||||||
|
// Supported themes are:
|
||||||
|
// - dark - white and gray dots over black background
|
||||||
|
// - dark:nobg - white and grey dots over transparent background
|
||||||
|
// - light - black and gray dots over white background
|
||||||
|
// - light:nobg - black and grey dots over transparent background
|
||||||
|
func SetTheme(theme string) {
|
||||||
|
switch theme {
|
||||||
|
case "dark":
|
||||||
|
bg = black
|
||||||
|
fg = white
|
||||||
|
gray = darkGray
|
||||||
|
case "dark:nobg":
|
||||||
|
bg = transparent
|
||||||
|
fg = white
|
||||||
|
gray = darkGray
|
||||||
|
case "light":
|
||||||
|
bg = white
|
||||||
|
fg = black
|
||||||
|
gray = lightGray
|
||||||
|
case "light:nobg":
|
||||||
|
bg = transparent
|
||||||
|
fg = black
|
||||||
|
gray = lightGray
|
||||||
|
default:
|
||||||
|
log.Printf("unknown theme: %q", theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// render returns a PNG image of the logo.
|
// render returns a PNG image of the logo.
|
||||||
func (logo tsLogo) render() *bytes.Buffer {
|
func (logo tsLogo) render() *bytes.Buffer {
|
||||||
const borderUnits = 1
|
const borderUnits = 1
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
//go:build cgo || !darwin
|
//go:build cgo || !darwin
|
||||||
|
|
||||||
// Package systray provides a minimal Tailscale systray application.
|
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -621,11 +621,9 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
|||||||
title += strings.Split(sugg.Name, ".")[0]
|
title += strings.Split(sugg.Name, ".")[0]
|
||||||
}
|
}
|
||||||
menu.exitNodes.AddSeparator()
|
menu.exitNodes.AddSeparator()
|
||||||
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
|
active := recommendedIsActive(status, sugg.ID, sugg.Location.CountryCode(), sugg.Location.City())
|
||||||
|
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", active)
|
||||||
setExitNodeOnClick(rm, sugg.ID)
|
setExitNodeOnClick(rm, sugg.ID)
|
||||||
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
|
|
||||||
rm.Check()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,13 +645,11 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
|||||||
if !ps.Online {
|
if !ps.Online {
|
||||||
name += " (offline)"
|
name += " (offline)"
|
||||||
}
|
}
|
||||||
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", false)
|
active := status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID
|
||||||
|
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", active)
|
||||||
if !ps.Online {
|
if !ps.Online {
|
||||||
sm.Disable()
|
sm.Disable()
|
||||||
}
|
}
|
||||||
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
|
|
||||||
sm.Check()
|
|
||||||
}
|
|
||||||
setExitNodeOnClick(sm, ps.ID)
|
setExitNodeOnClick(sm, ps.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -743,6 +739,30 @@ func (mc *mvCountry) sortedCities() []*mvCity {
|
|||||||
return cities
|
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.
|
// countryFlag takes a 2-character ASCII string and returns the corresponding emoji flag.
|
||||||
// It returns the empty string on error.
|
// It returns the empty string on error.
|
||||||
func countryFlag(code string) string {
|
func countryFlag(code string) string {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// 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 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
-119
@@ -35,8 +35,10 @@ import (
|
|||||||
"tailscale.com/net/netutil"
|
"tailscale.com/net/netutil"
|
||||||
"tailscale.com/net/tsaddr"
|
"tailscale.com/net/tsaddr"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/tsweb"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
"tailscale.com/types/views"
|
"tailscale.com/types/views"
|
||||||
|
"tailscale.com/util/ctxkey"
|
||||||
"tailscale.com/util/httpm"
|
"tailscale.com/util/httpm"
|
||||||
"tailscale.com/util/syspolicy/policyclient"
|
"tailscale.com/util/syspolicy/policyclient"
|
||||||
"tailscale.com/version"
|
"tailscale.com/version"
|
||||||
@@ -527,45 +529,40 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiHandler[data any] struct {
|
// handleJSON manages decoding the request's body JSON as data and passing it
|
||||||
s *Server
|
// on to the provided handler function.
|
||||||
w http.ResponseWriter
|
func handleJSON[data any](h func(ctx context.Context, data data) error) http.HandlerFunc {
|
||||||
r *http.Request
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
// permissionCheck allows for defining whether a requesting peer's
|
var body data
|
||||||
// capabilities grant them access to make the given data update.
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
// If permissionCheck reports false, the request fails as unauthorized.
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
permissionCheck func(data data, peer peerCapabilities) bool
|
return
|
||||||
}
|
}
|
||||||
|
if err := h(r.Context(), body); err != nil {
|
||||||
// newHandler constructs a new api handler which restricts the given request
|
if httpErr, ok := errors.AsType[tsweb.HTTPError](err); ok {
|
||||||
// to the specified permission check. If the permission check fails for
|
tsweb.WriteHTTPError(w, r, httpErr)
|
||||||
// the peer associated with the request, an unauthorized error is returned
|
} else {
|
||||||
// to the client.
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
|
}
|
||||||
return &apiHandler[data]{
|
return
|
||||||
s: s,
|
}
|
||||||
w: w,
|
w.WriteHeader(http.StatusOK)
|
||||||
r: r,
|
|
||||||
permissionCheck: permissionCheck,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// alwaysAllowed can be passed as the permissionCheck argument to newHandler
|
var contextKeyPeer = ctxkey.New("peer-capabilities", peerCapabilities{})
|
||||||
// for requests that are always allowed to complete regardless of a peer's
|
|
||||||
// capabilities.
|
|
||||||
func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
|
|
||||||
|
|
||||||
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
|
func (s *Server) setPeer(r *http.Request) (*http.Request, error) {
|
||||||
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
|
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
|
||||||
// WhoIs when originally checking for a session from authorizeRequest.
|
// 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
|
// 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.
|
// up having to re-call them to grab the peer capabilities.
|
||||||
status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
|
status, err := s.lc.StatusWithoutPeers(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
|
whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -573,56 +570,11 @@ func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return peer, nil
|
return r.WithContext(contextKeyPeer.WithValue(r.Context(), peer)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
|
func (s *Server) getPeer(ctx context.Context) peerCapabilities {
|
||||||
|
return contextKeyPeer.Value(ctx)
|
||||||
// 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.
|
// serveAPI serves requests for the web client api.
|
||||||
@@ -637,67 +589,44 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
r, err = s.setPeer(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||||
switch {
|
switch {
|
||||||
case path == "/data" && r.Method == httpm.GET:
|
case path == "/data" && r.Method == httpm.GET:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.serveGetNodeData(w, r)
|
||||||
handle(s.serveGetNodeData)
|
|
||||||
return
|
return
|
||||||
case path == "/exit-nodes" && r.Method == httpm.GET:
|
case path == "/exit-nodes" && r.Method == httpm.GET:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.serveGetExitNodes(w, r)
|
||||||
handle(s.serveGetExitNodes)
|
|
||||||
return
|
return
|
||||||
case path == "/routes" && r.Method == httpm.POST:
|
case path == "/routes" && r.Method == httpm.POST:
|
||||||
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
|
handleJSON[postRoutesRequest](s.servePostRoutes)(w, r)
|
||||||
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
|
return
|
||||||
case path == "/device-details-click" && r.Method == httpm.POST:
|
case path == "/device-details-click" && r.Method == httpm.POST:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.serveDeviceDetailsClick(w, r)
|
||||||
handle(s.serveDeviceDetailsClick)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/logout" && r.Method == httpm.POST:
|
case path == "/local/v0/logout" && r.Method == httpm.POST:
|
||||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
return peer.canEdit(capFeatureAccount)
|
|
||||||
}
|
|
||||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
|
||||||
handle(s.proxyRequestToLocalAPI)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
|
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
|
||||||
peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
|
handleJSON[maskedPrefs](s.serveUpdatePrefs)(w, r)
|
||||||
if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
newHandler[maskedPrefs](s, w, r, peerAllowed).
|
|
||||||
handleJSON(s.serveUpdatePrefs)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/update/check" && r.Method == httpm.GET:
|
case path == "/local/v0/update/check" && r.Method == httpm.GET:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
handle(s.proxyRequestToLocalAPI)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/update/check" && r.Method == httpm.POST:
|
case path == "/local/v0/update/check" && r.Method == httpm.POST:
|
||||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
return peer.canEdit(capFeatureAccount)
|
|
||||||
}
|
|
||||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
|
||||||
handle(s.proxyRequestToLocalAPI)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
|
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
handle(s.proxyRequestToLocalAPI)
|
|
||||||
return
|
return
|
||||||
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
|
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
|
||||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
handle(s.proxyRequestToLocalAPI)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||||
@@ -1122,6 +1051,11 @@ type maskedPrefs struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
|
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{
|
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||||
RunSSHSet: prefs.RunSSHSet,
|
RunSSHSet: prefs.RunSSHSet,
|
||||||
Prefs: ipn.Prefs{
|
Prefs: ipn.Prefs{
|
||||||
@@ -1140,6 +1074,17 @@ type postRoutesRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
|
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)
|
prefs, err := s.lc.GetPrefs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1153,13 +1098,14 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
|
|||||||
}
|
}
|
||||||
currNonExitRoutes = append(currNonExitRoutes, r.String())
|
currNonExitRoutes = append(currNonExitRoutes, r.String())
|
||||||
}
|
}
|
||||||
// Set non-edited fields to their current values.
|
// For each group of fields not being set, preserve the current prefs.
|
||||||
if data.SetExitNode {
|
if !data.SetExitNode {
|
||||||
data.AdvertiseRoutes = currNonExitRoutes
|
|
||||||
} else if data.SetRoutes {
|
|
||||||
data.AdvertiseExitNode = currAdvertisingExitNode
|
data.AdvertiseExitNode = currAdvertisingExitNode
|
||||||
data.UseExitNode = prefs.ExitNodeID
|
data.UseExitNode = prefs.ExitNodeID
|
||||||
}
|
}
|
||||||
|
if !data.SetRoutes {
|
||||||
|
data.AdvertiseRoutes = currNonExitRoutes
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate routes.
|
// Calculate routes.
|
||||||
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
||||||
@@ -1336,6 +1282,19 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
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
|
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
|
||||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+148
-2
@@ -191,7 +191,7 @@ func TestServeAPI(t *testing.T) {
|
|||||||
reqBody: "{\"setExitNode\":true}",
|
reqBody: "{\"setExitNode\":true}",
|
||||||
tests: []requestTest{{
|
tests: []requestTest{{
|
||||||
remoteIP: remoteIPWithNoCapabilities,
|
remoteIP: remoteIPWithNoCapabilities,
|
||||||
wantResponse: "not allowed",
|
wantResponse: "SetExitNode not allowed",
|
||||||
wantStatus: http.StatusUnauthorized,
|
wantStatus: http.StatusUnauthorized,
|
||||||
}, {
|
}, {
|
||||||
remoteIP: remoteIPWithAllCapabilities,
|
remoteIP: remoteIPWithAllCapabilities,
|
||||||
@@ -204,7 +204,7 @@ func TestServeAPI(t *testing.T) {
|
|||||||
reqContentType: "application/json",
|
reqContentType: "application/json",
|
||||||
tests: []requestTest{{
|
tests: []requestTest{{
|
||||||
remoteIP: remoteIPWithNoCapabilities,
|
remoteIP: remoteIPWithNoCapabilities,
|
||||||
wantResponse: "not allowed",
|
wantResponse: "RunSSHSet not allowed",
|
||||||
wantStatus: http.StatusUnauthorized,
|
wantStatus: http.StatusUnauthorized,
|
||||||
}, {
|
}, {
|
||||||
remoteIP: remoteIPWithAllCapabilities,
|
remoteIP: remoteIPWithAllCapabilities,
|
||||||
@@ -1604,3 +1604,149 @@ func TestCSRFProtect(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServePostRoutes(t *testing.T) {
|
||||||
|
existingExitNodeID := tailcfg.StableNodeID("existing-exit-node")
|
||||||
|
existingRoute := netip.MustParsePrefix("192.168.1.0/24")
|
||||||
|
|
||||||
|
existingPrefs := &ipn.Prefs{
|
||||||
|
ExitNodeID: existingExitNodeID,
|
||||||
|
AdvertiseRoutes: []netip.Prefix{existingRoute},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data postRoutesRequest
|
||||||
|
peerCaps peerCapabilities
|
||||||
|
wantErr bool
|
||||||
|
wantEditPrefs bool // whether EditPrefs (PATCH /prefs) should be called
|
||||||
|
wantExitNodeID tailcfg.StableNodeID
|
||||||
|
wantRoutes []netip.Prefix
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty-request",
|
||||||
|
data: postRoutesRequest{},
|
||||||
|
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||||
|
wantErr: true,
|
||||||
|
wantEditPrefs: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetExitNode-only",
|
||||||
|
data: postRoutesRequest{
|
||||||
|
SetExitNode: true,
|
||||||
|
UseExitNode: "new-exit-node",
|
||||||
|
},
|
||||||
|
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||||
|
wantEditPrefs: true,
|
||||||
|
wantExitNodeID: "new-exit-node",
|
||||||
|
wantRoutes: []netip.Prefix{existingRoute},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetExitNode-not-allowed",
|
||||||
|
data: postRoutesRequest{
|
||||||
|
SetExitNode: true,
|
||||||
|
UseExitNode: "new-exit-node",
|
||||||
|
},
|
||||||
|
peerCaps: peerCapabilities{capFeatureSubnets: true},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetRoutes-only",
|
||||||
|
data: postRoutesRequest{
|
||||||
|
SetRoutes: true,
|
||||||
|
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||||
|
},
|
||||||
|
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||||
|
wantEditPrefs: true,
|
||||||
|
wantExitNodeID: existingExitNodeID,
|
||||||
|
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetRoutes-not-allowed",
|
||||||
|
data: postRoutesRequest{
|
||||||
|
SetRoutes: true,
|
||||||
|
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||||
|
},
|
||||||
|
peerCaps: peerCapabilities{capFeatureExitNodes: true},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetExitNode-and-SetRoutes",
|
||||||
|
data: postRoutesRequest{
|
||||||
|
SetExitNode: true,
|
||||||
|
SetRoutes: true,
|
||||||
|
UseExitNode: "new-exit-node",
|
||||||
|
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||||
|
},
|
||||||
|
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||||
|
wantEditPrefs: true,
|
||||||
|
wantExitNodeID: "new-exit-node",
|
||||||
|
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var gotPrefs *ipn.MaskedPrefs
|
||||||
|
|
||||||
|
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||||
|
defer lal.Close()
|
||||||
|
|
||||||
|
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/localapi/v0/prefs" {
|
||||||
|
t.Errorf("unexpected localapi call to %q", r.URL.Path)
|
||||||
|
http.Error(w, "unexpected localapi call", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case httpm.GET:
|
||||||
|
writeJSON(w, existingPrefs)
|
||||||
|
case httpm.PATCH:
|
||||||
|
var mp ipn.MaskedPrefs
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&mp); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gotPrefs = &mp
|
||||||
|
writeJSON(w, gotPrefs.Prefs)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected method %q on /prefs", r.Method)
|
||||||
|
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
defer localapi.Close()
|
||||||
|
go localapi.Serve(lal)
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
mode: ManageServerMode,
|
||||||
|
lc: &local.Client{Dial: lal.Dial},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := contextKeyPeer.WithValue(t.Context(), tt.peerCaps)
|
||||||
|
err := s.servePostRoutes(ctx, tt.data)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Error("wanted error, got nil")
|
||||||
|
}
|
||||||
|
if gotPrefs != nil {
|
||||||
|
t.Error("EditPrefs should not have been called on error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotPrefs == nil {
|
||||||
|
t.Fatal("expected EditPrefs to be called")
|
||||||
|
}
|
||||||
|
if diff := cmp.Diff(tt.wantExitNodeID, gotPrefs.ExitNodeID); diff != "" {
|
||||||
|
t.Errorf("ExitNodeID mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
if diff := cmp.Diff(tt.wantRoutes, gotPrefs.AdvertiseRoutes, cmp.Comparer(func(a, b netip.Prefix) bool { return a.Compare(b) == 0 })); diff != "" {
|
||||||
|
t.Errorf("AdvertiseRoutes mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ const (
|
|||||||
updaterPrefix = "tailscale-updater"
|
updaterPrefix = "tailscale-updater"
|
||||||
)
|
)
|
||||||
|
|
||||||
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
|
func makeCmdTailscaleCopy() (origPathExe, tmpPathExe string, err error) {
|
||||||
selfExe, err := os.Executable()
|
srcExe, err := findCmdTailscale()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
f, err := os.Open(selfExe)
|
f, err := os.Open(srcExe)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,25 @@ func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
|
|||||||
f2.Close()
|
f2.Close()
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
return selfExe, f2.Name(), f2.Close()
|
return srcExe, f2.Name(), f2.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// findCmdTailscale returns the path to the binary that should be copied for the update
|
||||||
|
// re-execution. The copy is re-executed with "update" as a subcommand, so it must be
|
||||||
|
// a binary that handles "update" (ie tailscale.exe, not tailscaled.exe)
|
||||||
|
func findCmdTailscale() (string, error) {
|
||||||
|
selfExe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.EqualFold(filepath.Base(selfExe), "tailscale.exe") {
|
||||||
|
return selfExe, nil
|
||||||
|
}
|
||||||
|
ts := filepath.Join(filepath.Dir(selfExe), "tailscale.exe")
|
||||||
|
if _, err := os.Stat(ts); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot find tailscale.exe alongside %s: %w", selfExe, err)
|
||||||
|
}
|
||||||
|
return ts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func markTempFileWindows(name string) error {
|
func markTempFileWindows(name string) error {
|
||||||
@@ -159,14 +177,14 @@ you can run the command prompt as Administrator one of these ways:
|
|||||||
|
|
||||||
up.Logf("making tailscale.exe copy to switch to...")
|
up.Logf("making tailscale.exe copy to switch to...")
|
||||||
up.cleanupOldDownloads(filepath.Join(os.TempDir(), updaterPrefix+"-*.exe"))
|
up.cleanupOldDownloads(filepath.Join(os.TempDir(), updaterPrefix+"-*.exe"))
|
||||||
_, selfCopy, err := makeSelfCopy()
|
_, cmdTailscaleCopy, err := makeCmdTailscaleCopy()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer os.Remove(selfCopy)
|
defer os.Remove(cmdTailscaleCopy)
|
||||||
up.Logf("running tailscale.exe copy for final install...")
|
up.Logf("running tailscale.exe copy for final install...")
|
||||||
|
|
||||||
cmd := exec.Command(selfCopy, "update")
|
cmd := exec.Command(cmdTailscaleCopy, "update")
|
||||||
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winVersionEnv+"="+ver)
|
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winVersionEnv+"="+ver)
|
||||||
cmd.Stdout = up.Stderr
|
cmd.Stdout = up.Stderr
|
||||||
cmd.Stderr = up.Stderr
|
cmd.Stderr = up.Stderr
|
||||||
|
|||||||
+45
-19
@@ -143,25 +143,9 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
|||||||
writef("if src.%s != nil {", fname)
|
writef("if src.%s != nil {", fname)
|
||||||
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
|
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
|
||||||
writef("for i := range dst.%s {", fname)
|
writef("for i := range dst.%s {", fname)
|
||||||
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
|
writeSliceElemClone(writef, ft.Elem(),
|
||||||
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname)
|
fmt.Sprintf("src.%s[i]", fname),
|
||||||
if codegen.ContainsPointers(ptr.Elem()) {
|
fmt.Sprintf("dst.%s[i]", fname))
|
||||||
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("}")
|
||||||
writef("}")
|
writef("}")
|
||||||
} else {
|
} else {
|
||||||
@@ -189,11 +173,28 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
|||||||
n := it.QualifiedName(sliceType.Elem())
|
n := it.QualifiedName(sliceType.Elem())
|
||||||
writef("if dst.%s != nil {", fname)
|
writef("if dst.%s != nil {", fname)
|
||||||
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
|
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)
|
writef("\tfor k := range src.%s {", fname)
|
||||||
// use zero-length slice instead of nil to ensure
|
// use zero-length slice instead of nil to ensure
|
||||||
// the key is always copied.
|
// the key is always copied.
|
||||||
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
|
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
|
||||||
writef("\t}")
|
writef("\t}")
|
||||||
|
}
|
||||||
writef("}")
|
writef("}")
|
||||||
} else if codegen.IsViewType(elem) || !codegen.ContainsPointers(elem) {
|
} else if codegen.IsViewType(elem) || !codegen.ContainsPointers(elem) {
|
||||||
// If the map values are view types (which are
|
// If the map values are view types (which are
|
||||||
@@ -242,6 +243,31 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
|||||||
buf.Write(codegen.AssertStructUnchanged(t, name, typeParams, "Clone", it))
|
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.
|
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
|
||||||
func hasBasicUnderlying(typ types.Type) bool {
|
func hasBasicUnderlying(typ types.Type) bool {
|
||||||
switch typ.Underlying().(type) {
|
switch typ.Underlying().(type) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
"tailscale.com/cmd/cloner/clonerex"
|
"tailscale.com/cmd/cloner/clonerex"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,6 +183,46 @@ func TestNamedMapContainer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapSlicePointerContainer(t *testing.T) {
|
||||||
|
num := 42
|
||||||
|
orig := &clonerex.MapSlicePointerContainer{
|
||||||
|
Routes: map[string][]*clonerex.SliceContainer{
|
||||||
|
"route1": {
|
||||||
|
{Slice: []*int{&num}},
|
||||||
|
{Slice: []*int{&num, &num}},
|
||||||
|
},
|
||||||
|
"route2": {
|
||||||
|
{Slice: []*int{&num}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := orig.Clone()
|
||||||
|
if !reflect.DeepEqual(orig, cloned) {
|
||||||
|
t.Errorf("Clone() = %v, want %v", cloned, orig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutate cloned.Routes pointer values
|
||||||
|
*cloned.Routes["route1"][0].Slice[0] = 999
|
||||||
|
if *orig.Routes["route1"][0].Slice[0] == 999 {
|
||||||
|
t.Errorf("Clone() aliased memory in Routes: original was modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapSlicePointerContainerNilValue(t *testing.T) {
|
||||||
|
num := 7
|
||||||
|
orig := &clonerex.MapSlicePointerContainer{
|
||||||
|
Routes: map[string][]*clonerex.SliceContainer{
|
||||||
|
"nil-value": nil,
|
||||||
|
"non-nil": {{Slice: []*int{&num}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cloned := orig.Clone()
|
||||||
|
if diff := cmp.Diff(orig.Routes, cloned.Routes); diff != "" {
|
||||||
|
t.Errorf("Clone() Routes mismatch (-orig +cloned):\n%s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeeplyNestedMap(t *testing.T) {
|
func TestDeeplyNestedMap(t *testing.T) {
|
||||||
num := 123
|
num := 123
|
||||||
orig := &clonerex.DeeplyNestedMap{
|
orig := &clonerex.DeeplyNestedMap{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer
|
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer
|
||||||
|
|
||||||
// Package clonerex is an example package for the cloner tool.
|
// Package clonerex is an example package for the cloner tool.
|
||||||
package clonerex
|
package clonerex
|
||||||
@@ -60,6 +60,13 @@ type NamedMapContainer struct {
|
|||||||
Attrs NamedMap
|
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)
|
// DeeplyNestedMap tests arbitrary depth of map nesting (3+ levels)
|
||||||
type DeeplyNestedMap struct {
|
type DeeplyNestedMap struct {
|
||||||
ThreeLevels map[string]map[string]map[string]int
|
ThreeLevels map[string]map[string]map[string]int
|
||||||
|
|||||||
@@ -176,9 +176,42 @@ var _NamedMapContainerCloneNeedsRegeneration = NamedMapContainer(struct {
|
|||||||
Attrs NamedMap
|
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 duplicates src into dst and reports whether it succeeded.
|
// Clone duplicates src into dst and reports whether it succeeded.
|
||||||
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
|
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
|
||||||
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer.
|
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer.
|
||||||
func Clone(dst, src any) bool {
|
func Clone(dst, src any) bool {
|
||||||
switch src := src.(type) {
|
switch src := src.(type) {
|
||||||
case *SliceContainer:
|
case *SliceContainer:
|
||||||
@@ -226,6 +259,15 @@ func Clone(dst, src any) bool {
|
|||||||
*dst = src.Clone()
|
*dst = src.Clone()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
case *MapSlicePointerContainer:
|
||||||
|
switch dst := dst.(type) {
|
||||||
|
case *MapSlicePointerContainer:
|
||||||
|
*dst = *src.Clone()
|
||||||
|
return true
|
||||||
|
case **MapSlicePointerContainer:
|
||||||
|
*dst = src.Clone()
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
|
|
||||||
"tailscale.com/client/local"
|
"tailscale.com/client/local"
|
||||||
"tailscale.com/ipn"
|
|
||||||
"tailscale.com/kube/egressservices"
|
"tailscale.com/kube/egressservices"
|
||||||
"tailscale.com/kube/kubeclient"
|
"tailscale.com/kube/kubeclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
|
"tailscale.com/types/netmap"
|
||||||
"tailscale.com/util/httpm"
|
"tailscale.com/util/httpm"
|
||||||
"tailscale.com/util/linuxfw"
|
"tailscale.com/util/linuxfw"
|
||||||
"tailscale.com/util/mak"
|
"tailscale.com/util/mak"
|
||||||
@@ -54,7 +55,7 @@ type egressProxy struct {
|
|||||||
|
|
||||||
tsClient *local.Client // never nil
|
tsClient *local.Client // never nil
|
||||||
|
|
||||||
netmapChan chan ipn.Notify // chan to receive netmap updates on
|
netmapChan chan *netmap.NetworkMap // chan to receive netmap updates on
|
||||||
|
|
||||||
podIPv4 string // never empty string, currently only IPv4 is supported
|
podIPv4 string // never empty string, currently only IPv4 is supported
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ type httpClient interface {
|
|||||||
// - the mounted egress config has changed
|
// - the mounted egress config has changed
|
||||||
// - the proxy's tailnet IP addresses have changed
|
// - the proxy's tailnet IP addresses have changed
|
||||||
// - tailnet IPs have changed for any backend targets specified by tailnet FQDN
|
// - tailnet IPs have changed for any backend targets specified by tailnet FQDN
|
||||||
func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRunOpts) error {
|
func (ep *egressProxy) run(ctx context.Context, nm *netmap.NetworkMap, opts egressProxyRunOpts) error {
|
||||||
ep.configure(opts)
|
ep.configure(opts)
|
||||||
var tickChan <-chan time.Time
|
var tickChan <-chan time.Time
|
||||||
var eventChan <-chan fsnotify.Event
|
var eventChan <-chan fsnotify.Event
|
||||||
@@ -105,7 +106,7 @@ func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRu
|
|||||||
eventChan = w.Events
|
eventChan = w.Events
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ep.sync(ctx, n); err != nil {
|
if err := ep.sync(ctx, nm); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
@@ -116,14 +117,14 @@ func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRu
|
|||||||
log.Printf("periodic sync, ensuring firewall config is up to date...")
|
log.Printf("periodic sync, ensuring firewall config is up to date...")
|
||||||
case <-eventChan:
|
case <-eventChan:
|
||||||
log.Printf("config file change detected, ensuring firewall config is up to date...")
|
log.Printf("config file change detected, ensuring firewall config is up to date...")
|
||||||
case n = <-ep.netmapChan:
|
case nm = <-ep.netmapChan:
|
||||||
shouldResync := ep.shouldResync(n)
|
shouldResync := ep.shouldResync(nm)
|
||||||
if !shouldResync {
|
if !shouldResync {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Printf("netmap change detected, ensuring firewall config is up to date...")
|
log.Printf("netmap change detected, ensuring firewall config is up to date...")
|
||||||
}
|
}
|
||||||
if err := ep.sync(ctx, n); err != nil {
|
if err := ep.sync(ctx, nm); err != nil {
|
||||||
return fmt.Errorf("error syncing egress service config: %w", err)
|
return fmt.Errorf("error syncing egress service config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +136,7 @@ type egressProxyRunOpts struct {
|
|||||||
kc kubeclient.Client
|
kc kubeclient.Client
|
||||||
tsClient *local.Client
|
tsClient *local.Client
|
||||||
stateSecret string
|
stateSecret string
|
||||||
netmapChan chan ipn.Notify
|
netmapChan chan *netmap.NetworkMap
|
||||||
podIPv4 string
|
podIPv4 string
|
||||||
tailnetAddrs []netip.Prefix
|
tailnetAddrs []netip.Prefix
|
||||||
}
|
}
|
||||||
@@ -164,7 +165,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
|
// 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
|
// 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
|
// as failed firewall update
|
||||||
func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
|
func (ep *egressProxy) sync(ctx context.Context, nm *netmap.NetworkMap) error {
|
||||||
cfgs, err := ep.getConfigs()
|
cfgs, err := ep.getConfigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error retrieving egress service configs: %w", err)
|
return fmt.Errorf("error retrieving egress service configs: %w", err)
|
||||||
@@ -173,12 +174,12 @@ func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error retrieving current egress proxy status: %w", err)
|
return fmt.Errorf("error retrieving current egress proxy status: %w", err)
|
||||||
}
|
}
|
||||||
newStatus, err := ep.syncEgressConfigs(cfgs, status, n)
|
newStatus, err := ep.syncEgressConfigs(cfgs, status, nm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error syncing egress service configs: %w", err)
|
return fmt.Errorf("error syncing egress service configs: %w", err)
|
||||||
}
|
}
|
||||||
if !servicesStatusIsEqual(newStatus, status) {
|
if !servicesStatusIsEqual(newStatus, status) {
|
||||||
if err := ep.setStatus(ctx, newStatus, n); err != nil {
|
if err := ep.setStatus(ctx, newStatus, nm); err != nil {
|
||||||
return fmt.Errorf("error setting egress proxy status: %w", err)
|
return fmt.Errorf("error setting egress proxy status: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,14 +188,14 @@ func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
|
|||||||
|
|
||||||
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
|
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
|
||||||
// Netmap must not be nil.
|
// Netmap must not be nil.
|
||||||
func (ep *egressProxy) addrsHaveChanged(n ipn.Notify) bool {
|
func (ep *egressProxy) addrsHaveChanged(nm *netmap.NetworkMap) bool {
|
||||||
return !reflect.DeepEqual(ep.tailnetAddrs, n.NetMap.SelfNode.Addresses())
|
return !reflect.DeepEqual(ep.tailnetAddrs, nm.SelfNode.Addresses())
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncEgressConfigs adds and deletes firewall rules to match the desired
|
// syncEgressConfigs adds and deletes firewall rules to match the desired
|
||||||
// configuration. It uses the provided status to determine what is currently
|
// configuration. It uses the provided status to determine what is currently
|
||||||
// applied and updates the status after a successful sync.
|
// applied and updates the status after a successful sync.
|
||||||
func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *egressservices.Status, n ipn.Notify) (*egressservices.Status, error) {
|
func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *egressservices.Status, nm *netmap.NetworkMap) (*egressservices.Status, error) {
|
||||||
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
|
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -212,8 +213,8 @@ func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *e
|
|||||||
// Add new services, update rules for any that have changed.
|
// Add new services, update rules for any that have changed.
|
||||||
rulesPerSvcToAdd := make(map[string][]rule, 0)
|
rulesPerSvcToAdd := make(map[string][]rule, 0)
|
||||||
rulesPerSvcToDelete := make(map[string][]rule, 0)
|
rulesPerSvcToDelete := make(map[string][]rule, 0)
|
||||||
for svcName, cfg := range *cfgs {
|
for svcName, cfg := range cfgs {
|
||||||
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, n)
|
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, nm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error determining tailnet target IPs: %w", err)
|
return nil, fmt.Errorf("error determining tailnet target IPs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -228,12 +229,12 @@ func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *e
|
|||||||
if len(rulesToDelete) != 0 {
|
if len(rulesToDelete) != 0 {
|
||||||
mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete)
|
mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete)
|
||||||
}
|
}
|
||||||
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(n) {
|
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(nm) {
|
||||||
// For each tailnet target, set up SNAT from the local tailnet device address of the matching
|
// For each tailnet target, set up SNAT from the local tailnet device address of the matching
|
||||||
// family.
|
// family.
|
||||||
for _, t := range tailnetTargetIPs {
|
for _, t := range tailnetTargetIPs {
|
||||||
var local netip.Addr
|
var local netip.Addr
|
||||||
for _, pfx := range n.NetMap.SelfNode.Addresses().All() {
|
for _, pfx := range nm.SelfNode.Addresses().All() {
|
||||||
if !pfx.IsSingleIP() {
|
if !pfx.IsSingleIP() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -352,7 +353,7 @@ func updatesForCfg(svcName string, cfg egressservices.Config, status *egressserv
|
|||||||
|
|
||||||
// deleteUnneccessaryServices ensure that any services found on status, but not
|
// deleteUnneccessaryServices ensure that any services found on status, but not
|
||||||
// present in config are deleted.
|
// 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) {
|
if !hasServicesConfigured(status) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -367,7 +368,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
for svcName, svc := range status.Services {
|
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)
|
log.Printf("service %s is no longer required, deleting", svcName)
|
||||||
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
|
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
|
||||||
return fmt.Errorf("error deleting service %s: %w", svcName, err)
|
return fmt.Errorf("error deleting service %s: %w", svcName, err)
|
||||||
@@ -379,7 +380,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getConfigs gets the mounted egress service configuration.
|
// 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)
|
svcsCfg := filepath.Join(ep.cfgPath, egressservices.KeyEgressServices)
|
||||||
j, err := os.ReadFile(svcsCfg)
|
j, err := os.ReadFile(svcsCfg)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -391,7 +392,7 @@ func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) {
|
|||||||
if len(j) == 0 || string(j) == "" {
|
if len(j) == 0 || string(j) == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
cfg := &egressservices.Configs{}
|
cfg := egressservices.Configs{}
|
||||||
if err := json.Unmarshal(j, &cfg); err != nil {
|
if err := json.Unmarshal(j, &cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -423,7 +424,7 @@ func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, e
|
|||||||
|
|
||||||
// setStatus writes egress proxy's currently configured firewall to the state
|
// setStatus writes egress proxy's currently configured firewall to the state
|
||||||
// Secret and updates proxy's tailnet addresses.
|
// Secret and updates proxy's tailnet addresses.
|
||||||
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, n ipn.Notify) error {
|
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, nm *netmap.NetworkMap) error {
|
||||||
// Pod IP is used to determine if a stored status applies to THIS proxy Pod.
|
// Pod IP is used to determine if a stored status applies to THIS proxy Pod.
|
||||||
if status == nil {
|
if status == nil {
|
||||||
status = &egressservices.Status{}
|
status = &egressservices.Status{}
|
||||||
@@ -446,7 +447,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 {
|
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
|
||||||
return fmt.Errorf("error patching state Secret: %w", err)
|
return fmt.Errorf("error patching state Secret: %w", err)
|
||||||
}
|
}
|
||||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
ep.tailnetAddrs = nm.SelfNode.Addresses().AsSlice()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,7 +457,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
|
|||||||
// FQDN, resolve the FQDN and return the resolved IPs. It checks if the
|
// 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
|
// netfilter runner supports IPv6 NAT and skips any IPv6 addresses if it
|
||||||
// doesn't.
|
// doesn't.
|
||||||
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.Notify) (addrs []netip.Addr, err error) {
|
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm *netmap.NetworkMap) (addrs []netip.Addr, err error) {
|
||||||
if svc.TailnetTarget.IP != "" {
|
if svc.TailnetTarget.IP != "" {
|
||||||
addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
|
addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -472,11 +473,11 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.N
|
|||||||
if svc.TailnetTarget.FQDN == "" {
|
if svc.TailnetTarget.FQDN == "" {
|
||||||
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
|
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
|
||||||
}
|
}
|
||||||
if n.NetMap == nil {
|
if nm == nil {
|
||||||
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
|
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
|
||||||
return addrs, nil
|
return addrs, nil
|
||||||
}
|
}
|
||||||
egressAddrs, err := resolveTailnetFQDN(n.NetMap, svc.TailnetTarget.FQDN)
|
egressAddrs, err := resolveTailnetFQDN(nm, svc.TailnetTarget.FQDN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("error fetching backend addresses for %q: %v", svc.TailnetTarget.FQDN, err)
|
log.Printf("error fetching backend addresses for %q: %v", svc.TailnetTarget.FQDN, err)
|
||||||
return addrs, nil
|
return addrs, nil
|
||||||
@@ -502,22 +503,22 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.N
|
|||||||
|
|
||||||
// shouldResync parses netmap 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.
|
// changes for which the egress proxy's firewall should be reconfigured.
|
||||||
func (ep *egressProxy) shouldResync(n ipn.Notify) bool {
|
func (ep *egressProxy) shouldResync(nm *netmap.NetworkMap) bool {
|
||||||
if n.NetMap == nil {
|
if nm == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// If proxy's tailnet addresses have changed, resync.
|
// If proxy's tailnet addresses have changed, resync.
|
||||||
if !reflect.DeepEqual(n.NetMap.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
|
if !reflect.DeepEqual(nm.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
|
||||||
log.Printf("node addresses have changed, trigger egress config resync")
|
log.Printf("node addresses have changed, trigger egress config resync")
|
||||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
ep.tailnetAddrs = nm.SelfNode.Addresses().AsSlice()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the IPs for any of the egress services configured via FQDN have
|
// If the IPs for any of the egress services configured via FQDN have
|
||||||
// changed, resync.
|
// changed, resync.
|
||||||
for fqdn, ips := range ep.targetFQDNs {
|
for fqdn, ips := range ep.targetFQDNs {
|
||||||
for _, nn := range n.NetMap.Peers {
|
for _, nn := range nm.Peers {
|
||||||
if equalFQDNs(nn.Name(), fqdn) {
|
if equalFQDNs(nn.Name(), fqdn) {
|
||||||
if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) {
|
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())
|
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())
|
||||||
@@ -602,8 +603,8 @@ type rule struct {
|
|||||||
protocol string
|
protocol string
|
||||||
}
|
}
|
||||||
|
|
||||||
func wantsServicesConfigured(cfgs *egressservices.Configs) bool {
|
func wantsServicesConfigured(cfgs egressservices.Configs) bool {
|
||||||
return cfgs != nil && len(*cfgs) != 0
|
return cfgs != nil && len(cfgs) != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasServicesConfigured(status *egressservices.Status) bool {
|
func hasServicesConfigured(status *egressservices.Status) bool {
|
||||||
@@ -657,13 +658,13 @@ 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
|
// 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
|
// backends and eventually kube proxy routing rules should be updated to no longer route traffic for the Service to this
|
||||||
// Pod.
|
// Pod.
|
||||||
func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs *egressservices.Configs, hp int) {
|
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
|
if cfgs == nil || len(cfgs) == 0 { // avoid sleeping if no services are configured
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Ensuring that cluster traffic for egress targets is no longer routed via this Pod...")
|
log.Printf("Ensuring that cluster traffic for egress targets is no longer routed via this Pod...")
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for s, cfg := range *cfgs {
|
for s, cfg := range cfgs {
|
||||||
hep := cfg.HealthCheckEndpoint
|
hep := cfg.HealthCheckEndpoint
|
||||||
if hep == "" {
|
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)
|
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)
|
||||||
|
|||||||
@@ -255,13 +255,13 @@ func TestWaitTillSafeToShutdown(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cfgs := &egressservices.Configs{}
|
cfgs := egressservices.Configs{}
|
||||||
switches := make(map[string]int)
|
switches := make(map[string]int)
|
||||||
|
|
||||||
for svc, callsToSwitch := range tt.services {
|
for svc, callsToSwitch := range tt.services {
|
||||||
endpoint := fmt.Sprintf("http://%s.local", svc)
|
endpoint := fmt.Sprintf("http://%s.local", svc)
|
||||||
if tt.healthCheckSet {
|
if tt.healthCheckSet {
|
||||||
(*cfgs)[svc] = egressservices.Config{
|
cfgs[svc] = egressservices.Config{
|
||||||
HealthCheckEndpoint: endpoint,
|
HealthCheckEndpoint: endpoint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-82
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"tailscale.com/client/local"
|
"tailscale.com/client/local"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/kube/authkey"
|
||||||
"tailscale.com/kube/egressservices"
|
"tailscale.com/kube/egressservices"
|
||||||
"tailscale.com/kube/ingressservices"
|
"tailscale.com/kube/ingressservices"
|
||||||
"tailscale.com/kube/kubeapi"
|
"tailscale.com/kube/kubeapi"
|
||||||
@@ -32,7 +33,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const fieldManager = "tailscale-container"
|
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
|
// 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.
|
// this rather than any of the upstream Kubernetes client libaries to avoid extra imports.
|
||||||
@@ -127,6 +127,9 @@ func (kc *kubeClient) deleteAuthKey(ctx context.Context) error {
|
|||||||
|
|
||||||
// resetContainerbootState resets state from previous runs of containerboot to
|
// resetContainerbootState resets state from previous runs of containerboot to
|
||||||
// ensure the operator doesn't use stale state when a Pod is first recreated.
|
// 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 {
|
func (kc *kubeClient) resetContainerbootState(ctx context.Context, podUID string, tailscaledConfigAuthkey string) error {
|
||||||
existingSecret, err := kc.GetSecret(ctx, kc.stateSecret)
|
existingSecret, err := kc.GetSecret(ctx, kc.stateSecret)
|
||||||
switch {
|
switch {
|
||||||
@@ -140,11 +143,6 @@ func (kc *kubeClient) resetContainerbootState(ctx context.Context, podUID string
|
|||||||
s := &kubeapi.Secret{
|
s := &kubeapi.Secret{
|
||||||
Data: map[string][]byte{
|
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,
|
kubetypes.KeyHTTPSEndpoint: nil,
|
||||||
egressservices.KeyEgressServices: nil,
|
egressservices.KeyEgressServices: nil,
|
||||||
ingressservices.IngressConfigKey: nil,
|
ingressservices.IngressConfigKey: nil,
|
||||||
@@ -169,47 +167,18 @@ func (kc *kubeClient) setAndWaitForAuthKeyReissue(ctx context.Context, client *l
|
|||||||
return fmt.Errorf("error disconnecting from control: %w", err)
|
return fmt.Errorf("error disconnecting from control: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = kc.setReissueAuthKey(ctx, tailscaledConfigAuthKey)
|
err = authkey.SetReissueAuthKey(ctx, kc.Client, kc.stateSecret, tailscaledConfigAuthKey, authkey.TailscaleContainerFieldManager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set reissue_authkey in Kubernetes Secret: %w", err)
|
return fmt.Errorf("failed to set reissue_authkey in Kubernetes Secret: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = kc.waitForAuthKeyReissue(ctx, cfg.TailscaledConfigFilePath, tailscaledConfigAuthKey, 10*time.Minute)
|
clearFn := func(ctx context.Context) error {
|
||||||
if err != nil {
|
return authkey.ClearReissueAuthKey(ctx, kc.Client, kc.stateSecret, authkey.TailscaleContainerFieldManager)
|
||||||
return fmt.Errorf("failed to receive new auth key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
getAuthKey := func() string { return authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath) }
|
||||||
}
|
tailscaledCfgDir := filepath.Dir(cfg.TailscaledConfigFilePath)
|
||||||
|
var notify <-chan struct{}
|
||||||
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 {
|
if w, err := fsnotify.NewWatcher(); err != nil {
|
||||||
log.Printf("auth key reissue: fsnotify unavailable, using polling: %v", err)
|
log.Printf("auth key reissue: fsnotify unavailable, using polling: %v", err)
|
||||||
} else if err := w.Add(tailscaledCfgDir); err != nil {
|
} else if err := w.Add(tailscaledCfgDir); err != nil {
|
||||||
@@ -217,56 +186,30 @@ func (kc *kubeClient) waitForAuthKeyReissue(ctx context.Context, configPath stri
|
|||||||
log.Printf("auth key reissue: fsnotify watch failed, using polling: %v", err)
|
log.Printf("auth key reissue: fsnotify watch failed, using polling: %v", err)
|
||||||
} else {
|
} else {
|
||||||
defer w.Close()
|
defer w.Close()
|
||||||
log.Printf("auth key reissue: watching for config changes via fsnotify")
|
ch := make(chan struct{}, 1)
|
||||||
eventChan = w.Events
|
toWatch := filepath.Join(tailscaledCfgDir, "..data")
|
||||||
}
|
go func() {
|
||||||
|
for ev := range w.Events {
|
||||||
// still keep polling if using fsnotify, for logging and in case fsnotify fails
|
if ev.Name == toWatch {
|
||||||
pt := time.NewTicker(pollInterval)
|
|
||||||
defer pt.Stop()
|
|
||||||
pollTicker = pt.C
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case ch <- struct{}{}:
|
||||||
return fmt.Errorf("timeout waiting for auth key reissue after %v", maxWait)
|
default:
|
||||||
case <-pollTicker: // Waits for polling tick, continues when received
|
|
||||||
case event := <-eventChan:
|
|
||||||
if event.Name != toWatch {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
notify = ch
|
||||||
|
log.Printf("auth key reissue: watching for config changes via fsnotify")
|
||||||
|
}
|
||||||
|
|
||||||
newAuthKey := authkeyFromTailscaledConfig(configPath)
|
err = authkey.WaitForAuthKeyReissue(ctx, tailscaledConfigAuthKey, 10*time.Minute, getAuthKey, clearFn, notify)
|
||||||
if newAuthKey != "" && newAuthKey != oldAuthKey {
|
if err != nil {
|
||||||
log.Printf("New auth key received from operator after %v", time.Since(start).Round(time.Second))
|
return fmt.Errorf("failed to receive new auth key: %w", err)
|
||||||
|
|
||||||
if err := kc.clearReissueAuthKeyRequest(ctx); err != nil {
|
|
||||||
log.Printf("Warning: failed to clear reissue request: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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
|
// waitForConsistentState waits for tailscaled to finish writing state if it
|
||||||
// looks like it's started. It is designed to reduce the likelihood that
|
// looks like it's started. It is designed to reduce the likelihood that
|
||||||
// tailscaled gets shut down in the window between authenticating to control
|
// tailscaled gets shut down in the window between authenticating to control
|
||||||
|
|||||||
@@ -259,10 +259,6 @@ func TestResetContainerbootState(t *testing.T) {
|
|||||||
expected: map[string][]byte{
|
expected: map[string][]byte{
|
||||||
kubetypes.KeyCapVer: capver,
|
kubetypes.KeyCapVer: capver,
|
||||||
kubetypes.KeyPodUID: []byte("1234"),
|
kubetypes.KeyPodUID: []byte("1234"),
|
||||||
// Cleared keys.
|
|
||||||
kubetypes.KeyDeviceID: nil,
|
|
||||||
kubetypes.KeyDeviceFQDN: nil,
|
|
||||||
kubetypes.KeyDeviceIPs: nil,
|
|
||||||
kubetypes.KeyHTTPSEndpoint: nil,
|
kubetypes.KeyHTTPSEndpoint: nil,
|
||||||
egressservices.KeyEgressServices: nil,
|
egressservices.KeyEgressServices: nil,
|
||||||
ingressservices.IngressConfigKey: nil,
|
ingressservices.IngressConfigKey: nil,
|
||||||
@@ -272,10 +268,6 @@ func TestResetContainerbootState(t *testing.T) {
|
|||||||
initial: map[string][]byte{},
|
initial: map[string][]byte{},
|
||||||
expected: 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,
|
kubetypes.KeyHTTPSEndpoint: nil,
|
||||||
egressservices.KeyEgressServices: nil,
|
egressservices.KeyEgressServices: nil,
|
||||||
ingressservices.IngressConfigKey: nil,
|
ingressservices.IngressConfigKey: nil,
|
||||||
@@ -303,9 +295,6 @@ func TestResetContainerbootState(t *testing.T) {
|
|||||||
kubetypes.KeyCapVer: capver,
|
kubetypes.KeyCapVer: capver,
|
||||||
kubetypes.KeyPodUID: []byte("1234"),
|
kubetypes.KeyPodUID: []byte("1234"),
|
||||||
// Cleared keys.
|
// Cleared keys.
|
||||||
kubetypes.KeyDeviceID: nil,
|
|
||||||
kubetypes.KeyDeviceFQDN: nil,
|
|
||||||
kubetypes.KeyDeviceIPs: nil,
|
|
||||||
kubetypes.KeyHTTPSEndpoint: nil,
|
kubetypes.KeyHTTPSEndpoint: nil,
|
||||||
egressservices.KeyEgressServices: nil,
|
egressservices.KeyEgressServices: nil,
|
||||||
ingressservices.IngressConfigKey: nil,
|
ingressservices.IngressConfigKey: nil,
|
||||||
@@ -321,9 +310,6 @@ func TestResetContainerbootState(t *testing.T) {
|
|||||||
kubetypes.KeyCapVer: capver,
|
kubetypes.KeyCapVer: capver,
|
||||||
kubetypes.KeyReissueAuthkey: nil,
|
kubetypes.KeyReissueAuthkey: nil,
|
||||||
// Cleared keys.
|
// Cleared keys.
|
||||||
kubetypes.KeyDeviceID: nil,
|
|
||||||
kubetypes.KeyDeviceFQDN: nil,
|
|
||||||
kubetypes.KeyDeviceIPs: nil,
|
|
||||||
kubetypes.KeyHTTPSEndpoint: nil,
|
kubetypes.KeyHTTPSEndpoint: nil,
|
||||||
egressservices.KeyEgressServices: nil,
|
egressservices.KeyEgressServices: nil,
|
||||||
ingressservices.IngressConfigKey: nil,
|
ingressservices.IngressConfigKey: nil,
|
||||||
@@ -338,9 +324,6 @@ func TestResetContainerbootState(t *testing.T) {
|
|||||||
kubetypes.KeyCapVer: capver,
|
kubetypes.KeyCapVer: capver,
|
||||||
// reissue_authkey not cleared.
|
// reissue_authkey not cleared.
|
||||||
// Cleared keys.
|
// Cleared keys.
|
||||||
kubetypes.KeyDeviceID: nil,
|
|
||||||
kubetypes.KeyDeviceFQDN: nil,
|
|
||||||
kubetypes.KeyDeviceIPs: nil,
|
|
||||||
kubetypes.KeyHTTPSEndpoint: nil,
|
kubetypes.KeyHTTPSEndpoint: nil,
|
||||||
egressservices.KeyEgressServices: nil,
|
egressservices.KeyEgressServices: nil,
|
||||||
ingressservices.IngressConfigKey: nil,
|
ingressservices.IngressConfigKey: nil,
|
||||||
@@ -355,9 +338,6 @@ func TestResetContainerbootState(t *testing.T) {
|
|||||||
kubetypes.KeyCapVer: capver,
|
kubetypes.KeyCapVer: capver,
|
||||||
// reissue_authkey not cleared.
|
// reissue_authkey not cleared.
|
||||||
// Cleared keys.
|
// Cleared keys.
|
||||||
kubetypes.KeyDeviceID: nil,
|
|
||||||
kubetypes.KeyDeviceFQDN: nil,
|
|
||||||
kubetypes.KeyDeviceIPs: nil,
|
|
||||||
kubetypes.KeyHTTPSEndpoint: nil,
|
kubetypes.KeyHTTPSEndpoint: nil,
|
||||||
egressservices.KeyEgressServices: nil,
|
egressservices.KeyEgressServices: nil,
|
||||||
ingressservices.IngressConfigKey: nil,
|
ingressservices.IngressConfigKey: nil,
|
||||||
|
|||||||
+70
-47
@@ -137,10 +137,11 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
|
|
||||||
|
"tailscale.com/client/local"
|
||||||
"tailscale.com/health"
|
"tailscale.com/health"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/conffile"
|
|
||||||
kubeutils "tailscale.com/k8s-operator"
|
kubeutils "tailscale.com/k8s-operator"
|
||||||
|
"tailscale.com/kube/authkey"
|
||||||
healthz "tailscale.com/kube/health"
|
healthz "tailscale.com/kube/health"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
klc "tailscale.com/kube/localclient"
|
klc "tailscale.com/kube/localclient"
|
||||||
@@ -209,7 +210,7 @@ func run() error {
|
|||||||
|
|
||||||
var tailscaledConfigAuthkey string
|
var tailscaledConfigAuthkey string
|
||||||
if isOneStepConfig(cfg) {
|
if isOneStepConfig(cfg) {
|
||||||
tailscaledConfigAuthkey = authkeyFromTailscaledConfig(cfg.TailscaledConfigFilePath)
|
tailscaledConfigAuthkey = authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
var kc *kubeClient
|
var kc *kubeClient
|
||||||
@@ -374,7 +375,7 @@ authLoop:
|
|||||||
if hasKubeStateStore(cfg) {
|
if hasKubeStateStore(cfg) {
|
||||||
log.Printf("Auth key missing or invalid (NeedsLogin state), disconnecting from control and requesting new key from operator")
|
log.Printf("Auth key missing or invalid (NeedsLogin state), disconnecting from control and requesting new key from operator")
|
||||||
|
|
||||||
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey)
|
err := kc.setAndWaitForAuthKeyReissue(ctx, client, cfg, tailscaledConfigAuthkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get a reissued authkey: %w", err)
|
return fmt.Errorf("failed to get a reissued authkey: %w", err)
|
||||||
}
|
}
|
||||||
@@ -414,7 +415,7 @@ authLoop:
|
|||||||
if isOneStepConfig(cfg) && hasKubeStateStore(cfg) {
|
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")
|
log.Printf("Auth key failed to authenticate (may be expired or single-use), disconnecting from control and requesting new key from operator")
|
||||||
|
|
||||||
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey)
|
err := kc.setAndWaitForAuthKeyReissue(ctx, client, cfg, tailscaledConfigAuthkey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get a reissued authkey: %w", err)
|
return fmt.Errorf("failed to get a reissued authkey: %w", err)
|
||||||
}
|
}
|
||||||
@@ -536,7 +537,7 @@ authLoop:
|
|||||||
failedResolveAttempts++
|
failedResolveAttempts++
|
||||||
}
|
}
|
||||||
|
|
||||||
var egressSvcsNotify chan ipn.Notify
|
var egressSvcsNotify chan *netmap.NetworkMap
|
||||||
notifyChan := make(chan ipn.Notify)
|
notifyChan := make(chan ipn.Notify)
|
||||||
errChan := make(chan error)
|
errChan := make(chan error)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -550,10 +551,17 @@ authLoop:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
// Peer set changes (Add/Remove) no longer ride on the IPN bus; poll
|
||||||
|
// periodically so egress FQDN resolution and peer-aware work picks
|
||||||
|
// them up. SelfChange covers prompt self changes.
|
||||||
|
const peerPollInterval = 15 * time.Second
|
||||||
|
peerPoll := time.NewTicker(peerPollInterval)
|
||||||
|
defer peerPoll.Stop()
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
runLoop:
|
runLoop:
|
||||||
for {
|
for {
|
||||||
|
var processNetmap bool
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Although killTailscaled() is deferred earlier, if we
|
// Although killTailscaled() is deferred earlier, if we
|
||||||
@@ -566,6 +574,8 @@ runLoop:
|
|||||||
return fmt.Errorf("failed to read from tailscaled: %w", err)
|
return fmt.Errorf("failed to read from tailscaled: %w", err)
|
||||||
case err := <-cfgWatchErrChan:
|
case err := <-cfgWatchErrChan:
|
||||||
return fmt.Errorf("failed to watch tailscaled config: %w", err)
|
return fmt.Errorf("failed to watch tailscaled config: %w", err)
|
||||||
|
case <-peerPoll.C:
|
||||||
|
processNetmap = true
|
||||||
case n := <-notifyChan:
|
case n := <-notifyChan:
|
||||||
// TODO: (ChaosInTheCRD) Add node removed check when supported by ipn
|
// TODO: (ChaosInTheCRD) Add node removed check when supported by ipn
|
||||||
if n.State != nil && *n.State != ipn.Running {
|
if n.State != nil && *n.State != ipn.Running {
|
||||||
@@ -576,8 +586,43 @@ runLoop:
|
|||||||
// whereupon we'll go through initial auth again.
|
// whereupon we'll go through initial auth again.
|
||||||
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", *n.State)
|
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", *n.State)
|
||||||
}
|
}
|
||||||
if n.NetMap != nil {
|
if n.SelfChange != nil {
|
||||||
addrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
processNetmap = true
|
||||||
|
}
|
||||||
|
case <-tc:
|
||||||
|
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
|
||||||
|
resetTimer(true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
|
||||||
|
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
|
||||||
|
}))
|
||||||
|
if backendsHaveChanged && len(addrs) != 0 {
|
||||||
|
log.Printf("Backend address change detected, installing proxy rules for backends %v", newBackendAddrs)
|
||||||
|
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
|
||||||
|
return fmt.Errorf("installing ingress proxy rules for DNS target %s: %v", cfg.ProxyTargetDNSName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
nm, err := fetchNetMap(ctx, client)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error fetching netmap: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nm != nil {
|
||||||
|
addrs = nm.SelfNode.Addresses().AsSlice()
|
||||||
newCurrentIPs := deephash.Hash(&addrs)
|
newCurrentIPs := deephash.Hash(&addrs)
|
||||||
ipsHaveChanged := newCurrentIPs != currentIPs
|
ipsHaveChanged := newCurrentIPs != currentIPs
|
||||||
|
|
||||||
@@ -589,14 +634,14 @@ runLoop:
|
|||||||
// Kubernetes Secret to clean up tailnet nodes
|
// Kubernetes Secret to clean up tailnet nodes
|
||||||
// for proxies whose route setup continuously
|
// for proxies whose route setup continuously
|
||||||
// fails.
|
// fails.
|
||||||
deviceID := n.NetMap.SelfNode.StableID()
|
deviceID := nm.SelfNode.StableID()
|
||||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceID, &deviceID) {
|
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceID, &deviceID) {
|
||||||
if err := kc.storeDeviceID(ctx, n.NetMap.SelfNode.StableID()); err != nil {
|
if err := kc.storeDeviceID(ctx, nm.SelfNode.StableID()); err != nil {
|
||||||
return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err)
|
return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.TailnetTargetFQDN != "" {
|
if cfg.TailnetTargetFQDN != "" {
|
||||||
egressAddrs, err := resolveTailnetFQDN(n.NetMap, cfg.TailnetTargetFQDN)
|
egressAddrs, err := resolveTailnetFQDN(nm, cfg.TailnetTargetFQDN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err.Error())
|
log.Print(err.Error())
|
||||||
break
|
break
|
||||||
@@ -652,7 +697,7 @@ runLoop:
|
|||||||
backendAddrs = newBackendAddrs
|
backendAddrs = newBackendAddrs
|
||||||
}
|
}
|
||||||
if cfg.ServeConfigPath != "" {
|
if cfg.ServeConfigPath != "" {
|
||||||
cd := certDomainFromNetmap(n.NetMap)
|
cd := certDomainFromNetmap(nm)
|
||||||
if cd == "" {
|
if cd == "" {
|
||||||
cd = kubetypes.ValueNoHTTPS
|
cd = kubetypes.ValueNoHTTPS
|
||||||
}
|
}
|
||||||
@@ -695,9 +740,9 @@ runLoop:
|
|||||||
// set up ensures that the operator does not
|
// set up ensures that the operator does not
|
||||||
// advertize endpoints of broken proxies.
|
// 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'.
|
// 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()}
|
deviceEndpoints := []any{nm.SelfNode.Name(), nm.SelfNode.Addresses()}
|
||||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceEndpoints, &deviceEndpoints) {
|
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceEndpoints, &deviceEndpoints) {
|
||||||
if err := kc.storeDeviceEndpoints(ctx, n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
|
if err := kc.storeDeviceEndpoints(ctx, nm.SelfNode.Name(), nm.SelfNode.Addresses().AsSlice()); err != nil {
|
||||||
return fmt.Errorf("storing device IPs and FQDN in Kubernetes Secret: %w", err)
|
return fmt.Errorf("storing device IPs and FQDN in Kubernetes Secret: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -726,7 +771,7 @@ runLoop:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if egressSvcsNotify != nil {
|
if egressSvcsNotify != nil {
|
||||||
egressSvcsNotify <- n
|
egressSvcsNotify <- nm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !startupTasksDone {
|
if !startupTasksDone {
|
||||||
@@ -748,7 +793,7 @@ runLoop:
|
|||||||
// will crash this node.
|
// will crash this node.
|
||||||
if cfg.EgressProxiesCfgPath != "" {
|
if cfg.EgressProxiesCfgPath != "" {
|
||||||
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath)
|
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath)
|
||||||
egressSvcsNotify = make(chan ipn.Notify)
|
egressSvcsNotify = make(chan *netmap.NetworkMap)
|
||||||
opts := egressProxyRunOpts{
|
opts := egressProxyRunOpts{
|
||||||
cfgPath: cfg.EgressProxiesCfgPath,
|
cfgPath: cfg.EgressProxiesCfgPath,
|
||||||
nfr: nfr,
|
nfr: nfr,
|
||||||
@@ -760,7 +805,7 @@ runLoop:
|
|||||||
tailnetAddrs: addrs,
|
tailnetAddrs: addrs,
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
if err := ep.run(ctx, n, opts); err != nil {
|
if err := ep.run(ctx, nm, opts); err != nil {
|
||||||
egressSvcsErrorChan <- err
|
egressSvcsErrorChan <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -806,29 +851,6 @@ runLoop:
|
|||||||
go reaper()
|
go reaper()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case <-tc:
|
|
||||||
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
|
|
||||||
resetTimer(true)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
|
|
||||||
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
|
|
||||||
}))
|
|
||||||
if backendsHaveChanged && len(addrs) != 0 {
|
|
||||||
log.Printf("Backend address change detected, installing proxy rules for backends %v", newBackendAddrs)
|
|
||||||
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
|
|
||||||
return fmt.Errorf("installing ingress proxy rules for DNS target %s: %v", cfg.ProxyTargetDNSName, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
backendAddrs = newBackendAddrs
|
|
||||||
resetTimer(false)
|
|
||||||
case e := <-egressSvcsErrorChan:
|
|
||||||
return fmt.Errorf("egress proxy failed: %v", e)
|
|
||||||
case e := <-ingressSvcsErrorChan:
|
|
||||||
return fmt.Errorf("ingress proxy failed: %v", e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
@@ -963,6 +985,15 @@ func runHTTPServer(mux *http.ServeMux, addr string) (close func() error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchNetMap fetches the current netmap from tailscaled via the
|
||||||
|
// "current-netmap" localapi debug action. The debug action's payload
|
||||||
|
// shape is intentionally not part of any stable API; containerboot
|
||||||
|
// reads its own internal-package types out of it. New external consumers
|
||||||
|
// should not rely on this — see [local.Client.Status] and friends.
|
||||||
|
func fetchNetMap(ctx context.Context, lc *local.Client) (*netmap.NetworkMap, error) {
|
||||||
|
return local.GetDebugResultJSON[*netmap.NetworkMap](ctx, lc, "current-netmap")
|
||||||
|
}
|
||||||
|
|
||||||
// resolveTailnetFQDN resolves a tailnet FQDN to a list of IP prefixes, which
|
// resolveTailnetFQDN resolves a tailnet FQDN to a list of IP prefixes, which
|
||||||
// can be either a peer device or a Tailscale Service.
|
// can be either a peer device or a Tailscale Service.
|
||||||
func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, error) {
|
func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, error) {
|
||||||
@@ -1024,11 +1055,3 @@ func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Pref
|
|||||||
|
|
||||||
return prefixes
|
return prefixes
|
||||||
}
|
}
|
||||||
|
|
||||||
func authkeyFromTailscaledConfig(path string) string {
|
|
||||||
if cfg, err := conffile.Load(path); err == nil && cfg.Parsed.AuthKey != nil {
|
|
||||||
return *cfg.Parsed.AuthKey
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
|
"tailscale.com/cmd/testwrapper/flakytest"
|
||||||
"tailscale.com/health"
|
"tailscale.com/health"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/kube/egressservices"
|
"tailscale.com/kube/egressservices"
|
||||||
@@ -45,6 +46,7 @@ import (
|
|||||||
const configFileAuthKey = "some-auth-key"
|
const configFileAuthKey = "some-auth-key"
|
||||||
|
|
||||||
func TestContainerBoot(t *testing.T) {
|
func TestContainerBoot(t *testing.T) {
|
||||||
|
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/19380")
|
||||||
boot := filepath.Join(t.TempDir(), "containerboot")
|
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 {
|
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)
|
t.Fatalf("Building containerboot: %v", err)
|
||||||
@@ -69,6 +71,12 @@ func TestContainerBoot(t *testing.T) {
|
|||||||
// Waits below to be true before proceeding to the next phase.
|
// Waits below to be true before proceeding to the next phase.
|
||||||
Notify *ipn.Notify
|
Notify *ipn.Notify
|
||||||
|
|
||||||
|
// If non-nil, install this NetMap on the fake LocalAPI before
|
||||||
|
// sending Notify. This is the replacement for the old
|
||||||
|
// Notify.NetMap field; reactive consumers fetch the current
|
||||||
|
// netmap via /localapi/v0/netmap on their own.
|
||||||
|
NetMap *netmap.NetworkMap
|
||||||
|
|
||||||
// WantCmds is the commands that containerboot should run in this phase.
|
// WantCmds is the commands that containerboot should run in this phase.
|
||||||
WantCmds []string
|
WantCmds []string
|
||||||
|
|
||||||
@@ -103,12 +111,10 @@ func TestContainerBoot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
runningNotify := &ipn.Notify{
|
runningNotify := &ipn.Notify{
|
||||||
State: new(ipn.Running),
|
State: new(ipn.Running),
|
||||||
NetMap: &netmap.NetworkMap{
|
SelfChange: &tailcfg.Node{
|
||||||
SelfNode: (&tailcfg.Node{
|
|
||||||
StableID: tailcfg.StableNodeID("myID"),
|
StableID: tailcfg.StableNodeID("myID"),
|
||||||
Name: "test-node.test.ts.net.",
|
Name: "test-node.test.ts.net.",
|
||||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||||
}).View(),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
type testCase struct {
|
type testCase struct {
|
||||||
@@ -381,6 +387,12 @@ func TestContainerBoot(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Notify: &ipn.Notify{
|
Notify: &ipn.Notify{
|
||||||
State: new(ipn.Running),
|
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{
|
NetMap: &netmap.NetworkMap{
|
||||||
SelfNode: (&tailcfg.Node{
|
SelfNode: (&tailcfg.Node{
|
||||||
StableID: tailcfg.StableNodeID("myID"),
|
StableID: tailcfg.StableNodeID("myID"),
|
||||||
@@ -395,7 +407,6 @@ func TestContainerBoot(t *testing.T) {
|
|||||||
}).View(),
|
}).View(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
WantLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
|
WantLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
|
||||||
WantExitCode: new(1),
|
WantExitCode: new(1),
|
||||||
},
|
},
|
||||||
@@ -629,6 +640,12 @@ func TestContainerBoot(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Notify: &ipn.Notify{
|
Notify: &ipn.Notify{
|
||||||
State: new(ipn.Running),
|
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{
|
NetMap: &netmap.NetworkMap{
|
||||||
SelfNode: (&tailcfg.Node{
|
SelfNode: (&tailcfg.Node{
|
||||||
StableID: tailcfg.StableNodeID("newID"),
|
StableID: tailcfg.StableNodeID("newID"),
|
||||||
@@ -636,7 +653,6 @@ func TestContainerBoot(t *testing.T) {
|
|||||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||||
}).View(),
|
}).View(),
|
||||||
},
|
},
|
||||||
},
|
|
||||||
WantKubeSecret: map[string]string{
|
WantKubeSecret: map[string]string{
|
||||||
"authkey": "tskey-key",
|
"authkey": "tskey-key",
|
||||||
"device_fqdn": "new-name.test.ts.net.",
|
"device_fqdn": "new-name.test.ts.net.",
|
||||||
@@ -1093,6 +1109,12 @@ func TestContainerBoot(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Notify: &ipn.Notify{
|
Notify: &ipn.Notify{
|
||||||
State: new(ipn.Running),
|
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{
|
NetMap: &netmap.NetworkMap{
|
||||||
SelfNode: (&tailcfg.Node{
|
SelfNode: (&tailcfg.Node{
|
||||||
StableID: tailcfg.StableNodeID("myID"),
|
StableID: tailcfg.StableNodeID("myID"),
|
||||||
@@ -1107,7 +1129,6 @@ func TestContainerBoot(t *testing.T) {
|
|||||||
}).View(),
|
}).View(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
WantKubeSecret: map[string]string{
|
WantKubeSecret: map[string]string{
|
||||||
"egress-services": string(mustJSON(t, egressStatus)),
|
"egress-services": string(mustJSON(t, egressStatus)),
|
||||||
"authkey": "tskey-key",
|
"authkey": "tskey-key",
|
||||||
@@ -1274,6 +1295,18 @@ func TestContainerBoot(t *testing.T) {
|
|||||||
t.Fatalf("phase %d: updating mtime for %q: %v", i, path, err)
|
t.Fatalf("phase %d: updating mtime for %q: %v", i, path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
nmForFake := p.NetMap
|
||||||
|
if nmForFake == nil && p.Notify != nil && p.Notify.SelfChange != nil {
|
||||||
|
// Synthesize a minimal netmap from SelfChange so
|
||||||
|
// containerboot's NetMap() fetch returns
|
||||||
|
// something usable when the test only set Notify.
|
||||||
|
nmForFake = &netmap.NetworkMap{
|
||||||
|
SelfNode: p.Notify.SelfChange.View(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nmForFake != nil {
|
||||||
|
env.lapi.SetNetMap(nmForFake)
|
||||||
|
}
|
||||||
env.lapi.Notify(p.Notify)
|
env.lapi.Notify(p.Notify)
|
||||||
if p.Signal != nil {
|
if p.Signal != nil {
|
||||||
cmd.Process.Signal(*p.Signal)
|
cmd.Process.Signal(*p.Signal)
|
||||||
@@ -1466,6 +1499,7 @@ type localAPI struct {
|
|||||||
sync.Mutex
|
sync.Mutex
|
||||||
cond *sync.Cond
|
cond *sync.Cond
|
||||||
notify *ipn.Notify
|
notify *ipn.Notify
|
||||||
|
netmap *netmap.NetworkMap // served by /localapi/v0/netmap
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lc *localAPI) Start() error {
|
func (lc *localAPI) Start() error {
|
||||||
@@ -1502,8 +1536,44 @@ func (lc *localAPI) Notify(n *ipn.Notify) {
|
|||||||
lc.cond.Broadcast()
|
lc.cond.Broadcast()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNetMap installs the netmap that the fake /localapi/v0/netmap endpoint
|
||||||
|
// will return.
|
||||||
|
func (lc *localAPI) SetNetMap(nm *netmap.NetworkMap) {
|
||||||
|
lc.Lock()
|
||||||
|
defer lc.Unlock()
|
||||||
|
lc.netmap = nm
|
||||||
|
}
|
||||||
|
|
||||||
func (lc *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (lc *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
|
case "/localapi/v0/netmap":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
lc.Lock()
|
||||||
|
nm := lc.netmap
|
||||||
|
lc.Unlock()
|
||||||
|
if nm == nil {
|
||||||
|
http.Error(w, "no netmap", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(nm)
|
||||||
|
return
|
||||||
|
case "/localapi/v0/debug":
|
||||||
|
// containerboot fetches the netmap via the "current-netmap"
|
||||||
|
// debug action; serve it like /localapi/v0/netmap above.
|
||||||
|
if r.URL.Query().Get("action") != "current-netmap" {
|
||||||
|
http.Error(w, "unsupported debug action", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
lc.Lock()
|
||||||
|
nm := lc.netmap
|
||||||
|
lc.Unlock()
|
||||||
|
if nm == nil {
|
||||||
|
http.Error(w, "no netmap", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(nm)
|
||||||
|
return
|
||||||
case "/localapi/v0/serve-config":
|
case "/localapi/v0/serve-config":
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case "GET":
|
case "GET":
|
||||||
|
|||||||
@@ -41,8 +41,28 @@ func (b *bitbucketResponseWriter) Write(p []byte) (int, error) { return len(p),
|
|||||||
|
|
||||||
func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {}
|
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 {
|
func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP {
|
||||||
t.Helper()
|
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)
|
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
handleBootstrapDNS(w, req)
|
handleBootstrapDNS(w, req)
|
||||||
@@ -100,7 +120,8 @@ func TestUnpublishedDNS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetMetrics() {
|
func resetMetrics(tb testing.TB) {
|
||||||
|
tstest.AssertNotParallel(tb)
|
||||||
publishedDNSHits.Set(0)
|
publishedDNSHits.Set(0)
|
||||||
publishedDNSMisses.Set(0)
|
publishedDNSMisses.Set(0)
|
||||||
unpublishedDNSHits.Set(0)
|
unpublishedDNSHits.Set(0)
|
||||||
@@ -114,8 +135,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
|||||||
pub := &dnsEntryMap{
|
pub := &dnsEntryMap{
|
||||||
IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}},
|
IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}},
|
||||||
}
|
}
|
||||||
dnsCache.Store(pub)
|
setDNSCache(t, pub)
|
||||||
dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`))
|
|
||||||
|
|
||||||
unpublishedDNSCache.Store(&dnsEntryMap{
|
unpublishedDNSCache.Store(&dnsEntryMap{
|
||||||
IPs: map[string][]net.IP{
|
IPs: map[string][]net.IP{
|
||||||
@@ -131,7 +151,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
|||||||
t.Run("CacheMiss", func(t *testing.T) {
|
t.Run("CacheMiss", func(t *testing.T) {
|
||||||
// One domain in map but empty, one not in map at all
|
// One domain in map but empty, one not in map at all
|
||||||
for _, q := range []string{"log.tailscale.com", "login.tailscale.com"} {
|
for _, q := range []string{"log.tailscale.com", "login.tailscale.com"} {
|
||||||
resetMetrics()
|
resetMetrics(t)
|
||||||
ips := getBootstrapDNS(t, q)
|
ips := getBootstrapDNS(t, q)
|
||||||
|
|
||||||
// Expected our public map to be returned on a cache miss
|
// Expected our public map to be returned on a cache miss
|
||||||
@@ -149,7 +169,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
|||||||
|
|
||||||
// Verify that we do get a valid response and metric.
|
// Verify that we do get a valid response and metric.
|
||||||
t.Run("CacheHit", func(t *testing.T) {
|
t.Run("CacheHit", func(t *testing.T) {
|
||||||
resetMetrics()
|
resetMetrics(t)
|
||||||
ips := getBootstrapDNS(t, "controlplane.tailscale.com")
|
ips := getBootstrapDNS(t, "controlplane.tailscale.com")
|
||||||
want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
|
want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
|
||||||
if !reflect.DeepEqual(ips, want) {
|
if !reflect.DeepEqual(ips, want) {
|
||||||
@@ -166,8 +186,10 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLookupMetric(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"}
|
d := []string{"a.io", "b.io", "c.io", "d.io", "e.io", "e.io", "e.io", "a.io"}
|
||||||
resetMetrics()
|
resetMetrics(t)
|
||||||
for _, q := range d {
|
for _, q := range d {
|
||||||
_ = getBootstrapDNS(t, q)
|
_ = getBootstrapDNS(t, q)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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/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/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/jsontext from github.com/go-json-experiment/json+
|
||||||
|
💣 github.com/go4org/hashtriemap from tailscale.com/derp/derpserver
|
||||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
||||||
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
|
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
|
||||||
@@ -310,7 +311,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
|||||||
hash from crypto+
|
hash from crypto+
|
||||||
hash/crc32 from compress/gzip+
|
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
|
hash/maphash from go4.org/mem+
|
||||||
html from net/http/pprof+
|
html from net/http/pprof+
|
||||||
html/template from tailscale.com/cmd/derper+
|
html/template from tailscale.com/cmd/derper+
|
||||||
internal/abi from crypto/x509/internal/macos+
|
internal/abi from crypto/x509/internal/macos+
|
||||||
|
|||||||
+27
-8
@@ -87,8 +87,7 @@ var (
|
|||||||
acceptConnLimit = flag.Float64("accept-connection-limit", math.Inf(+1), "rate limit for accepting new connection")
|
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")
|
acceptConnBurst = flag.Int("accept-connection-burst", math.MaxInt, "burst limit for accepting new connection")
|
||||||
|
|
||||||
perClientRateLimit = flag.Uint("per-client-rate-limit", 0, "per-client receive rate limit in bytes/sec; 0 means unlimited. Mesh peers are exempt.")
|
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.")
|
||||||
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 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")
|
tcpKeepAlive = flag.Duration("tcp-keepalive-time", 10*time.Minute, "TCP keepalive time")
|
||||||
@@ -195,12 +194,11 @@ func main() {
|
|||||||
s.SetVerifyClientURL(*verifyClientURL)
|
s.SetVerifyClientURL(*verifyClientURL)
|
||||||
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
|
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
|
||||||
s.SetTCPWriteTimeout(*tcpWriteTimeout)
|
s.SetTCPWriteTimeout(*tcpWriteTimeout)
|
||||||
if *perClientRateLimit > 0 {
|
if *rateConfigPath != "" {
|
||||||
burst := *perClientRateBurst
|
if err := s.LoadAndApplyRateConfig(*rateConfigPath); err != nil {
|
||||||
if burst < 1 {
|
log.Fatalf("derper: loading rate config: %v", err)
|
||||||
burst = *perClientRateLimit * 2
|
|
||||||
}
|
}
|
||||||
s.SetPerClientRateLimit(*perClientRateLimit, burst)
|
go watchRateConfig(ctx, s, *rateConfigPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
var meshKey string
|
var meshKey string
|
||||||
@@ -254,7 +252,7 @@ func main() {
|
|||||||
if err := startMesh(s); err != nil {
|
if err := startMesh(s); err != nil {
|
||||||
log.Fatalf("startMesh: %v", err)
|
log.Fatalf("startMesh: %v", err)
|
||||||
}
|
}
|
||||||
expvar.Publish("derp", s.ExpVar())
|
expvar.Publish("derp", s.ExpVar(*rateConfigPath != ""))
|
||||||
|
|
||||||
handleHome, ok := getHomeHandler(*flagHome)
|
handleHome, ok := getHomeHandler(*flagHome)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -436,6 +434,27 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// watchRateConfig listens for SIGHUP signals and reloads the rate config
|
||||||
|
// file on each signal, applying it to the server. It returns when ctx is done.
|
||||||
|
func watchRateConfig(ctx context.Context, s *derpserver.Server, path string) {
|
||||||
|
sighup := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sighup, syscall.SIGHUP)
|
||||||
|
defer signal.Stop(sighup)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-sighup:
|
||||||
|
log.Printf("derper: received SIGHUP, reloading rate config from %s", path)
|
||||||
|
if err := s.LoadAndApplyRateConfig(path); err != nil {
|
||||||
|
log.Printf("derper: rate config reload failed: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("derper: rate config reloaded successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
|
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
|
||||||
|
|
||||||
func prodAutocertHostPolicy(_ context.Context, host string) error {
|
func prodAutocertHostPolicy(_ context.Context, host string) error {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import (
|
|||||||
"github.com/tailscale/hujson"
|
"github.com/tailscale/hujson"
|
||||||
"golang.org/x/oauth2/clientcredentials"
|
"golang.org/x/oauth2/clientcredentials"
|
||||||
tsclient "tailscale.com/client/tailscale"
|
tsclient "tailscale.com/client/tailscale"
|
||||||
_ "tailscale.com/feature/condregister/identityfederation"
|
_ "tailscale.com/feature/identityfederation"
|
||||||
"tailscale.com/internal/client/tailscale"
|
"tailscale.com/internal/client/tailscale"
|
||||||
"tailscale.com/util/httpm"
|
"tailscale.com/util/httpm"
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-201
@@ -5,212 +5,16 @@
|
|||||||
package main // import "tailscale.com/cmd/hello"
|
package main // import "tailscale.com/cmd/hello"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
_ "embed"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
|
||||||
"html/template"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"tailscale.com/client/local"
|
"tailscale.com/cmd/hello/helloserver"
|
||||||
"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() {
|
func main() {
|
||||||
flag.Parse()
|
s := &helloserver.Server{
|
||||||
if *testIP != "" {
|
HTTPAddr: ":80",
|
||||||
res, err := localClient.WhoIs(context.Background(), *testIP)
|
HTTPSAddr: ":443",
|
||||||
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.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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,438 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
|
||||||
<title>Hello from Tailscale</title>
|
|
||||||
<style>
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
font-size: 100%;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
main {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
::before,
|
|
||||||
::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-width: 0;
|
|
||||||
border-style: solid;
|
|
||||||
border-color: #dad6d5;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5,
|
|
||||||
h6 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 24rem;
|
|
||||||
width: 95%;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.p-2 {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.p-4 {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.px-2 {
|
|
||||||
padding-left: 0.5rem;
|
|
||||||
padding-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pl-3 {
|
|
||||||
padding-left: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pr-3 {
|
|
||||||
padding-right: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pt-4 {
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mr-2 {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-1 {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-2 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-4 {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-6 {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-8 {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-12 {
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.width-full {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.min-width-0 {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rounded-lg {
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.relative {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justify-between {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items-center {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border {
|
|
||||||
border-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-t-1 {
|
|
||||||
border-top-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-gray-100 {
|
|
||||||
border-color: #f7f5f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-gray-200 {
|
|
||||||
border-color: #eeebea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-gray-300 {
|
|
||||||
border-color: #dad6d5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-white {
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-gray-0 {
|
|
||||||
background-color: #faf9f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-gray-100 {
|
|
||||||
background-color: #f7f5f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-green-600 {
|
|
||||||
color: #0d4b3b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-blue-600 {
|
|
||||||
color: #3f5db3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover\:text-blue-800:hover {
|
|
||||||
color: #253570;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-gray-600 {
|
|
||||||
color: #444342;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-gray-700 {
|
|
||||||
color: #2e2d2d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-gray-800 {
|
|
||||||
color: #232222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-sm {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-semibold {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-medium {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-regular {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.truncate {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overflow-hidden {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-pic {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background-size: cover;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate .panel {
|
|
||||||
transform: translateY(10%);
|
|
||||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.0), 0 10px 10px -5px rgba(0, 0, 0, 0.0);
|
|
||||||
transition: transform 1200ms ease, opacity 1200ms ease, box-shadow 1200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate .panel-interior {
|
|
||||||
opacity: 0.0;
|
|
||||||
transition: opacity 1200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate .logo {
|
|
||||||
transform: translateY(2rem);
|
|
||||||
opacity: 0.0;
|
|
||||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate .header-title {
|
|
||||||
transform: translateY(1.6rem);
|
|
||||||
opacity: 0.0;
|
|
||||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate .header-text {
|
|
||||||
transform: translateY(1.2rem);
|
|
||||||
opacity: 0.0;
|
|
||||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate .footer {
|
|
||||||
transform: translateY(-0.5rem);
|
|
||||||
opacity: 0.0;
|
|
||||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animating .panel {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1.0;
|
|
||||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.animating .panel-interior {
|
|
||||||
opacity: 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animating .spinner {
|
|
||||||
opacity: 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animating .logo,
|
|
||||||
.animating .header-title,
|
|
||||||
.animating .header-text,
|
|
||||||
.animating .footer {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: inline-flex;
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
align-items: center;
|
|
||||||
transition: opacity 200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner span {
|
|
||||||
display: inline-block;
|
|
||||||
background-color: currentColor;
|
|
||||||
border-radius: 9999px;
|
|
||||||
animation-name: loading-dots-blink;
|
|
||||||
animation-duration: 1.4s;
|
|
||||||
animation-iteration-count: infinite;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
width: 0.35em;
|
|
||||||
height: 0.35em;
|
|
||||||
margin: 0 0.15em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner span:nth-child(2) {
|
|
||||||
animation-delay: 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner span:nth-child(3) {
|
|
||||||
animation-delay: 400ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate .spinner {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes loading-dots-blink {
|
|
||||||
0% {
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion) {
|
|
||||||
* {
|
|
||||||
animation-duration: 0ms !important;
|
|
||||||
transition-duration: 0ms !important;
|
|
||||||
transition-delay: 0ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-100">
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
var lastSeen = localStorage.getItem("lastSeen");
|
|
||||||
if (!lastSeen) {
|
|
||||||
document.body.classList.add("animate");
|
|
||||||
window.addEventListener("load", function () {
|
|
||||||
setTimeout(function () {
|
|
||||||
document.body.classList.add("animating");
|
|
||||||
localStorage.setItem("lastSeen", Date.now());
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<main class="text-gray-800">
|
|
||||||
<svg class="logo mb-6" width="28" height="28" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle opacity="0.2" cx="3.4" cy="3.25" r="2.7" fill="currentColor" />
|
|
||||||
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor" />
|
|
||||||
<circle opacity="0.2" cx="3.4" cy="19.5" r="2.7" fill="currentColor" />
|
|
||||||
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor" />
|
|
||||||
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor" />
|
|
||||||
<circle opacity="0.2" cx="11.5" cy="3.25" r="2.7" fill="currentColor" />
|
|
||||||
<circle opacity="0.2" cx="19.5" cy="3.25" r="2.7" fill="currentColor" />
|
|
||||||
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor" />
|
|
||||||
<circle opacity="0.2" cx="19.5" cy="19.5" r="2.7" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
<header class="mb-8 text-center">
|
|
||||||
<h1 class="header-title font-title font-semibold mb-2">You're connected over Tailscale!</h1>
|
|
||||||
<p class="header-text">This device is signed in as…</p>
|
|
||||||
</header>
|
|
||||||
<div class="panel relative bg-white rounded-lg width-full shadow-xl mb-8 p-4">
|
|
||||||
<div class="spinner text-gray-600">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
<div class="panel-interior flex items-center width-full min-width-0 p-2 mb-4">
|
|
||||||
<div class="profile-pic bg-gray-100" style="background-image: url({{.ProfilePicURL}});"></div>
|
|
||||||
<div class="overflow-hidden">
|
|
||||||
{{ with .DisplayName }}
|
|
||||||
<h4 class="font-semibold truncate">{{.}}</h4>
|
|
||||||
{{ end }}
|
|
||||||
<h5 class="text-gray-600 truncate">{{.LoginName}}</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="panel-interior border border-gray-200 bg-gray-0 rounded-lg p-2 pl-3 pr-3 mb-2 width-full flex justify-between items-center">
|
|
||||||
<div class="flex items-center min-width-0">
|
|
||||||
<svg class="text-gray-600 mr-2" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
|
||||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
|
||||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
|
||||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
<h4 class="font-semibold truncate mr-2">{{.MachineName}}</h4>
|
|
||||||
</div>
|
|
||||||
<h5>{{.IP}}</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer class="footer text-gray-600 text-center mb-12">
|
|
||||||
<p>Read about <a href="https://tailscale.com/kb/1017/install#advanced-features" class="text-blue-600 hover:text-blue-800"
|
|
||||||
target="_blank">what you can do next →</a></p>
|
|
||||||
<p>Read about <a href="https://tailscale.com/kb/1073/hello" class="text-blue-600 hover:text-blue-800"
|
|
||||||
target="_blank">the Hello service →</a></p>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||||
|
<title>Hello from Tailscale</title>
|
||||||
|
<link type="text/css" rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/script.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100">
|
||||||
|
<main class="text-gray-800">
|
||||||
|
<svg class="logo mb-6" width="28" height="28" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle opacity="0.2" cx="3.4" cy="3.25" r="2.7" fill="currentColor" />
|
||||||
|
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor" />
|
||||||
|
<circle opacity="0.2" cx="3.4" cy="19.5" r="2.7" fill="currentColor" />
|
||||||
|
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor" />
|
||||||
|
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor" />
|
||||||
|
<circle opacity="0.2" cx="11.5" cy="3.25" r="2.7" fill="currentColor" />
|
||||||
|
<circle opacity="0.2" cx="19.5" cy="3.25" r="2.7" fill="currentColor" />
|
||||||
|
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor" />
|
||||||
|
<circle opacity="0.2" cx="19.5" cy="19.5" r="2.7" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<header class="mb-8 text-center">
|
||||||
|
<h1 class="header-title font-title font-semibold mb-2">You're connected over Tailscale!</h1>
|
||||||
|
<p class="header-text">This device is signed in as…</p>
|
||||||
|
</header>
|
||||||
|
<div class="panel relative bg-white rounded-lg width-full shadow-xl mb-8 p-4">
|
||||||
|
<div class="spinner text-gray-600">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-interior flex items-center width-full min-width-0 p-2 mb-4">
|
||||||
|
<div class="profile-pic bg-gray-100">
|
||||||
|
<img
|
||||||
|
src="{{.ProfilePicURL}}"
|
||||||
|
alt="Profile picture"
|
||||||
|
class="profile-pic-img"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
{{ with .DisplayName }}
|
||||||
|
<h4 class="font-semibold truncate">{{.}}</h4>
|
||||||
|
{{ end }}
|
||||||
|
<h5 class="text-gray-600 truncate">{{.LoginName}}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="panel-interior border border-gray-200 bg-gray-0 rounded-lg p-2 pl-3 pr-3 mb-2 width-full flex justify-between items-center">
|
||||||
|
<div class="flex items-center min-width-0">
|
||||||
|
<svg class="text-gray-600 mr-2" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||||
|
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||||
|
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
<h4 class="font-semibold truncate mr-2">{{.MachineName}}</h4>
|
||||||
|
</div>
|
||||||
|
<h5>{{.IP}}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="footer text-gray-600 text-center mb-12">
|
||||||
|
<p>Read about <a href="https://tailscale.com/kb/1017/install#advanced-features" class="text-blue-600 hover:text-blue-800"
|
||||||
|
target="_blank">what you can do next →</a></p>
|
||||||
|
<p>Read about <a href="https://tailscale.com/kb/1073/hello" class="text-blue-600 hover:text-blue-800"
|
||||||
|
target="_blank">the Hello service →</a></p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// Package helloserver implements the HTTP server behind hello.ts.net.
|
||||||
|
package helloserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tailscale.com/client/local"
|
||||||
|
"tailscale.com/client/tailscale/apitype"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed hello.tmpl.html
|
||||||
|
var embeddedTemplate string
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
var staticHandler = http.FileServerFS(staticFiles)
|
||||||
|
|
||||||
|
var tmpl = template.Must(template.New("home").Parse(embeddedTemplate))
|
||||||
|
|
||||||
|
// Server is an HTTP server for hello.ts.net.
|
||||||
|
//
|
||||||
|
// The zero value is not valid; populate at least one of HTTPAddr or HTTPSAddr
|
||||||
|
// before calling Run.
|
||||||
|
type Server struct {
|
||||||
|
// HTTPAddr is the address to run an HTTP server on, or empty for none.
|
||||||
|
HTTPAddr string
|
||||||
|
|
||||||
|
// HTTPSAddr is the address to run an HTTPS server on, or empty for none.
|
||||||
|
HTTPSAddr string
|
||||||
|
|
||||||
|
// LocalClient is used to look up the identity of incoming requests and
|
||||||
|
// to obtain TLS certificates. If nil, the zero value of local.Client is
|
||||||
|
// used.
|
||||||
|
LocalClient *local.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) localClient() *local.Client {
|
||||||
|
if s.LocalClient != nil {
|
||||||
|
return s.LocalClient
|
||||||
|
}
|
||||||
|
return &local.Client{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the configured HTTP and HTTPS servers and blocks until one of
|
||||||
|
// them returns an error.
|
||||||
|
func (s *Server) Run() error {
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
if s.HTTPAddr != "" {
|
||||||
|
log.Printf("running HTTP server on %s", s.HTTPAddr)
|
||||||
|
go func() {
|
||||||
|
errc <- http.ListenAndServe(s.HTTPAddr, s)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if s.HTTPSAddr != "" {
|
||||||
|
log.Printf("running HTTPS server on %s", s.HTTPSAddr)
|
||||||
|
go func() {
|
||||||
|
hs := &http.Server{
|
||||||
|
Addr: s.HTTPSAddr,
|
||||||
|
Handler: s,
|
||||||
|
TLSConfig: &tls.Config{
|
||||||
|
GetCertificate: s.localClient().GetCertificate,
|
||||||
|
},
|
||||||
|
IdleTimeout: 30 * time.Second,
|
||||||
|
ReadHeaderTimeout: 20 * time.Second,
|
||||||
|
MaxHeaderBytes: 10 << 10,
|
||||||
|
}
|
||||||
|
errc <- hs.ListenAndServeTLS("", "")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
return <-errc
|
||||||
|
}
|
||||||
|
|
||||||
|
type tmplData struct {
|
||||||
|
DisplayName string // "Foo Barberson"
|
||||||
|
LoginName string // "foo@bar.com"
|
||||||
|
ProfilePicURL string // "https://..."
|
||||||
|
MachineName string // "imac5k"
|
||||||
|
MachineOS string // "Linux"
|
||||||
|
IP string // "100.2.3.4"
|
||||||
|
}
|
||||||
|
|
||||||
|
func tailscaleIP(who *apitype.WhoIsResponse) string {
|
||||||
|
if who == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
vals, err := tailcfg.UnmarshalNodeCapJSON[string](who.Node.CapMap, tailcfg.NodeAttrNativeIPV4)
|
||||||
|
if err == nil && len(vals) > 0 {
|
||||||
|
return vals[0]
|
||||||
|
}
|
||||||
|
for _, nodeIP := range who.Node.Addresses {
|
||||||
|
if nodeIP.Addr().Is4() && nodeIP.IsSingleIP() {
|
||||||
|
return nodeIP.Addr().String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, nodeIP := range who.Node.Addresses {
|
||||||
|
if nodeIP.IsSingleIP() {
|
||||||
|
return nodeIP.Addr().String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler.
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.TLS == nil && s.HTTPSAddr != "" {
|
||||||
|
host := r.Host
|
||||||
|
if strings.Contains(r.Host, "100.101.102.103") {
|
||||||
|
host = "hello.ts.net"
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "https://"+host, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(r.RequestURI, "/static/") {
|
||||||
|
staticHandler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.RequestURI != "/" {
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
who, err := s.localClient().WhoIs(r.Context(), r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("whois(%q) error: %v", r.RemoteAddr, err)
|
||||||
|
http.Error(w, "Your Tailscale works, but we failed to look you up.", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := tmplData{
|
||||||
|
DisplayName: who.UserProfile.DisplayName,
|
||||||
|
LoginName: who.UserProfile.LoginName,
|
||||||
|
ProfilePicURL: who.UserProfile.ProfilePicURL,
|
||||||
|
MachineName: firstLabel(who.Node.ComputedName),
|
||||||
|
MachineOS: who.Node.Hostinfo.OS(),
|
||||||
|
IP: tailscaleIP(who),
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
tmpl.Execute(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstLabel returns s up until the first period, if any.
|
||||||
|
func firstLabel(s string) string {
|
||||||
|
s, _, _ = strings.Cut(s, ".")
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
(function () {
|
||||||
|
var lastSeen = localStorage.getItem("lastSeen");
|
||||||
|
if (!lastSeen) {
|
||||||
|
document.body.classList.add("animate");
|
||||||
|
window.addEventListener("load", function () {
|
||||||
|
setTimeout(function () {
|
||||||
|
document.body.classList.add("animating");
|
||||||
|
localStorage.setItem("lastSeen", Date.now());
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 100%;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
main {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-width: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #dad6d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 24rem;
|
||||||
|
width: 95%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-2 {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-2 {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-3 {
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-3 {
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-4 {
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-2 {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-1 {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-6 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-8 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.width-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-width-0 {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-lg {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relative {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-t-1 {
|
||||||
|
border-top-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-gray-100 {
|
||||||
|
border-color: #f7f5f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-gray-200 {
|
||||||
|
border-color: #eeebea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-gray-300 {
|
||||||
|
border-color: #dad6d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-white {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gray-0 {
|
||||||
|
background-color: #faf9f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gray-100 {
|
||||||
|
background-color: #f7f5f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-green-600 {
|
||||||
|
color: #0d4b3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-blue-600 {
|
||||||
|
color: #3f5db3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-blue-800:hover {
|
||||||
|
color: #253570;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-600 {
|
||||||
|
color: #444342;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-700 {
|
||||||
|
color: #2e2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-800 {
|
||||||
|
color: #232222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-semibold {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-medium {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-regular {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-hidden {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-pic {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
background-size: cover;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-pic-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate .panel {
|
||||||
|
transform: translateY(10%);
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.0), 0 10px 10px -5px rgba(0, 0, 0, 0.0);
|
||||||
|
transition: transform 1200ms ease, opacity 1200ms ease, box-shadow 1200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate .panel-interior {
|
||||||
|
opacity: 0.0;
|
||||||
|
transition: opacity 1200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate .logo {
|
||||||
|
transform: translateY(2rem);
|
||||||
|
opacity: 0.0;
|
||||||
|
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate .header-title {
|
||||||
|
transform: translateY(1.6rem);
|
||||||
|
opacity: 0.0;
|
||||||
|
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate .header-text {
|
||||||
|
transform: translateY(1.2rem);
|
||||||
|
opacity: 0.0;
|
||||||
|
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate .footer {
|
||||||
|
transform: translateY(-0.5rem);
|
||||||
|
opacity: 0.0;
|
||||||
|
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animating .panel {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1.0;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animating .panel-interior {
|
||||||
|
opacity: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animating .spinner {
|
||||||
|
opacity: 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animating .logo,
|
||||||
|
.animating .header-title,
|
||||||
|
.animating .header-text,
|
||||||
|
.animating .footer {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
align-items: center;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner span {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: currentColor;
|
||||||
|
border-radius: 9999px;
|
||||||
|
animation-name: loading-dots-blink;
|
||||||
|
animation-duration: 1.4s;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
width: 0.35em;
|
||||||
|
height: 0.35em;
|
||||||
|
margin: 0 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner span:nth-child(2) {
|
||||||
|
animation-delay: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner span:nth-child(3) {
|
||||||
|
animation-delay: 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate .spinner {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-dots-blink {
|
||||||
|
0% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
* {
|
||||||
|
animation-duration: 0ms !important;
|
||||||
|
transition-duration: 0ms !important;
|
||||||
|
transition-delay: 0ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,77 +6,6 @@ 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 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/internal/common from github.com/alexbrainman/sspi/negotiate
|
||||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
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/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
|
||||||
github.com/blang/semver/v4 from k8s.io/component-base/metrics
|
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/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus+
|
||||||
@@ -130,7 +59,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
github.com/google/gnostic-models/jsonschema from github.com/google/gnostic-models/compiler
|
github.com/google/gnostic-models/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/openapiv2 from k8s.io/client-go/discovery+
|
||||||
github.com/google/gnostic-models/openapiv3 from k8s.io/kube-openapi/pkg/handler3+
|
github.com/google/gnostic-models/openapiv3 from k8s.io/kube-openapi/pkg/handler3+
|
||||||
github.com/google/uuid from github.com/prometheus-community/pro-bing+
|
github.com/google/uuid from k8s.io/apimachinery/pkg/util/uuid+
|
||||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
||||||
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
|
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
|
||||||
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
||||||
@@ -164,7 +93,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal+
|
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal+
|
||||||
github.com/pkg/errors from github.com/evanphx/json-patch/v5+
|
github.com/pkg/errors from github.com/evanphx/json-patch/v5+
|
||||||
github.com/pmezard/go-difflib/difflib from k8s.io/apimachinery/pkg/util/diff
|
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 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/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+
|
💣 github.com/prometheus/client_golang/prometheus from github.com/prometheus/client_golang/prometheus/collectors+
|
||||||
@@ -180,7 +108,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
|
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
|
||||||
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
|
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
|
||||||
github.com/spf13/pflag from k8s.io/client-go/tools/clientcmd+
|
github.com/spf13/pflag from k8s.io/client-go/tools/clientcmd+
|
||||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||||
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
|
W 💣 github.com/tailscale/go-winio 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/fs from github.com/tailscale/go-winio
|
||||||
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
|
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
|
||||||
@@ -805,11 +733,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
|
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
|
||||||
tailscale.com/feature/c2n from tailscale.com/tsnet
|
tailscale.com/feature/c2n from tailscale.com/tsnet
|
||||||
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
|
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
|
||||||
tailscale.com/feature/condregister/identityfederation from tailscale.com/tsnet
|
|
||||||
tailscale.com/feature/condregister/oauthkey 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/portmapper from tailscale.com/tsnet
|
||||||
tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet
|
tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet
|
||||||
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
|
|
||||||
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
|
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
|
||||||
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
|
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
|
||||||
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
|
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
|
||||||
@@ -817,7 +743,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
tailscale.com/health from tailscale.com/control/controlclient+
|
tailscale.com/health from tailscale.com/control/controlclient+
|
||||||
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
||||||
tailscale.com/hostinfo from tailscale.com/client/web+
|
tailscale.com/hostinfo from tailscale.com/client/web+
|
||||||
tailscale.com/internal/client/tailscale from tailscale.com/feature/identityfederation+
|
tailscale.com/internal/client/tailscale from tailscale.com/feature/oauthkey+
|
||||||
tailscale.com/ipn from tailscale.com/client/local+
|
tailscale.com/ipn from tailscale.com/client/local+
|
||||||
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
|
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
|
||||||
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
|
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
|
||||||
@@ -910,7 +836,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
tailscale.com/tstime from tailscale.com/cmd/k8s-operator+
|
tailscale.com/tstime from tailscale.com/cmd/k8s-operator+
|
||||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
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/tsweb/varz from tailscale.com/util/usermetric+
|
||||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
||||||
tailscale.com/types/bools from tailscale.com/tsnet+
|
tailscale.com/types/bools from tailscale.com/tsnet+
|
||||||
@@ -1000,7 +926,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
tailscale.com/wgengine/wgcfg/nmcfg 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/wgint from tailscale.com/wgengine+
|
||||||
tailscale.com/wgengine/wglog 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/argon2 from tailscale.com/tka
|
||||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
||||||
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
||||||
@@ -1023,14 +948,15 @@ 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/http/httpproxy from tailscale.com/net/tshttpproxy
|
||||||
golang.org/x/net/http2 from k8s.io/apimachinery/pkg/util/net+
|
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/http2/hpack from golang.org/x/net/http2+
|
||||||
golang.org/x/net/icmp from github.com/prometheus-community/pro-bing+
|
golang.org/x/net/icmp from tailscale.com/net/ping
|
||||||
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
|
golang.org/x/net/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/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/iana from golang.org/x/net/icmp+
|
||||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
|
||||||
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
||||||
golang.org/x/net/ipv4 from github.com/prometheus-community/pro-bing+
|
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
|
||||||
golang.org/x/net/ipv6 from github.com/prometheus-community/pro-bing+
|
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
|
||||||
golang.org/x/net/proxy from tailscale.com/net/netns
|
golang.org/x/net/proxy from tailscale.com/net/netns
|
||||||
D golang.org/x/net/route from tailscale.com/net/netmon+
|
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/net/websocket from tailscale.com/k8s-operator/sessionrecording/ws
|
||||||
@@ -1137,7 +1063,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
crypto/sha3 from crypto/internal/fips140hash+
|
crypto/sha3 from crypto/internal/fips140hash+
|
||||||
crypto/sha512 from crypto/ecdsa+
|
crypto/sha512 from crypto/ecdsa+
|
||||||
crypto/subtle from crypto/cipher+
|
crypto/subtle from crypto/cipher+
|
||||||
crypto/tls from github.com/prometheus-community/pro-bing+
|
crypto/tls from github.com/prometheus/client_golang/prometheus/promhttp+
|
||||||
crypto/tls/internal/fips140tls from crypto/tls
|
crypto/tls/internal/fips140tls from crypto/tls
|
||||||
crypto/x509 from crypto/tls+
|
crypto/x509 from crypto/tls+
|
||||||
D crypto/x509/internal/macos from crypto/x509
|
D crypto/x509/internal/macos from crypto/x509
|
||||||
@@ -1246,7 +1172,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
mime/quotedprintable from mime/multipart
|
mime/quotedprintable from mime/multipart
|
||||||
net from crypto/tls+
|
net from crypto/tls+
|
||||||
net/http from expvar+
|
net/http from expvar+
|
||||||
net/http/httptrace from github.com/prometheus-community/pro-bing+
|
net/http/httptrace from github.com/prometheus/client_golang/prometheus/promhttp+
|
||||||
net/http/httputil from tailscale.com/client/web+
|
net/http/httputil from tailscale.com/client/web+
|
||||||
net/http/internal from net/http+
|
net/http/internal from net/http+
|
||||||
net/http/internal/ascii from net/http+
|
net/http/internal/ascii from net/http+
|
||||||
|
|||||||
@@ -146,3 +146,6 @@ spec:
|
|||||||
tolerations:
|
tolerations:
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- with .Values.operatorConfig.priorityClassName }}
|
||||||
|
priorityClassName: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ operatorConfig:
|
|||||||
|
|
||||||
affinity: {}
|
affinity: {}
|
||||||
|
|
||||||
|
priorityClassName: ""
|
||||||
|
|
||||||
podSecurityContext: {}
|
podSecurityContext: {}
|
||||||
|
|
||||||
securityContext: {}
|
securityContext: {}
|
||||||
|
|||||||
@@ -104,6 +104,884 @@ spec:
|
|||||||
description: Pod configuration.
|
description: Pod configuration.
|
||||||
type: object
|
type: object
|
||||||
properties:
|
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:
|
tolerations:
|
||||||
description: If specified, applies tolerations to the pods deployed by the DNSConfig resource.
|
description: If specified, applies tolerations to the pods deployed by the DNSConfig resource.
|
||||||
type: array
|
type: array
|
||||||
|
|||||||
@@ -442,6 +442,884 @@ spec:
|
|||||||
pod:
|
pod:
|
||||||
description: Pod configuration.
|
description: Pod configuration.
|
||||||
properties:
|
properties:
|
||||||
|
affinity:
|
||||||
|
description: If specified, applies affinity rules to the pods deployed by the DNSConfig resource.
|
||||||
|
properties:
|
||||||
|
nodeAffinity:
|
||||||
|
description: Describes node affinity scheduling rules for the pod.
|
||||||
|
properties:
|
||||||
|
preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
description: |-
|
||||||
|
The scheduler will prefer to schedule pods to nodes that satisfy
|
||||||
|
the affinity expressions specified by this field, but it may choose
|
||||||
|
a node that violates one or more of the expressions. The node that is
|
||||||
|
most preferred is the one with the greatest sum of weights, i.e.
|
||||||
|
for each node that meets all of the scheduling requirements (resource
|
||||||
|
request, requiredDuringScheduling affinity expressions, etc.),
|
||||||
|
compute a sum by iterating through the elements of this field and adding
|
||||||
|
"weight" to the sum if the node matches the corresponding matchExpressions; the
|
||||||
|
node(s) with the highest sum are the most preferred.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
An empty preferred scheduling term matches all objects with implicit weight 0
|
||||||
|
(i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).
|
||||||
|
properties:
|
||||||
|
preference:
|
||||||
|
description: A node selector term, associated with the corresponding weight.
|
||||||
|
properties:
|
||||||
|
matchExpressions:
|
||||||
|
description: A list of node selector requirements by node's labels.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A node selector requirement is a selector that contains values, a key, and an operator
|
||||||
|
that relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: The label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
Represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
An array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. If the operator is Gt or Lt, the values
|
||||||
|
array must have a single element, which will be interpreted as an integer.
|
||||||
|
This array is replaced during a strategic merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
matchFields:
|
||||||
|
description: A list of node selector requirements by node's fields.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A node selector requirement is a selector that contains values, a key, and an operator
|
||||||
|
that relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: The label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
Represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
An array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. If the operator is Gt or Lt, the values
|
||||||
|
array must have a single element, which will be interpreted as an integer.
|
||||||
|
This array is replaced during a strategic merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
type: object
|
||||||
|
x-kubernetes-map-type: atomic
|
||||||
|
weight:
|
||||||
|
description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- preference
|
||||||
|
- weight
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
description: |-
|
||||||
|
If the affinity requirements specified by this field are not met at
|
||||||
|
scheduling time, the pod will not be scheduled onto the node.
|
||||||
|
If the affinity requirements specified by this field cease to be met
|
||||||
|
at some point during pod execution (e.g. due to an update), the system
|
||||||
|
may or may not try to eventually evict the pod from its node.
|
||||||
|
properties:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
description: Required. A list of node selector terms. The terms are ORed.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A null or empty node selector term matches no objects. The requirements of
|
||||||
|
them are ANDed.
|
||||||
|
The TopologySelectorTerm type implements a subset of the NodeSelectorTerm.
|
||||||
|
properties:
|
||||||
|
matchExpressions:
|
||||||
|
description: A list of node selector requirements by node's labels.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A node selector requirement is a selector that contains values, a key, and an operator
|
||||||
|
that relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: The label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
Represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
An array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. If the operator is Gt or Lt, the values
|
||||||
|
array must have a single element, which will be interpreted as an integer.
|
||||||
|
This array is replaced during a strategic merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
matchFields:
|
||||||
|
description: A list of node selector requirements by node's fields.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A node selector requirement is a selector that contains values, a key, and an operator
|
||||||
|
that relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: The label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
Represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
An array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. If the operator is Gt or Lt, the values
|
||||||
|
array must have a single element, which will be interpreted as an integer.
|
||||||
|
This array is replaced during a strategic merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
type: object
|
||||||
|
x-kubernetes-map-type: atomic
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- nodeSelectorTerms
|
||||||
|
type: object
|
||||||
|
x-kubernetes-map-type: atomic
|
||||||
|
type: object
|
||||||
|
podAffinity:
|
||||||
|
description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).
|
||||||
|
properties:
|
||||||
|
preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
description: |-
|
||||||
|
The scheduler will prefer to schedule pods to nodes that satisfy
|
||||||
|
the affinity expressions specified by this field, but it may choose
|
||||||
|
a node that violates one or more of the expressions. The node that is
|
||||||
|
most preferred is the one with the greatest sum of weights, i.e.
|
||||||
|
for each node that meets all of the scheduling requirements (resource
|
||||||
|
request, requiredDuringScheduling affinity expressions, etc.),
|
||||||
|
compute a sum by iterating through the elements of this field and adding
|
||||||
|
"weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the
|
||||||
|
node(s) with the highest sum are the most preferred.
|
||||||
|
items:
|
||||||
|
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
|
||||||
|
properties:
|
||||||
|
podAffinityTerm:
|
||||||
|
description: Required. A pod affinity term, associated with the corresponding weight.
|
||||||
|
properties:
|
||||||
|
labelSelector:
|
||||||
|
description: |-
|
||||||
|
A label query over a set of resources, in this case pods.
|
||||||
|
If it's null, this PodAffinityTerm matches with no Pods.
|
||||||
|
properties:
|
||||||
|
matchExpressions:
|
||||||
|
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||||
|
relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: key is the label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
operator represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
values is an array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. This array is replaced during a strategic
|
||||||
|
merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
matchLabels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: |-
|
||||||
|
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||||
|
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||||
|
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
x-kubernetes-map-type: atomic
|
||||||
|
matchLabelKeys:
|
||||||
|
description: |-
|
||||||
|
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||||
|
be taken into consideration. The keys are used to lookup values from the
|
||||||
|
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||||
|
to select the group of existing pods which pods will be taken into consideration
|
||||||
|
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||||
|
pod labels will be ignored. The default value is empty.
|
||||||
|
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||||
|
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
mismatchLabelKeys:
|
||||||
|
description: |-
|
||||||
|
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||||
|
be taken into consideration. The keys are used to lookup values from the
|
||||||
|
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||||
|
to select the group of existing pods which pods will be taken into consideration
|
||||||
|
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||||
|
pod labels will be ignored. The default value is empty.
|
||||||
|
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||||
|
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
namespaceSelector:
|
||||||
|
description: |-
|
||||||
|
A label query over the set of namespaces that the term applies to.
|
||||||
|
The term is applied to the union of the namespaces selected by this field
|
||||||
|
and the ones listed in the namespaces field.
|
||||||
|
null selector and null or empty namespaces list means "this pod's namespace".
|
||||||
|
An empty selector ({}) matches all namespaces.
|
||||||
|
properties:
|
||||||
|
matchExpressions:
|
||||||
|
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||||
|
relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: key is the label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
operator represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
values is an array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. This array is replaced during a strategic
|
||||||
|
merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
matchLabels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: |-
|
||||||
|
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||||
|
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||||
|
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
x-kubernetes-map-type: atomic
|
||||||
|
namespaces:
|
||||||
|
description: |-
|
||||||
|
namespaces specifies a static list of namespace names that the term applies to.
|
||||||
|
The term is applied to the union of the namespaces listed in this field
|
||||||
|
and the ones selected by namespaceSelector.
|
||||||
|
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
topologyKey:
|
||||||
|
description: |-
|
||||||
|
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||||
|
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||||
|
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||||
|
selected pods is running.
|
||||||
|
Empty topologyKey is not allowed.
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- topologyKey
|
||||||
|
type: object
|
||||||
|
weight:
|
||||||
|
description: |-
|
||||||
|
weight associated with matching the corresponding podAffinityTerm,
|
||||||
|
in the range 1-100.
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- podAffinityTerm
|
||||||
|
- weight
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
description: |-
|
||||||
|
If the affinity requirements specified by this field are not met at
|
||||||
|
scheduling time, the pod will not be scheduled onto the node.
|
||||||
|
If the affinity requirements specified by this field cease to be met
|
||||||
|
at some point during pod execution (e.g. due to a pod label update), the
|
||||||
|
system may or may not try to eventually evict the pod from its node.
|
||||||
|
When there are multiple elements, the lists of nodes corresponding to each
|
||||||
|
podAffinityTerm are intersected, i.e. all terms must be satisfied.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
Defines a set of pods (namely those matching the labelSelector
|
||||||
|
relative to the given namespace(s)) that this pod should be
|
||||||
|
co-located (affinity) or not co-located (anti-affinity) with,
|
||||||
|
where co-located is defined as running on a node whose value of
|
||||||
|
the label with key <topologyKey> matches that of any node on which
|
||||||
|
a pod of the set of pods is running
|
||||||
|
properties:
|
||||||
|
labelSelector:
|
||||||
|
description: |-
|
||||||
|
A label query over a set of resources, in this case pods.
|
||||||
|
If it's null, this PodAffinityTerm matches with no Pods.
|
||||||
|
properties:
|
||||||
|
matchExpressions:
|
||||||
|
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||||
|
relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: key is the label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
operator represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
values is an array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. This array is replaced during a strategic
|
||||||
|
merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
matchLabels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: |-
|
||||||
|
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||||
|
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||||
|
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
x-kubernetes-map-type: atomic
|
||||||
|
matchLabelKeys:
|
||||||
|
description: |-
|
||||||
|
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||||
|
be taken into consideration. The keys are used to lookup values from the
|
||||||
|
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||||
|
to select the group of existing pods which pods will be taken into consideration
|
||||||
|
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||||
|
pod labels will be ignored. The default value is empty.
|
||||||
|
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||||
|
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
mismatchLabelKeys:
|
||||||
|
description: |-
|
||||||
|
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||||
|
be taken into consideration. The keys are used to lookup values from the
|
||||||
|
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||||
|
to select the group of existing pods which pods will be taken into consideration
|
||||||
|
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||||
|
pod labels will be ignored. The default value is empty.
|
||||||
|
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||||
|
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
namespaceSelector:
|
||||||
|
description: |-
|
||||||
|
A label query over the set of namespaces that the term applies to.
|
||||||
|
The term is applied to the union of the namespaces selected by this field
|
||||||
|
and the ones listed in the namespaces field.
|
||||||
|
null selector and null or empty namespaces list means "this pod's namespace".
|
||||||
|
An empty selector ({}) matches all namespaces.
|
||||||
|
properties:
|
||||||
|
matchExpressions:
|
||||||
|
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||||
|
relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: key is the label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
operator represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
values is an array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. This array is replaced during a strategic
|
||||||
|
merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
matchLabels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: |-
|
||||||
|
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||||
|
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||||
|
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
x-kubernetes-map-type: atomic
|
||||||
|
namespaces:
|
||||||
|
description: |-
|
||||||
|
namespaces specifies a static list of namespace names that the term applies to.
|
||||||
|
The term is applied to the union of the namespaces listed in this field
|
||||||
|
and the ones selected by namespaceSelector.
|
||||||
|
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
topologyKey:
|
||||||
|
description: |-
|
||||||
|
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||||
|
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||||
|
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||||
|
selected pods is running.
|
||||||
|
Empty topologyKey is not allowed.
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- topologyKey
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
type: object
|
||||||
|
podAntiAffinity:
|
||||||
|
description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).
|
||||||
|
properties:
|
||||||
|
preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
description: |-
|
||||||
|
The scheduler will prefer to schedule pods to nodes that satisfy
|
||||||
|
the anti-affinity expressions specified by this field, but it may choose
|
||||||
|
a node that violates one or more of the expressions. The node that is
|
||||||
|
most preferred is the one with the greatest sum of weights, i.e.
|
||||||
|
for each node that meets all of the scheduling requirements (resource
|
||||||
|
request, requiredDuringScheduling anti-affinity expressions, etc.),
|
||||||
|
compute a sum by iterating through the elements of this field and 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.
|
||||||
|
items:
|
||||||
|
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
|
||||||
|
properties:
|
||||||
|
podAffinityTerm:
|
||||||
|
description: Required. A pod affinity term, associated with the corresponding weight.
|
||||||
|
properties:
|
||||||
|
labelSelector:
|
||||||
|
description: |-
|
||||||
|
A label query over a set of resources, in this case pods.
|
||||||
|
If it's null, this PodAffinityTerm matches with no Pods.
|
||||||
|
properties:
|
||||||
|
matchExpressions:
|
||||||
|
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||||
|
relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: key is the label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
operator represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
values is an array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. This array is replaced during a strategic
|
||||||
|
merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
matchLabels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: |-
|
||||||
|
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||||
|
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||||
|
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
x-kubernetes-map-type: atomic
|
||||||
|
matchLabelKeys:
|
||||||
|
description: |-
|
||||||
|
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||||
|
be taken into consideration. The keys are used to lookup values from the
|
||||||
|
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||||
|
to select the group of existing pods which pods will be taken into consideration
|
||||||
|
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||||
|
pod labels will be ignored. The default value is empty.
|
||||||
|
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||||
|
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
mismatchLabelKeys:
|
||||||
|
description: |-
|
||||||
|
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||||
|
be taken into consideration. The keys are used to lookup values from the
|
||||||
|
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||||
|
to select the group of existing pods which pods will be taken into consideration
|
||||||
|
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||||
|
pod labels will be ignored. The default value is empty.
|
||||||
|
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||||
|
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
namespaceSelector:
|
||||||
|
description: |-
|
||||||
|
A label query over the set of namespaces that the term applies to.
|
||||||
|
The term is applied to the union of the namespaces selected by this field
|
||||||
|
and the ones listed in the namespaces field.
|
||||||
|
null selector and null or empty namespaces list means "this pod's namespace".
|
||||||
|
An empty selector ({}) matches all namespaces.
|
||||||
|
properties:
|
||||||
|
matchExpressions:
|
||||||
|
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||||
|
relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: key is the label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
operator represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
values is an array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. This array is replaced during a strategic
|
||||||
|
merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
matchLabels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: |-
|
||||||
|
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||||
|
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||||
|
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
x-kubernetes-map-type: atomic
|
||||||
|
namespaces:
|
||||||
|
description: |-
|
||||||
|
namespaces specifies a static list of namespace names that the term applies to.
|
||||||
|
The term is applied to the union of the namespaces listed in this field
|
||||||
|
and the ones selected by namespaceSelector.
|
||||||
|
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
topologyKey:
|
||||||
|
description: |-
|
||||||
|
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||||
|
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||||
|
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||||
|
selected pods is running.
|
||||||
|
Empty topologyKey is not allowed.
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- topologyKey
|
||||||
|
type: object
|
||||||
|
weight:
|
||||||
|
description: |-
|
||||||
|
weight associated with matching the corresponding podAffinityTerm,
|
||||||
|
in the range 1-100.
|
||||||
|
format: int32
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- podAffinityTerm
|
||||||
|
- weight
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
description: |-
|
||||||
|
If the anti-affinity requirements specified by this field are not met at
|
||||||
|
scheduling time, the pod will not be scheduled onto the node.
|
||||||
|
If the anti-affinity requirements specified by this field cease to be met
|
||||||
|
at some point during pod execution (e.g. due to a pod label update), the
|
||||||
|
system may or may not try to eventually evict the pod from its node.
|
||||||
|
When there are multiple elements, the lists of nodes corresponding to each
|
||||||
|
podAffinityTerm are intersected, i.e. all terms must be satisfied.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
Defines a set of pods (namely those matching the labelSelector
|
||||||
|
relative to the given namespace(s)) that this pod should be
|
||||||
|
co-located (affinity) or not co-located (anti-affinity) with,
|
||||||
|
where co-located is defined as running on a node whose value of
|
||||||
|
the label with key <topologyKey> matches that of any node on which
|
||||||
|
a pod of the set of pods is running
|
||||||
|
properties:
|
||||||
|
labelSelector:
|
||||||
|
description: |-
|
||||||
|
A label query over a set of resources, in this case pods.
|
||||||
|
If it's null, this PodAffinityTerm matches with no Pods.
|
||||||
|
properties:
|
||||||
|
matchExpressions:
|
||||||
|
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||||
|
relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: key is the label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
operator represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
values is an array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. This array is replaced during a strategic
|
||||||
|
merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
matchLabels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: |-
|
||||||
|
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||||
|
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||||
|
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
x-kubernetes-map-type: atomic
|
||||||
|
matchLabelKeys:
|
||||||
|
description: |-
|
||||||
|
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||||
|
be taken into consideration. The keys are used to lookup values from the
|
||||||
|
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||||
|
to select the group of existing pods which pods will be taken into consideration
|
||||||
|
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||||
|
pod labels will be ignored. The default value is empty.
|
||||||
|
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||||
|
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
mismatchLabelKeys:
|
||||||
|
description: |-
|
||||||
|
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||||
|
be taken into consideration. The keys are used to lookup values from the
|
||||||
|
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||||
|
to select the group of existing pods which pods will be taken into consideration
|
||||||
|
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||||
|
pod labels will be ignored. The default value is empty.
|
||||||
|
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||||
|
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
namespaceSelector:
|
||||||
|
description: |-
|
||||||
|
A label query over the set of namespaces that the term applies to.
|
||||||
|
The term is applied to the union of the namespaces selected by this field
|
||||||
|
and the ones listed in the namespaces field.
|
||||||
|
null selector and null or empty namespaces list means "this pod's namespace".
|
||||||
|
An empty selector ({}) matches all namespaces.
|
||||||
|
properties:
|
||||||
|
matchExpressions:
|
||||||
|
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||||
|
relates the key and values.
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
description: key is the label key that the selector applies to.
|
||||||
|
type: string
|
||||||
|
operator:
|
||||||
|
description: |-
|
||||||
|
operator represents a key's relationship to a set of values.
|
||||||
|
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||||
|
type: string
|
||||||
|
values:
|
||||||
|
description: |-
|
||||||
|
values is an array of string values. If the operator is In or NotIn,
|
||||||
|
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
the values array must be empty. This array is replaced during a strategic
|
||||||
|
merge patch.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- operator
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
matchLabels:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: |-
|
||||||
|
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||||
|
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||||
|
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
x-kubernetes-map-type: atomic
|
||||||
|
namespaces:
|
||||||
|
description: |-
|
||||||
|
namespaces specifies a static list of namespace names that the term applies to.
|
||||||
|
The term is applied to the union of the namespaces listed in this field
|
||||||
|
and the ones selected by namespaceSelector.
|
||||||
|
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
topologyKey:
|
||||||
|
description: |-
|
||||||
|
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||||
|
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||||
|
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||||
|
selected pods is running.
|
||||||
|
Empty topologyKey is not allowed.
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- topologyKey
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-type: atomic
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
nodeSelector:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
description: If specified, applies node selector rules to the pods deployed by the DNSConfig resource.
|
||||||
|
type: object
|
||||||
tolerations:
|
tolerations:
|
||||||
description: If specified, applies tolerations to the pods deployed by the DNSConfig resource.
|
description: If specified, applies tolerations to the pods deployed by the DNSConfig resource.
|
||||||
items:
|
items:
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import (
|
|||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
kube "tailscale.com/k8s-operator"
|
kube "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
|
"tailscale.com/tsnet"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
"tailscale.com/util/httpm"
|
"tailscale.com/util/httpm"
|
||||||
)
|
)
|
||||||
@@ -31,12 +33,12 @@ func TestL3Ingress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply nginx
|
// Apply nginx
|
||||||
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx"))
|
nginx := nginxDeployment(ns)
|
||||||
|
createAndCleanup(t, kubeClient, nginx)
|
||||||
// Apply service to expose it as ingress
|
// Apply service to expose it as ingress
|
||||||
name := generateName("test-ingress")
|
|
||||||
svc := &corev1.Service{
|
svc := &corev1.Service{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: name,
|
Name: generateName("test-ingress"),
|
||||||
Namespace: ns,
|
Namespace: ns,
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"tailscale.com/expose": "true",
|
"tailscale.com/expose": "true",
|
||||||
@@ -44,7 +46,7 @@ func TestL3Ingress(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
Selector: map[string]string{
|
Selector: map[string]string{
|
||||||
"app.kubernetes.io/name": "nginx",
|
"app.kubernetes.io/name": nginx.Name,
|
||||||
},
|
},
|
||||||
Ports: []corev1.ServicePort{
|
Ports: []corev1.ServicePort{
|
||||||
{
|
{
|
||||||
@@ -58,7 +60,7 @@ func TestL3Ingress(t *testing.T) {
|
|||||||
createAndCleanup(t, kubeClient, svc)
|
createAndCleanup(t, kubeClient, svc)
|
||||||
|
|
||||||
if err := tstest.WaitFor(time.Minute, func() error {
|
if err := tstest.WaitFor(time.Minute, func() error {
|
||||||
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, name)}
|
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, svc.Name)}
|
||||||
if err := get(t.Context(), kubeClient, maybeReadySvc); err != nil {
|
if err := get(t.Context(), kubeClient, maybeReadySvc); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -79,7 +81,7 @@ func TestL3Ingress(t *testing.T) {
|
|||||||
if err := kubeClient.List(t.Context(), &secrets,
|
if err := kubeClient.List(t.Context(), &secrets,
|
||||||
client.InNamespace("tailscale"),
|
client.InNamespace("tailscale"),
|
||||||
client.MatchingLabels{
|
client.MatchingLabels{
|
||||||
"tailscale.com/parent-resource": name,
|
"tailscale.com/parent-resource": svc.Name,
|
||||||
"tailscale.com/parent-resource-ns": ns,
|
"tailscale.com/parent-resource-ns": ns,
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -109,33 +111,34 @@ func TestL3HAIngress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply nginx.
|
// Apply nginx.
|
||||||
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx"))
|
nginx := nginxDeployment(ns)
|
||||||
|
createAndCleanup(t, kubeClient, nginx)
|
||||||
|
|
||||||
// Create an ingress ProxyGroup.
|
// Create an ingress ProxyGroup.
|
||||||
createAndCleanup(t, kubeClient, &tsapi.ProxyGroup{
|
pg := &tsapi.ProxyGroup{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "ingress",
|
Name: generateName("ingress"),
|
||||||
},
|
},
|
||||||
Spec: tsapi.ProxyGroupSpec{
|
Spec: tsapi.ProxyGroupSpec{
|
||||||
Type: tsapi.ProxyGroupTypeIngress,
|
Type: tsapi.ProxyGroupTypeIngress,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
createAndCleanup(t, kubeClient, pg)
|
||||||
|
|
||||||
// Apply a Service to expose nginx via the ProxyGroup.
|
// Apply a Service to expose nginx via the ProxyGroup.
|
||||||
name := generateName("test-ingress")
|
|
||||||
svc := &corev1.Service{
|
svc := &corev1.Service{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: name,
|
Name: generateName("test-ingress"),
|
||||||
Namespace: ns,
|
Namespace: ns,
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"tailscale.com/proxy-group": "ingress",
|
"tailscale.com/proxy-group": pg.Name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
Type: corev1.ServiceTypeLoadBalancer,
|
Type: corev1.ServiceTypeLoadBalancer,
|
||||||
LoadBalancerClass: new("tailscale"),
|
LoadBalancerClass: new("tailscale"),
|
||||||
Selector: map[string]string{
|
Selector: map[string]string{
|
||||||
"app.kubernetes.io/name": "nginx",
|
"app.kubernetes.io/name": nginx.Name,
|
||||||
},
|
},
|
||||||
Ports: []corev1.ServicePort{
|
Ports: []corev1.ServicePort{
|
||||||
{
|
{
|
||||||
@@ -150,12 +153,12 @@ func TestL3HAIngress(t *testing.T) {
|
|||||||
|
|
||||||
var svcIPv4 string
|
var svcIPv4 string
|
||||||
forceReconcile := triggerReconcile(t,
|
forceReconcile := triggerReconcile(t,
|
||||||
client.ObjectKey{Namespace: ns, Name: name},
|
client.ObjectKey{Namespace: ns, Name: svc.Name},
|
||||||
&corev1.Service{}, 30*time.Second)
|
&corev1.Service{}, 30*time.Second)
|
||||||
|
|
||||||
// Wait for Service to be ready
|
// Wait for Service to be ready
|
||||||
if err := tstest.WaitFor(5*time.Minute, func() error {
|
if err := tstest.WaitFor(5*time.Minute, func() error {
|
||||||
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, name)}
|
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, svc.Name)}
|
||||||
forceReconcile()
|
forceReconcile()
|
||||||
if err := get(t.Context(), kubeClient, maybeReadySvc); err != nil {
|
if err := get(t.Context(), kubeClient, maybeReadySvc); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -186,15 +189,16 @@ func TestL7Ingress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply nginx Deployment and Service.
|
// Apply nginx Deployment and Service.
|
||||||
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx"))
|
nginx := nginxDeployment(ns)
|
||||||
|
createAndCleanup(t, kubeClient, nginx)
|
||||||
createAndCleanup(t, kubeClient, &corev1.Service{
|
createAndCleanup(t, kubeClient, &corev1.Service{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "nginx",
|
Name: nginx.Name,
|
||||||
Namespace: ns,
|
Namespace: ns,
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
Selector: map[string]string{
|
Selector: map[string]string{
|
||||||
"app.kubernetes.io/name": "nginx",
|
"app.kubernetes.io/name": nginx.Name,
|
||||||
},
|
},
|
||||||
Ports: []corev1.ServicePort{
|
Ports: []corev1.ServicePort{
|
||||||
{
|
{
|
||||||
@@ -206,13 +210,12 @@ func TestL7Ingress(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Apply Ingress to expose nginx.
|
// Apply Ingress to expose nginx.
|
||||||
name := generateName("test-ingress")
|
ingress := l7Ingress(ns, nginx.Name, map[string]string{})
|
||||||
ingress := l7Ingress(ns, name, map[string]string{})
|
|
||||||
createAndCleanup(t, kubeClient, ingress)
|
createAndCleanup(t, kubeClient, ingress)
|
||||||
|
|
||||||
t.Log("Waiting for the Ingress to be ready...")
|
t.Log("Waiting for the Ingress to be ready...")
|
||||||
|
|
||||||
hostname, err := waitForIngressHostname(t, ns, name)
|
hostname, err := waitForIngressHostname(t, ns, ingress.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error waiting for Ingress hostname: %v", err)
|
t.Fatalf("error waiting for Ingress hostname: %v", err)
|
||||||
}
|
}
|
||||||
@@ -228,15 +231,16 @@ func TestL7HAIngress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply nginx Deployment and Service.
|
// Apply nginx Deployment and Service.
|
||||||
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx"))
|
nginx := nginxDeployment(ns)
|
||||||
|
createAndCleanup(t, kubeClient, nginx)
|
||||||
createAndCleanup(t, kubeClient, &corev1.Service{
|
createAndCleanup(t, kubeClient, &corev1.Service{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "nginx",
|
Name: nginx.Name,
|
||||||
Namespace: ns,
|
Namespace: ns,
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
Selector: map[string]string{
|
Selector: map[string]string{
|
||||||
"app.kubernetes.io/name": "nginx",
|
"app.kubernetes.io/name": nginx.Name,
|
||||||
},
|
},
|
||||||
Ports: []corev1.ServicePort{
|
Ports: []corev1.ServicePort{
|
||||||
{
|
{
|
||||||
@@ -248,23 +252,23 @@ func TestL7HAIngress(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Create ProxyGroup that the Ingress will reference.
|
// Create ProxyGroup that the Ingress will reference.
|
||||||
createAndCleanup(t, kubeClient, &tsapi.ProxyGroup{
|
pg := &tsapi.ProxyGroup{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "ingress",
|
Name: generateName("ingress"),
|
||||||
},
|
},
|
||||||
Spec: tsapi.ProxyGroupSpec{
|
Spec: tsapi.ProxyGroupSpec{
|
||||||
Type: tsapi.ProxyGroupTypeIngress,
|
Type: tsapi.ProxyGroupTypeIngress,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
createAndCleanup(t, kubeClient, pg)
|
||||||
|
|
||||||
// Apply Ingress to expose nginx.
|
// Apply Ingress to expose nginx.
|
||||||
name := generateName("test-ingress")
|
ingress := l7Ingress(ns, nginx.Name, map[string]string{"tailscale.com/proxy-group": pg.Name})
|
||||||
ingress := l7Ingress(ns, name, map[string]string{"tailscale.com/proxy-group": "ingress"})
|
|
||||||
createAndCleanup(t, kubeClient, ingress)
|
createAndCleanup(t, kubeClient, ingress)
|
||||||
|
|
||||||
t.Log("Waiting for the Ingress to be ready...")
|
t.Log("Waiting for the Ingress to be ready...")
|
||||||
|
|
||||||
hostname, err := waitForIngressHostname(t, ns, name)
|
hostname, err := waitForIngressHostname(t, ns, ingress.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error waiting for Ingress hostname: %v", err)
|
t.Fatalf("error waiting for Ingress hostname: %v", err)
|
||||||
}
|
}
|
||||||
@@ -274,7 +278,88 @@ func TestL7HAIngress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func l7Ingress(namespace, name string, annotations map[string]string) *networkingv1.Ingress {
|
func TestL7HAIngressMultiTailnet(t *testing.T) {
|
||||||
|
if tnClient == nil || secondTNClient == nil {
|
||||||
|
t.Skip("TestL7HAIngressMultiTailnet requires a working tailnet client for a first and second tailnet")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply nginx Deployment and Service.
|
||||||
|
nginx := nginxDeployment(ns)
|
||||||
|
createAndCleanup(t, kubeClient, nginx)
|
||||||
|
createAndCleanup(t, kubeClient, &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: nginx.Name,
|
||||||
|
Namespace: ns,
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{
|
||||||
|
"app.kubernetes.io/name": nginx.Name,
|
||||||
|
},
|
||||||
|
Ports: []corev1.ServicePort{
|
||||||
|
{
|
||||||
|
Name: "http",
|
||||||
|
Port: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create Ingress ProxyGroup for each Tailnet.
|
||||||
|
firstTailnetPG := &tsapi.ProxyGroup{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: generateName("first-tailnet"),
|
||||||
|
},
|
||||||
|
Spec: tsapi.ProxyGroupSpec{
|
||||||
|
Type: tsapi.ProxyGroupTypeIngress,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
createAndCleanup(t, kubeClient, firstTailnetPG)
|
||||||
|
secondTailnetPG := &tsapi.ProxyGroup{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: generateName("second-tailnet"),
|
||||||
|
},
|
||||||
|
Spec: tsapi.ProxyGroupSpec{
|
||||||
|
Type: tsapi.ProxyGroupTypeIngress,
|
||||||
|
Tailnet: "second-tailnet",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
createAndCleanup(t, kubeClient, secondTailnetPG)
|
||||||
|
|
||||||
|
if err := verifyProxyGroupTailnet(t, firstTailnetPG, tnClient); err != nil {
|
||||||
|
t.Fatalf("verifying ProxyGroup %s is registered to the correct tailnet: %v", firstTailnetPG.Name, err)
|
||||||
|
}
|
||||||
|
if err := verifyProxyGroupTailnet(t, secondTailnetPG, secondTNClient); err != nil {
|
||||||
|
t.Fatalf("verifying ProxyGroup %s is registered to the correct tailnet: %v", secondTailnetPG.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Ingress to expose nginx.
|
||||||
|
ingress := l7Ingress(ns, nginx.Name, map[string]string{
|
||||||
|
"tailscale.com/proxy-group": secondTailnetPG.Name,
|
||||||
|
})
|
||||||
|
createAndCleanup(t, kubeClient, ingress)
|
||||||
|
|
||||||
|
// Check that the tailscale (VIP) Service has been created in the expected Tailnet.
|
||||||
|
svcName := "svc:" + ingress.Name
|
||||||
|
if err := tstest.WaitFor(3*time.Minute, func() error {
|
||||||
|
_, err := secondTSClient.VIPServices().Get(t.Context(), svcName)
|
||||||
|
if tailscale.IsNotFound(err) {
|
||||||
|
return fmt.Errorf("Tailscale service %q not yet in expected tailnet", svcName)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Tailscale service %q never appeared in expected tailnet: %v", svcName, err)
|
||||||
|
}
|
||||||
|
hostname, err := waitForIngressHostname(t, ns, ingress.Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error waiting for Ingress hostname: %v", err)
|
||||||
|
}
|
||||||
|
if err := testIngressIsReachable(t, newHTTPClient(secondTNClient), fmt.Sprintf("https://%s:443", hostname)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func l7Ingress(namespace, svc string, annotations map[string]string) *networkingv1.Ingress {
|
||||||
|
name := generateName("test-ingress")
|
||||||
ingress := &networkingv1.Ingress{
|
ingress := &networkingv1.Ingress{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -296,7 +381,7 @@ func l7Ingress(namespace, name string, annotations map[string]string) *networkin
|
|||||||
PathType: new(networkingv1.PathTypePrefix),
|
PathType: new(networkingv1.PathTypePrefix),
|
||||||
Backend: networkingv1.IngressBackend{
|
Backend: networkingv1.IngressBackend{
|
||||||
Service: &networkingv1.IngressServiceBackend{
|
Service: &networkingv1.IngressServiceBackend{
|
||||||
Name: "nginx",
|
Name: svc,
|
||||||
Port: networkingv1.ServiceBackendPort{
|
Port: networkingv1.ServiceBackendPort{
|
||||||
Number: 80,
|
Number: 80,
|
||||||
},
|
},
|
||||||
@@ -313,26 +398,27 @@ func l7Ingress(namespace, name string, annotations map[string]string) *networkin
|
|||||||
return ingress
|
return ingress
|
||||||
}
|
}
|
||||||
|
|
||||||
func nginxDeployment(namespace, name string) *appsv1.Deployment {
|
func nginxDeployment(namespace string) *appsv1.Deployment {
|
||||||
|
name := generateName("nginx")
|
||||||
return &appsv1.Deployment{
|
return &appsv1.Deployment{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: name,
|
Name: name,
|
||||||
Namespace: namespace,
|
Namespace: namespace,
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
"app.kubernetes.io/name": "nginx",
|
"app.kubernetes.io/name": name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Spec: appsv1.DeploymentSpec{
|
Spec: appsv1.DeploymentSpec{
|
||||||
Replicas: new(int32(1)),
|
Replicas: new(int32(1)),
|
||||||
Selector: &metav1.LabelSelector{
|
Selector: &metav1.LabelSelector{
|
||||||
MatchLabels: map[string]string{
|
MatchLabels: map[string]string{
|
||||||
"app.kubernetes.io/name": "nginx",
|
"app.kubernetes.io/name": name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Template: corev1.PodTemplateSpec{
|
Template: corev1.PodTemplateSpec{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
"app.kubernetes.io/name": "nginx",
|
"app.kubernetes.io/name": name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Spec: corev1.PodSpec{
|
Spec: corev1.PodSpec{
|
||||||
@@ -406,6 +492,56 @@ func testIngressIsReachable(t *testing.T, httpClient *http.Client, url string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyProxyGroupTailnet verifies that a ProxyGroup is registered to the correct tailnet.
|
||||||
|
// This is done by getting the expected tailnet domain for the tailnet client,
|
||||||
|
// and comparing this with the actual device fqdn in the ProxyGroup state secret.
|
||||||
|
func verifyProxyGroupTailnet(t *testing.T, pg *tsapi.ProxyGroup, cl *tsnet.Server) error {
|
||||||
|
t.Helper()
|
||||||
|
// Determine the expected tailnet Magic DNS Name.
|
||||||
|
lc, err := cl.LocalClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status, err := lc.Status(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, expectedTailnet, ok := strings.Cut(strings.TrimSuffix(status.Self.DNSName, "."), ".")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected DNSName format %q", status.Self.DNSName)
|
||||||
|
}
|
||||||
|
// Read the device FQDN from the first state secret for the ProxyGroup,
|
||||||
|
// and verify that this matches the expected tailnet.
|
||||||
|
if err := tstest.WaitFor(3*time.Minute, func() error {
|
||||||
|
var secrets corev1.SecretList
|
||||||
|
if err := kubeClient.List(t.Context(), &secrets,
|
||||||
|
client.InNamespace("tailscale"),
|
||||||
|
client.MatchingLabels{
|
||||||
|
kubetypes.LabelSecretType: kubetypes.LabelSecretTypeState,
|
||||||
|
"tailscale.com/parent-resource-type": "proxygroup",
|
||||||
|
"tailscale.com/parent-resource": pg.Name,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(secrets.Items) == 0 {
|
||||||
|
return fmt.Errorf("no state secrets found for ProxyGroup %q yet", pg.Name)
|
||||||
|
}
|
||||||
|
fqdn := strings.TrimSuffix(string(secrets.Items[0].Data[kubetypes.KeyDeviceFQDN]), ".")
|
||||||
|
_, tailnet, ok := strings.Cut(fqdn, ".")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("ProxyGroup %q: device FQDN %q has no domain yet", pg.Name, fqdn)
|
||||||
|
}
|
||||||
|
if tailnet != expectedTailnet {
|
||||||
|
return fmt.Errorf("ProxyGroup %q on wrong tailnet: got domain %q, want %q", pg.Name, tailnet, expectedTailnet)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("ProxyGroup %q not on expected tailnet: %v", pg.Name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func waitForIngressHostname(t *testing.T, namespace, name string) (string, error) {
|
func waitForIngressHostname(t *testing.T, namespace, name string) (string, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var hostname string
|
var hostname string
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func createAndCleanup(t *testing.T, cl client.Client, obj client.Object) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
// Use context.Background() for cleanup, as t.Context() is cancelled
|
// Use context.Background() for cleanup, as t.Context() is cancelled
|
||||||
// just before cleanup functions are called.
|
// just before cleanup functions are called.
|
||||||
if err = cl.Delete(context.Background(), obj); err != nil {
|
if err := cl.Delete(context.Background(), obj); err != nil {
|
||||||
t.Errorf("error cleaning up %s %s/%s: %s", obj.GetObjectKind().GroupVersionKind(), obj.GetNamespace(), obj.GetName(), err)
|
t.Errorf("error cleaning up %s %s/%s: %s", obj.GetObjectKind().GroupVersionKind(), obj.GetNamespace(), obj.GetName(), err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -69,7 +69,7 @@ func createAndCleanupErr(t *testing.T, cl client.Client, obj client.Object) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
if err = cl.Delete(context.Background(), obj); err != nil {
|
if err := cl.Delete(context.Background(), obj); err != nil {
|
||||||
t.Errorf("error cleaning up %s %s/%s: %s", obj.GetObjectKind().GroupVersionKind(), obj.GetNamespace(), obj.GetName(), err)
|
t.Errorf("error cleaning up %s %s/%s: %s", obj.GetObjectKind().GroupVersionKind(), obj.GetNamespace(), obj.GetName(), err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+194
-25
@@ -4,6 +4,7 @@
|
|||||||
package e2e
|
package e2e
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
@@ -39,6 +40,7 @@ import (
|
|||||||
"helm.sh/helm/v3/pkg/release"
|
"helm.sh/helm/v3/pkg/release"
|
||||||
"helm.sh/helm/v3/pkg/storage/driver"
|
"helm.sh/helm/v3/pkg/storage/driver"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/watch"
|
"k8s.io/apimachinery/pkg/watch"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
@@ -70,9 +72,12 @@ const (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
tsClient *tailscale.Client // For API calls to control.
|
tsClient *tailscale.Client // For API calls to control.
|
||||||
tnClient *tsnet.Server // For testing real tailnet traffic.
|
tnClient *tsnet.Server // For testing real tailnet traffic on first tailnet.
|
||||||
|
secondTSClient *tailscale.Client // For API calls to the secondary tailnet (_second_tailnet).
|
||||||
|
secondTNClient *tsnet.Server // For testing real tailnet traffic on second tailnet.
|
||||||
restCfg *rest.Config // For constructing a client-go client if necessary.
|
restCfg *rest.Config // For constructing a client-go client if necessary.
|
||||||
kubeClient client.WithWatch // For k8s API calls.
|
kubeClient client.WithWatch // For k8s API calls.
|
||||||
|
clusterLoginServer string
|
||||||
|
|
||||||
//go:embed certs/pebble.minica.crt
|
//go:embed certs/pebble.minica.crt
|
||||||
pebbleMiniCACert []byte
|
pebbleMiniCACert []byte
|
||||||
@@ -157,11 +162,11 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
clusterLoginServer string // Login server from cluster Pod point of view.
|
clientID, clientSecret string // OAuth client for the first tailnet (for the operator to use).
|
||||||
clientID, clientSecret string // OAuth client for the operator to use.
|
|
||||||
caPaths []string // Extra CA cert file paths to add to images.
|
caPaths []string // Extra CA cert file paths to add to images.
|
||||||
|
|
||||||
certsDir = filepath.Join(tmp, "certs") // Directory containing extra CA certs to add to images.
|
certsDir = filepath.Join(tmp, "certs") // Directory containing extra CA certs to add to images.
|
||||||
|
secondClientID, secondClientSecret string // OAuth client for the second tailnet (for the operator to use).
|
||||||
)
|
)
|
||||||
if *fDevcontrol {
|
if *fDevcontrol {
|
||||||
// Deploy pebble and get its certs.
|
// Deploy pebble and get its certs.
|
||||||
@@ -279,7 +284,7 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
return 0, fmt.Errorf("failed to set policy file: %w", err)
|
return 0, fmt.Errorf("failed to set policy file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("ACLs configured")
|
logger.Info("ACLs configured for first tailnet")
|
||||||
|
|
||||||
key, err := tsClient.Keys().CreateOAuthClient(ctx, tailscale.CreateOAuthClientRequest{
|
key, err := tsClient.Keys().CreateOAuthClient(ctx, tailscale.CreateOAuthClientRequest{
|
||||||
Scopes: []string{"auth_keys", "devices:core", "services"},
|
Scopes: []string{"auth_keys", "devices:core", "services"},
|
||||||
@@ -287,36 +292,77 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
Description: "k8s-operator client for e2e tests",
|
Description: "k8s-operator client for e2e tests",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to marshal OAuth client creation request: %w", err)
|
return 0, fmt.Errorf("failed to create OAuth client for first tailnet: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
clientID = key.ID
|
clientID = key.ID
|
||||||
clientSecret = key.Key
|
clientSecret = key.Key
|
||||||
|
|
||||||
|
logger.Info("OAuth credentials set for first tailnet")
|
||||||
|
|
||||||
|
// Create second tailnet. The bootstrap credentials returned have 'all' permissions-
|
||||||
|
// they are used for administrative actions and to create a separately scoped
|
||||||
|
// Oauth client for the k8s operator.
|
||||||
|
bootstrapClient, err := createTailnet(ctx, tsClient)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create second tailnet: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set HTTPS on second tailnet.
|
||||||
|
err = bootstrapClient.TailnetSettings().Update(ctx, tailscale.UpdateTailnetSettingsRequest{HTTPSEnabled: new(true)})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to configure https for second tailnet: %w", err)
|
||||||
|
}
|
||||||
|
logger.Info("HTTPS settings configured for second tailnet")
|
||||||
|
|
||||||
|
// Set ACLs for second tailnet.
|
||||||
|
if err = bootstrapClient.PolicyFile().Set(ctx, string(requiredACLs), ""); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to set policy file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("ACLs configured for second tailnet")
|
||||||
|
|
||||||
|
// Create an OAuth client for the second tailnet to be used
|
||||||
|
// by the k8s-operator.
|
||||||
|
secondKey, err := bootstrapClient.Keys().CreateOAuthClient(ctx, tailscale.CreateOAuthClientRequest{
|
||||||
|
Scopes: []string{"auth_keys", "devices:core", "services"},
|
||||||
|
Tags: []string{"tag:k8s-operator"},
|
||||||
|
Description: "k8s-operator client for e2e tests",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create OAuth client for second tailnet: %w", err)
|
||||||
|
}
|
||||||
|
secondClientID = secondKey.ID
|
||||||
|
secondClientSecret = secondKey.Key
|
||||||
|
|
||||||
|
secondTSClient, err = tailscaleClientFromSecret(ctx, "http://localhost:31544", secondClientID, secondClientSecret)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to set up second tailnet client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
clientSecret = os.Getenv("TS_API_CLIENT_SECRET")
|
clientSecret = os.Getenv("TS_API_CLIENT_SECRET")
|
||||||
if clientSecret == "" {
|
if clientSecret == "" {
|
||||||
return 0, fmt.Errorf("must use --devcontrol or set TS_API_CLIENT_SECRET to an OAuth client suitable for the operator")
|
return 0, fmt.Errorf("must use --devcontrol or set TS_API_CLIENT_SECRET to an OAuth client suitable for the operator")
|
||||||
}
|
}
|
||||||
// Format is "tskey-client-<id>-<random>".
|
clientID, err = clientIDFromSecret(clientSecret)
|
||||||
parts := strings.Split(clientSecret, "-")
|
|
||||||
if len(parts) != 4 {
|
|
||||||
return 0, fmt.Errorf("TS_API_CLIENT_SECRET is not valid")
|
|
||||||
}
|
|
||||||
clientID = parts[2]
|
|
||||||
credentials := clientcredentials.Config{
|
|
||||||
ClientID: clientID,
|
|
||||||
ClientSecret: clientSecret,
|
|
||||||
TokenURL: fmt.Sprintf("%s/api/v2/oauth/token", ipn.DefaultControlURL),
|
|
||||||
Scopes: []string{"auth_keys"},
|
|
||||||
}
|
|
||||||
tk, err := credentials.Token(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to get OAuth token: %w", err)
|
return 0, fmt.Errorf("failed to get client id from secret: %w", err)
|
||||||
}
|
}
|
||||||
// An access token will last for an hour which is plenty of time for
|
tsClient, err = tailscaleClientFromSecret(ctx, ipn.DefaultControlURL, clientID, clientSecret)
|
||||||
// the tests to run. No need for token refresh logic.
|
if err != nil {
|
||||||
tsClient = &tailscale.Client{
|
return 0, fmt.Errorf("failed to set up first tailnet client: %w", err)
|
||||||
APIKey: tk.AccessToken,
|
}
|
||||||
|
secondClientSecret = os.Getenv("SECOND_TS_API_CLIENT_SECRET")
|
||||||
|
if secondClientSecret == "" {
|
||||||
|
return 0, fmt.Errorf("must use --devcontrol or set SECOND_TS_API_CLIENT_SECRET to an OAuth client suitable for the operator")
|
||||||
|
}
|
||||||
|
secondClientID, err = clientIDFromSecret(secondClientSecret)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get client id from secret: %w", err)
|
||||||
|
}
|
||||||
|
secondTSClient, err = tailscaleClientFromSecret(ctx, ipn.DefaultControlURL, secondClientID, secondClientSecret)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to set up second tailnet client: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,10 +492,16 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
|
|
||||||
authKey, err := tsClient.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps})
|
authKey, err := tsClient.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, fmt.Errorf("failed to create auth key for first tailnet: %w", err)
|
||||||
}
|
}
|
||||||
defer tsClient.Keys().Delete(context.Background(), authKey.ID)
|
defer tsClient.Keys().Delete(context.Background(), authKey.ID)
|
||||||
|
|
||||||
|
secondAuthKey, err := secondTSClient.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create auth key for second tailnet: %w", err)
|
||||||
|
}
|
||||||
|
defer secondTSClient.Keys().Delete(context.Background(), secondAuthKey.ID)
|
||||||
|
|
||||||
tnClient = &tsnet.Server{
|
tnClient = &tsnet.Server{
|
||||||
ControlURL: tsClient.BaseURL.String(),
|
ControlURL: tsClient.BaseURL.String(),
|
||||||
Hostname: "test-proxy",
|
Hostname: "test-proxy",
|
||||||
@@ -463,9 +515,64 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
}
|
}
|
||||||
defer tnClient.Close()
|
defer tnClient.Close()
|
||||||
|
|
||||||
|
secondTNClient = &tsnet.Server{
|
||||||
|
ControlURL: secondTSClient.BaseURL.String(),
|
||||||
|
Hostname: "test-proxy",
|
||||||
|
Ephemeral: true,
|
||||||
|
Store: &mem.Store{},
|
||||||
|
AuthKey: secondAuthKey.Key,
|
||||||
|
}
|
||||||
|
_, err = secondTNClient.Up(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer secondTNClient.Close()
|
||||||
|
|
||||||
|
// Create the tailnet Secret in the tailscale namespace.
|
||||||
|
secret := &corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "second-tailnet-credentials",
|
||||||
|
Namespace: "tailscale",
|
||||||
|
},
|
||||||
|
Data: map[string][]byte{
|
||||||
|
"client_id": []byte(secondClientID),
|
||||||
|
"client_secret": []byte(secondClientSecret),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := createOrUpdate(ctx, kubeClient, secret); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create second-tailnet-credentials Secret: %w", err)
|
||||||
|
}
|
||||||
|
defer kubeClient.Delete(context.Background(), secret)
|
||||||
|
|
||||||
|
// Create the Tailnet resource.
|
||||||
|
tn := &tsapi.Tailnet{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "second-tailnet",
|
||||||
|
},
|
||||||
|
Spec: tsapi.TailnetSpec{
|
||||||
|
LoginURL: clusterLoginServer,
|
||||||
|
Credentials: tsapi.TailnetCredentials{
|
||||||
|
SecretName: "second-tailnet-credentials",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := createOrUpdate(ctx, kubeClient, tn); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create second-tailnet Tailnet: %w", err)
|
||||||
|
}
|
||||||
|
defer kubeClient.Delete(context.Background(), tn)
|
||||||
|
|
||||||
return m.Run(), nil
|
return m.Run(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clientIDFromSecret(clientSecret string) (string, error) {
|
||||||
|
// Format is "tskey-client-<id>-<random>".
|
||||||
|
parts := strings.Split(clientSecret, "-")
|
||||||
|
if len(parts) != 4 {
|
||||||
|
return "", fmt.Errorf("secret is not valid")
|
||||||
|
}
|
||||||
|
return parts[2], nil
|
||||||
|
}
|
||||||
|
|
||||||
func upgraderOrInstaller(cfg *action.Configuration, releaseName string) helmInstallerFunc {
|
func upgraderOrInstaller(cfg *action.Configuration, releaseName string) helmInstallerFunc {
|
||||||
hist := action.NewHistory(cfg)
|
hist := action.NewHistory(cfg)
|
||||||
hist.Max = 1
|
hist.Max = 1
|
||||||
@@ -724,3 +831,65 @@ func buildImage(ctx context.Context, dir, repo, target, tag string, extraCACerts
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createOrUpdate(ctx context.Context, cl client.Client, obj client.Object) error {
|
||||||
|
if err := cl.Create(ctx, obj); err != nil {
|
||||||
|
if !apierrors.IsAlreadyExists(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return cl.Update(ctx, obj)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTailnet creates a new tailnet and returns a tailscale.Client
|
||||||
|
// authenticated against it using the bootstrap credentials included in the
|
||||||
|
// creation response.
|
||||||
|
func createTailnet(ctx context.Context, tsClient *tailscale.Client) (*tailscale.Client, error) {
|
||||||
|
tailnetName := fmt.Sprintf("second-tailnet-%d", time.Now().Unix())
|
||||||
|
body, err := json.Marshal(map[string]any{"displayName": tailnetName})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal tailnet creation request: %w", err)
|
||||||
|
}
|
||||||
|
// TODO(beckypauley): change to use a method on tailscale.Client once this is available.
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "POST", tsClient.BaseURL.String()+"/api/v2/organizations/-/tailnets", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", tsClient.APIKey))
|
||||||
|
resp, err := tsClient.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create tailnet: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("HTTP %d creating tailnet: %s", resp.StatusCode, string(b))
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
OauthClient struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
} `json:"oauthClient"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
return tailscaleClientFromSecret(ctx, tsClient.BaseURL.String(), result.OauthClient.ID, result.OauthClient.Secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailscaleClientFromSecret exchanges OAuth client credentials for an access token and
|
||||||
|
// returns a tailscale.Client configured to use it. The token is valid for
|
||||||
|
// one hour, which is sufficient for the tests to run. No need for refresh logic.
|
||||||
|
func tailscaleClientFromSecret(ctx context.Context, baseURL, clientID, clientSecret string) (*tailscale.Client, error) {
|
||||||
|
cfg := clientcredentials.Config{
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
TokenURL: fmt.Sprintf("%s/api/v2/oauth/token", baseURL),
|
||||||
|
}
|
||||||
|
tk, err := cfg.Token(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get OAuth token for client %q: %w", clientID, err)
|
||||||
|
}
|
||||||
|
return &tailscale.Client{
|
||||||
|
APIKey: tk.AccessToken,
|
||||||
|
BaseURL: must.Get(url.Parse(baseURL)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
|
||||||
"tailscale.com/kube/egressservices"
|
"tailscale.com/kube/egressservices"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ func (er *egressEpsReconciler) Reconcile(ctx context.Context, req reconcile.Requ
|
|||||||
lg.Debugf("No egress config found, likely because ProxyGroup has not been created")
|
lg.Debugf("No egress config found, likely because ProxyGroup has not been created")
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
cfg, ok := (*cfgs)[tailnetSvc]
|
cfg, ok := cfgs[tailnetSvc]
|
||||||
if !ok {
|
if !ok {
|
||||||
lg.Infof("[unexpected] configuration for tailnet service %s not found", tailnetSvc)
|
lg.Infof("[unexpected] configuration for tailnet service %s not found", tailnetSvc)
|
||||||
return res, nil
|
return res, nil
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
"tailscale.com/kube/egressservices"
|
"tailscale.com/kube/egressservices"
|
||||||
@@ -347,11 +348,11 @@ func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName s
|
|||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
tailnetSvc := tailnetSvcName(svc)
|
tailnetSvc := tailnetSvcName(svc)
|
||||||
gotCfg := (*cfgs)[tailnetSvc]
|
gotCfg := cfgs[tailnetSvc]
|
||||||
wantsCfg := egressSvcCfg(svc, clusterIPSvc, esr.tsNamespace, lg)
|
wantsCfg := egressSvcCfg(svc, clusterIPSvc, esr.tsNamespace, lg)
|
||||||
if !reflect.DeepEqual(gotCfg, wantsCfg) {
|
if !reflect.DeepEqual(gotCfg, wantsCfg) {
|
||||||
lg.Debugf("updating egress services ConfigMap %s", cm.Name)
|
lg.Debugf("updating egress services ConfigMap %s", cm.Name)
|
||||||
mak.Set(cfgs, tailnetSvc, wantsCfg)
|
mak.Set(&cfgs, tailnetSvc, wantsCfg)
|
||||||
bs, err := json.Marshal(cfgs)
|
bs, err := json.Marshal(cfgs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, fmt.Errorf("error marshalling egress services configs: %w", err)
|
return nil, false, fmt.Errorf("error marshalling egress services configs: %w", err)
|
||||||
@@ -485,19 +486,19 @@ func (esr *egressSvcsReconciler) ensureEgressSvcCfgDeleted(ctx context.Context,
|
|||||||
lggr.Debugf("ConfigMap does not contain egress service configs")
|
lggr.Debugf("ConfigMap does not contain egress service configs")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cfgs := &egressservices.Configs{}
|
cfgs := egressservices.Configs{}
|
||||||
if err := json.Unmarshal(bs, cfgs); err != nil {
|
if err := json.Unmarshal(bs, &cfgs); err != nil {
|
||||||
return fmt.Errorf("error unmarshalling egress services configs")
|
return fmt.Errorf("error unmarshalling egress services configs")
|
||||||
}
|
}
|
||||||
tailnetSvc := tailnetSvcName(svc)
|
tailnetSvc := tailnetSvcName(svc)
|
||||||
_, ok := (*cfgs)[tailnetSvc]
|
_, ok := cfgs[tailnetSvc]
|
||||||
if !ok {
|
if !ok {
|
||||||
lggr.Debugf("ConfigMap does not contain egress service config, likely because it was already deleted")
|
lggr.Debugf("ConfigMap does not contain egress service config, likely because it was already deleted")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
lggr.Infof("before deleting config %+#v", *cfgs)
|
lggr.Infof("before deleting config %+#v", cfgs)
|
||||||
delete(*cfgs, tailnetSvc)
|
delete(cfgs, tailnetSvc)
|
||||||
lggr.Infof("after deleting config %+#v", *cfgs)
|
lggr.Infof("after deleting config %+#v", cfgs)
|
||||||
bs, err := json.Marshal(cfgs)
|
bs, err := json.Marshal(cfgs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error marshalling egress services configs: %w", err)
|
return fmt.Errorf("error marshalling egress services configs: %w", err)
|
||||||
@@ -649,7 +650,7 @@ func isEgressSvcForProxyGroup(obj client.Object) bool {
|
|||||||
|
|
||||||
// egressSvcConfig returns a ConfigMap that contains egress services configuration for the provided ProxyGroup as well
|
// egressSvcConfig returns a ConfigMap that contains egress services configuration for the provided ProxyGroup as well
|
||||||
// as unmarshalled configuration from the ConfigMap.
|
// as unmarshalled configuration from the ConfigMap.
|
||||||
func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, tsNamespace string) (cm *corev1.ConfigMap, cfgs *egressservices.Configs, err error) {
|
func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, tsNamespace string) (cm *corev1.ConfigMap, cfgs egressservices.Configs, err error) {
|
||||||
name := pgEgressCMName(proxyGroupName)
|
name := pgEgressCMName(proxyGroupName)
|
||||||
cm = &corev1.ConfigMap{
|
cm = &corev1.ConfigMap{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
@@ -664,9 +665,9 @@ func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, ts
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("error retrieving egress services ConfigMap %s: %v", name, err)
|
return nil, nil, fmt.Errorf("error retrieving egress services ConfigMap %s: %v", name, err)
|
||||||
}
|
}
|
||||||
cfgs = &egressservices.Configs{}
|
cfgs = egressservices.Configs{}
|
||||||
if len(cm.BinaryData[egressservices.KeyEgressServices]) != 0 {
|
if len(cm.BinaryData[egressservices.KeyEgressServices]) != 0 {
|
||||||
if err := json.Unmarshal(cm.BinaryData[egressservices.KeyEgressServices], cfgs); err != nil {
|
if err := json.Unmarshal(cm.BinaryData[egressservices.KeyEgressServices], &cfgs); err != nil {
|
||||||
return nil, nil, fmt.Errorf("error unmarshaling egress services config %v: %w", cm.BinaryData[egressservices.KeyEgressServices], err)
|
return nil, nil, fmt.Errorf("error unmarshaling egress services config %v: %w", cm.BinaryData[egressservices.KeyEgressServices], err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/util/intstr"
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
"tailscale.com/kube/egressservices"
|
"tailscale.com/kube/egressservices"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
@@ -284,11 +285,11 @@ func configFromCM(t *testing.T, cm *corev1.ConfigMap, svcName string) *egressser
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cfgs := &egressservices.Configs{}
|
cfgs := egressservices.Configs{}
|
||||||
if err := json.Unmarshal(cfgBs, cfgs); err != nil {
|
if err := json.Unmarshal(cfgBs, &cfgs); err != nil {
|
||||||
t.Fatalf("error unmarshalling config: %v", err)
|
t.Fatalf("error unmarshalling config: %v", err)
|
||||||
}
|
}
|
||||||
cfg, ok := (*cfgs)[svcName]
|
cfg, ok := cfgs[svcName]
|
||||||
if ok {
|
if ok {
|
||||||
return &cfg
|
return &cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1081,7 +1081,7 @@ func certResourceLabels(pgName, domain string) map[string]string {
|
|||||||
return map[string]string{
|
return map[string]string{
|
||||||
kubetypes.LabelManaged: "true",
|
kubetypes.LabelManaged: "true",
|
||||||
labelProxyGroup: pgName,
|
labelProxyGroup: pgName,
|
||||||
labelDomain: domain,
|
labelDomain: tsoperator.TruncateLabelValue(domain),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
kube "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
)
|
)
|
||||||
@@ -227,13 +228,13 @@ func metricsResourceLabels(opts *metricsOpts) map[string]string {
|
|||||||
kubetypes.LabelManaged: "true",
|
kubetypes.LabelManaged: "true",
|
||||||
labelMetricsTarget: opts.proxyStsName,
|
labelMetricsTarget: opts.proxyStsName,
|
||||||
labelPromProxyType: opts.proxyType,
|
labelPromProxyType: opts.proxyType,
|
||||||
labelPromProxyParentName: opts.proxyLabels[LabelParentName],
|
labelPromProxyParentName: kube.TruncateLabelValue(opts.proxyLabels[LabelParentName]),
|
||||||
}
|
}
|
||||||
// Include namespace label for proxies created for a namespaced type.
|
// Include namespace label for proxies created for a namespaced type.
|
||||||
if isNamespacedProxyType(opts.proxyType) {
|
if isNamespacedProxyType(opts.proxyType) {
|
||||||
lbls[labelPromProxyParentNamespace] = opts.proxyLabels[LabelParentNamespace]
|
lbls[labelPromProxyParentNamespace] = kube.TruncateLabelValue(opts.proxyLabels[LabelParentNamespace])
|
||||||
}
|
}
|
||||||
lbls[labelPromJob] = promJobName(opts)
|
lbls[labelPromJob] = kube.TruncateLabelValue(promJobName(opts))
|
||||||
return lbls
|
return lbls
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,11 +251,11 @@ func promJobName(opts *metricsOpts) string {
|
|||||||
func metricsSvcSelector(proxyLabels map[string]string, proxyType string) map[string]string {
|
func metricsSvcSelector(proxyLabels map[string]string, proxyType string) map[string]string {
|
||||||
sel := map[string]string{
|
sel := map[string]string{
|
||||||
labelPromProxyType: proxyType,
|
labelPromProxyType: proxyType,
|
||||||
labelPromProxyParentName: proxyLabels[LabelParentName],
|
labelPromProxyParentName: kube.TruncateLabelValue(proxyLabels[LabelParentName]),
|
||||||
}
|
}
|
||||||
// Include namespace label for proxies created for a namespaced type.
|
// Include namespace label for proxies created for a namespaced type.
|
||||||
if isNamespacedProxyType(proxyType) {
|
if isNamespacedProxyType(proxyType) {
|
||||||
sel[labelPromProxyParentNamespace] = proxyLabels[LabelParentNamespace]
|
sel[labelPromProxyParentNamespace] = kube.TruncateLabelValue(proxyLabels[LabelParentNamespace])
|
||||||
}
|
}
|
||||||
return sel
|
return sel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ func (a *NameserverReconciler) maybeProvision(ctx context.Context, tsDNSCfg *tsa
|
|||||||
}
|
}
|
||||||
if tsDNSCfg.Spec.Nameserver.Pod != nil {
|
if tsDNSCfg.Spec.Nameserver.Pod != nil {
|
||||||
dCfg.tolerations = tsDNSCfg.Spec.Nameserver.Pod.Tolerations
|
dCfg.tolerations = tsDNSCfg.Spec.Nameserver.Pod.Tolerations
|
||||||
|
dCfg.affinity = tsDNSCfg.Spec.Nameserver.Pod.Affinity
|
||||||
|
dCfg.nodeSelector = tsDNSCfg.Spec.Nameserver.Pod.NodeSelector
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, deployable := range []deployable{saDeployable, deployDeployable, svcDeployable, cmDeployable} {
|
for _, deployable := range []deployable{saDeployable, deployDeployable, svcDeployable, cmDeployable} {
|
||||||
@@ -225,6 +227,8 @@ type deployConfig struct {
|
|||||||
namespace string
|
namespace string
|
||||||
clusterIP string
|
clusterIP string
|
||||||
tolerations []corev1.Toleration
|
tolerations []corev1.Toleration
|
||||||
|
affinity *corev1.Affinity
|
||||||
|
nodeSelector map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -250,6 +254,8 @@ var (
|
|||||||
d.ObjectMeta.Labels = cfg.labels
|
d.ObjectMeta.Labels = cfg.labels
|
||||||
d.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
d.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||||
d.Spec.Template.Spec.Tolerations = cfg.tolerations
|
d.Spec.Template.Spec.Tolerations = cfg.tolerations
|
||||||
|
d.Spec.Template.Spec.Affinity = cfg.affinity
|
||||||
|
d.Spec.Template.Spec.NodeSelector = cfg.nodeSelector
|
||||||
updateF := func(oldD *appsv1.Deployment) {
|
updateF := func(oldD *appsv1.Deployment) {
|
||||||
oldD.Spec = d.Spec
|
oldD.Spec = d.Spec
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ func TestNameserverReconciler(t *testing.T) {
|
|||||||
ClusterIP: "5.4.3.2",
|
ClusterIP: "5.4.3.2",
|
||||||
},
|
},
|
||||||
Pod: &tsapi.NameserverPod{
|
Pod: &tsapi.NameserverPod{
|
||||||
|
NodeSelector: map[string]string{
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
Tolerations: []corev1.Toleration{
|
Tolerations: []corev1.Toleration{
|
||||||
{
|
{
|
||||||
Key: "some-key",
|
Key: "some-key",
|
||||||
@@ -51,6 +54,23 @@ func TestNameserverReconciler(t *testing.T) {
|
|||||||
Effect: corev1.TaintEffectNoSchedule,
|
Effect: corev1.TaintEffectNoSchedule,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Affinity: &corev1.Affinity{
|
||||||
|
NodeAffinity: &corev1.NodeAffinity{
|
||||||
|
RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{
|
||||||
|
NodeSelectorTerms: []corev1.NodeSelectorTerm{
|
||||||
|
{
|
||||||
|
MatchExpressions: []corev1.NodeSelectorRequirement{
|
||||||
|
{
|
||||||
|
Key: "some-key",
|
||||||
|
Operator: corev1.NodeSelectorOpIn,
|
||||||
|
Values: []string{"some-value"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -97,6 +117,26 @@ func TestNameserverReconciler(t *testing.T) {
|
|||||||
Effect: corev1.TaintEffectNoSchedule,
|
Effect: corev1.TaintEffectNoSchedule,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
wantsDeploy.Spec.Template.Spec.Affinity = &corev1.Affinity{
|
||||||
|
NodeAffinity: &corev1.NodeAffinity{
|
||||||
|
RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{
|
||||||
|
NodeSelectorTerms: []corev1.NodeSelectorTerm{
|
||||||
|
{
|
||||||
|
MatchExpressions: []corev1.NodeSelectorRequirement{
|
||||||
|
{
|
||||||
|
Key: "some-key",
|
||||||
|
Operator: corev1.NodeSelectorOpIn,
|
||||||
|
Values: []string{"some-value"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wantsDeploy.Spec.Template.Spec.NodeSelector = map[string]string{
|
||||||
|
"foo": "bar",
|
||||||
|
}
|
||||||
|
|
||||||
expectEqual(t, fc, wantsDeploy)
|
expectEqual(t, fc, wantsDeploy)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -698,6 +698,8 @@ func runReconcilers(opts reconcilerOpts) {
|
|||||||
log: opts.log.Named("recorder-reconciler"),
|
log: opts.log.Named("recorder-reconciler"),
|
||||||
clock: tstime.DefaultClock{},
|
clock: tstime.DefaultClock{},
|
||||||
clients: clients,
|
clients: clients,
|
||||||
|
authKeyRateLimits: make(map[string]*rate.Limiter),
|
||||||
|
authKeyReissuing: make(map[string]bool),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
startlog.Fatalf("could not create Recorder reconciler: %v", err)
|
startlog.Fatalf("could not create Recorder reconciler: %v", err)
|
||||||
|
|||||||
@@ -1160,6 +1160,9 @@ func (r *ProxyGroupReconciler) ensureStateRemovedForProxyGroup(pg *tsapi.ProxyGr
|
|||||||
gaugeIngressProxyGroupResources.Set(int64(r.ingressProxyGroups.Len()))
|
gaugeIngressProxyGroupResources.Set(int64(r.ingressProxyGroups.Len()))
|
||||||
gaugeAPIServerProxyGroupResources.Set(int64(r.apiServerProxyGroups.Len()))
|
gaugeAPIServerProxyGroupResources.Set(int64(r.apiServerProxyGroups.Len()))
|
||||||
delete(r.authKeyRateLimits, pg.Name)
|
delete(r.authKeyRateLimits, pg.Name)
|
||||||
|
for i := range pgReplicas(pg) {
|
||||||
|
delete(r.authKeyReissuing, pgStateSecretName(pg.Name, i))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pgTailscaledConfig(pg *tsapi.ProxyGroup, loginServer string, pc *tsapi.ProxyClass, idx int32, authKey *string, staticEndpoints []netip.AddrPort, oldAdvertiseServices []string) (tailscaledConfigs, error) {
|
func pgTailscaledConfig(pg *tsapi.ProxyGroup, loginServer string, pc *tsapi.ProxyClass, idx int32, authKey *string, staticEndpoints []netip.AddrPort, oldAdvertiseServices []string) (tailscaledConfigs, error) {
|
||||||
|
|||||||
+124
-14
@@ -14,9 +14,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
xslices "golang.org/x/exp/slices"
|
xslices "golang.org/x/exp/slices"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
rbacv1 "k8s.io/api/rbac/v1"
|
rbacv1 "k8s.io/api/rbac/v1"
|
||||||
@@ -62,7 +64,8 @@ type RecorderReconciler struct {
|
|||||||
clock tstime.Clock
|
clock tstime.Clock
|
||||||
clients ClientProvider
|
clients ClientProvider
|
||||||
tsNamespace string
|
tsNamespace string
|
||||||
|
authKeyRateLimits map[string]*rate.Limiter // per-Recorder rate limiters for auth key re-issuance.
|
||||||
|
authKeyReissuing map[string]bool
|
||||||
mu sync.Mutex // protects following
|
mu sync.Mutex // protects following
|
||||||
recorders set.Slice[types.UID] // for recorders gauge
|
recorders set.Slice[types.UID] // for recorders gauge
|
||||||
}
|
}
|
||||||
@@ -164,9 +167,23 @@ func (r *RecorderReconciler) Reconcile(ctx context.Context, req reconcile.Reques
|
|||||||
func (r *RecorderReconciler) maybeProvision(ctx context.Context, tsClient tsclient.Client, tsr *tsapi.Recorder) error {
|
func (r *RecorderReconciler) maybeProvision(ctx context.Context, tsClient tsclient.Client, tsr *tsapi.Recorder) error {
|
||||||
logger := r.logger(tsr.Name)
|
logger := r.logger(tsr.Name)
|
||||||
|
|
||||||
|
var replicas int32 = 1
|
||||||
|
if tsr.Spec.Replicas != nil {
|
||||||
|
replicas = *tsr.Spec.Replicas
|
||||||
|
}
|
||||||
|
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.recorders.Add(tsr.UID)
|
r.recorders.Add(tsr.UID)
|
||||||
gaugeRecorderResources.Set(int64(r.recorders.Len()))
|
gaugeRecorderResources.Set(int64(r.recorders.Len()))
|
||||||
|
if _, ok := r.authKeyRateLimits[tsr.Name]; !ok {
|
||||||
|
r.authKeyRateLimits[tsr.Name] = rate.NewLimiter(rate.Every(30*time.Second), int(replicas))
|
||||||
|
}
|
||||||
|
for replica := range replicas {
|
||||||
|
name := fmt.Sprintf("%s-%d", tsr.Name, replica)
|
||||||
|
if _, ok := r.authKeyReissuing[name]; !ok {
|
||||||
|
r.authKeyReissuing[name] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
if err := r.ensureAuthSecretsCreated(ctx, tsClient, tsr); err != nil {
|
if err := r.ensureAuthSecretsCreated(ctx, tsClient, tsr); err != nil {
|
||||||
@@ -174,11 +191,6 @@ func (r *RecorderReconciler) maybeProvision(ctx context.Context, tsClient tsclie
|
|||||||
}
|
}
|
||||||
|
|
||||||
// State Secrets are pre-created so we can use the Recorder CR as its owner ref.
|
// State Secrets are pre-created so we can use the Recorder CR as its owner ref.
|
||||||
var replicas int32 = 1
|
|
||||||
if tsr.Spec.Replicas != nil {
|
|
||||||
replicas = *tsr.Spec.Replicas
|
|
||||||
}
|
|
||||||
|
|
||||||
for replica := range replicas {
|
for replica := range replicas {
|
||||||
sec := tsrStateSecret(tsr, r.tsNamespace, replica)
|
sec := tsrStateSecret(tsr, r.tsNamespace, replica)
|
||||||
_, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sec, func(s *corev1.Secret) {
|
_, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sec, func(s *corev1.Secret) {
|
||||||
@@ -423,6 +435,10 @@ func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Record
|
|||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.recorders.Remove(tsr.UID)
|
r.recorders.Remove(tsr.UID)
|
||||||
gaugeRecorderResources.Set(int64(r.recorders.Len()))
|
gaugeRecorderResources.Set(int64(r.recorders.Len()))
|
||||||
|
delete(r.authKeyRateLimits, tsr.Name)
|
||||||
|
for replica := range replicas {
|
||||||
|
delete(r.authKeyReissuing, fmt.Sprintf("%s-%d", tsr.Name, replica))
|
||||||
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -447,28 +463,122 @@ func (r *RecorderReconciler) ensureAuthSecretsCreated(ctx context.Context, tsCli
|
|||||||
Name: fmt.Sprintf("%s-auth-%d", tsr.Name, replica),
|
Name: fmt.Sprintf("%s-auth-%d", tsr.Name, replica),
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.Get(ctx, key, &corev1.Secret{})
|
existingSecret := &corev1.Secret{}
|
||||||
|
err := r.Get(ctx, key, existingSecret)
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
logger.Debugf("auth Secret %q already exists", key.Name)
|
reissue, err := r.shouldReissueAuthKey(ctx, tsClient, tsr, replica, existingSecret)
|
||||||
continue
|
if err != nil {
|
||||||
case !apierrors.IsNotFound(err):
|
return fmt.Errorf("error checking auth key reissue for replica %d: %w", replica, err)
|
||||||
return fmt.Errorf("failed to get Secret %q: %w", key.Name, err)
|
}
|
||||||
|
if !reissue {
|
||||||
|
logger.Debugf("auth Secret %q already exists, no reissue needed", key.Name)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
authKey, err := newAuthKey(ctx, tsClient, tags.Stringify())
|
authKey, err := newAuthKey(ctx, tsClient, tags.Stringify())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
existingSecret.Data["authkey"] = []byte(authKey)
|
||||||
if err = r.Create(ctx, tsrAuthSecret(tsr, r.tsNamespace, authKey, replica)); err != nil {
|
if err = r.Update(ctx, existingSecret); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
continue
|
||||||
|
case apierrors.IsNotFound(err):
|
||||||
|
authKey, err := newAuthKey(ctx, tsClient, tags.Stringify())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.Create(ctx, tsrAuthSecret(tsr, r.tsNamespace, authKey, replica)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("failed to get Secret %q: %w", key.Name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldReissueAuthKey returns true if the proxy needs a new auth key. It
|
||||||
|
// tracks in-flight reissues via authKeyReissuing to avoid duplicate API calls
|
||||||
|
// across reconciles.
|
||||||
|
func (r *RecorderReconciler) shouldReissueAuthKey(ctx context.Context, tsClient tsclient.Client, tsr *tsapi.Recorder, replica int32, authSecret *corev1.Secret) (shouldReissue bool, err error) {
|
||||||
|
stateSecret, err := r.getStateSecret(ctx, tsr.Name, replica)
|
||||||
|
if err != nil || stateSecret == nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stateSecretName := fmt.Sprintf("%s-%d", tsr.Name, replica)
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
reissuing := r.authKeyReissuing[stateSecretName]
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if reissuing {
|
||||||
|
_, requestStillPresent := stateSecret.Data[kubetypes.KeyReissueAuthkey]
|
||||||
|
if !requestStillPresent {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.authKeyReissuing[stateSecretName] = false
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.log.Debugf("auth key reissue completed for %q", stateSecretName)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
r.log.Debugf("auth key already in process of re-issuance for %q, waiting", stateSecretName)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.authKeyReissuing[stateSecretName] = shouldReissue
|
||||||
|
r.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
brokenAuthkey, ok := stateSecret.Data[kubetypes.KeyReissueAuthkey]
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgAuthKey := string(authSecret.Data["authkey"])
|
||||||
|
empty := cfgAuthKey == ""
|
||||||
|
broken := cfgAuthKey == string(brokenAuthkey)
|
||||||
|
|
||||||
|
if !empty && !broken {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lim := r.authKeyRateLimits[tsr.Name]
|
||||||
|
if !lim.Allow() {
|
||||||
|
r.log.Debugf("auth key re-issuance rate limit exceeded, limit: %.2f, burst: %d, tokens: %.2f",
|
||||||
|
lim.Limit(), lim.Burst(), lim.Tokens())
|
||||||
|
return false, fmt.Errorf("auth key re-issuance rate limit exceeded for Recorder %q, will retry with backoff", tsr.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.log.Infof("Recorder replica %s failing to auth; attempting cleanup and new key", stateSecretName)
|
||||||
|
if tsID := stateSecret.Data[kubetypes.KeyDeviceID]; len(tsID) > 0 {
|
||||||
|
id := tailcfg.StableNodeID(tsID)
|
||||||
|
if err := r.ensureDeviceDeleted(ctx, tsClient, id, r.log); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RecorderReconciler) ensureDeviceDeleted(ctx context.Context, tsClient tsclient.Client, id tailcfg.StableNodeID, logger *zap.SugaredLogger) error {
|
||||||
|
logger.Debugf("deleting device %s from control", string(id))
|
||||||
|
err := tsClient.Devices().Delete(ctx, string(id))
|
||||||
|
switch {
|
||||||
|
case tailscale.IsNotFound(err):
|
||||||
|
logger.Debugf("device %s not found, likely because it has already been deleted from control", string(id))
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("error deleting device: %w", err)
|
||||||
|
default:
|
||||||
|
logger.Debugf("device %s deleted from control", string(id))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *RecorderReconciler) validate(ctx context.Context, tsr *tsapi.Recorder) error {
|
func (r *RecorderReconciler) validate(ctx context.Context, tsr *tsapi.Recorder) error {
|
||||||
if !tsr.Spec.EnableUI && tsr.Spec.Storage.S3 == nil {
|
if !tsr.Spec.EnableUI && tsr.Spec.Storage.S3 == nil {
|
||||||
return errors.New("must either enable UI or use S3 storage to ensure recordings are accessible")
|
return errors.New("must either enable UI or use S3 storage to ensure recordings are accessible")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
rbacv1 "k8s.io/api/rbac/v1"
|
rbacv1 "k8s.io/api/rbac/v1"
|
||||||
@@ -61,6 +62,8 @@ func TestRecorder(t *testing.T) {
|
|||||||
recorder: fr,
|
recorder: fr,
|
||||||
log: zl.Sugar(),
|
log: zl.Sugar(),
|
||||||
clock: cl,
|
clock: cl,
|
||||||
|
authKeyRateLimits: make(map[string]*rate.Limiter),
|
||||||
|
authKeyReissuing: make(map[string]bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("invalid_spec_gives_an_error_condition", func(t *testing.T) {
|
t.Run("invalid_spec_gives_an_error_condition", func(t *testing.T) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"k8s.io/utils/strings/slices"
|
"k8s.io/utils/strings/slices"
|
||||||
"tailscale.com/client/local"
|
"tailscale.com/client/local"
|
||||||
"tailscale.com/cmd/k8s-proxy/internal/config"
|
"tailscale.com/cmd/k8s-proxy/internal/config"
|
||||||
|
"tailscale.com/health"
|
||||||
"tailscale.com/hostinfo"
|
"tailscale.com/hostinfo"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/store"
|
"tailscale.com/ipn/store"
|
||||||
@@ -41,6 +42,7 @@ import (
|
|||||||
"tailscale.com/kube/certs"
|
"tailscale.com/kube/certs"
|
||||||
healthz "tailscale.com/kube/health"
|
healthz "tailscale.com/kube/health"
|
||||||
"tailscale.com/kube/k8s-proxy/conf"
|
"tailscale.com/kube/k8s-proxy/conf"
|
||||||
|
"tailscale.com/kube/kubeclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
klc "tailscale.com/kube/localclient"
|
klc "tailscale.com/kube/localclient"
|
||||||
"tailscale.com/kube/metrics"
|
"tailscale.com/kube/metrics"
|
||||||
@@ -171,10 +173,31 @@ func run(logger *zap.SugaredLogger) error {
|
|||||||
|
|
||||||
// If Pod UID unset, assume we're running outside of a cluster/not managed
|
// If Pod UID unset, assume we're running outside of a cluster/not managed
|
||||||
// by the operator, so no need to set additional state keys.
|
// by the operator, so no need to set additional state keys.
|
||||||
|
var kc kubeclient.Client
|
||||||
|
var stateSecretName string
|
||||||
if podUID != "" {
|
if podUID != "" {
|
||||||
if err := state.SetInitialKeys(st, podUID); err != nil {
|
if err := state.SetInitialKeys(st, podUID); err != nil {
|
||||||
return fmt.Errorf("error setting initial state: %w", err)
|
return fmt.Errorf("error setting initial state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.Parsed.State != nil {
|
||||||
|
if name, ok := strings.CutPrefix(*cfg.Parsed.State, "kube:"); ok {
|
||||||
|
stateSecretName = name
|
||||||
|
|
||||||
|
kc, err = kubeclient.New(k8sProxyFieldManager)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var configAuthKey string
|
||||||
|
if cfg.Parsed.AuthKey != nil {
|
||||||
|
configAuthKey = *cfg.Parsed.AuthKey
|
||||||
|
}
|
||||||
|
if err := resetState(ctx, kc, stateSecretName, podUID, configAuthKey); err != nil {
|
||||||
|
return fmt.Errorf("error resetting state: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var authKey string
|
var authKey string
|
||||||
@@ -197,23 +220,69 @@ func run(logger *zap.SugaredLogger) error {
|
|||||||
ts.Hostname = *cfg.Parsed.Hostname
|
ts.Hostname = *cfg.Parsed.Hostname
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure we crash loop if Up doesn't complete in reasonable time.
|
|
||||||
upCtx, upCancel := context.WithTimeout(ctx, time.Minute)
|
|
||||||
defer upCancel()
|
|
||||||
if _, err := ts.Up(upCtx); err != nil {
|
|
||||||
return fmt.Errorf("error starting tailscale server: %w", err)
|
|
||||||
}
|
|
||||||
defer ts.Close()
|
|
||||||
lc, err := ts.LocalClient()
|
lc, err := ts.LocalClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error getting local client: %w", err)
|
return fmt.Errorf("error getting local client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup for updating state keys.
|
// Make sure we crash loop if Up doesn't complete in reasonable time.
|
||||||
|
upCtx, upCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer upCancel()
|
||||||
|
|
||||||
|
// ts.Up() deliberately ignores NeedsLogin because it fires transiently
|
||||||
|
// during normal auth-key login. We can watch for the login-state health
|
||||||
|
// warning here though, which only fires on terminal auth failure, and
|
||||||
|
// cancel early.
|
||||||
|
go func() {
|
||||||
|
w, err := lc.WatchIPNBus(upCtx, ipn.NotifyInitialHealthState)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
for {
|
||||||
|
n, err := w.Next()
|
||||||
|
if err != nil {
|
||||||
|
logger.Debugf("failed to process message from ipn bus: %s", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n.Health != nil {
|
||||||
|
if _, ok := n.Health.Warnings[health.LoginStateWarnable.Code]; ok {
|
||||||
|
upCancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := ts.Up(upCtx); err != nil {
|
||||||
|
if kc != nil && stateSecretName != "" {
|
||||||
|
return handleAuthKeyReissue(ctx, lc, kc, stateSecretName, authKey, cfgChan, logger)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
reissueCh := make(chan struct{}, 1)
|
||||||
if podUID != "" {
|
if podUID != "" {
|
||||||
group.Go(func() error {
|
group.Go(func() error {
|
||||||
return state.KeepKeysUpdated(ctx, st, klc.New(lc))
|
return state.KeepKeysUpdated(ctx, st, klc.New(lc))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if kc != nil && stateSecretName != "" {
|
||||||
|
needsReissue, err := checkInitialAuthState(ctx, lc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error checking initial auth state: %w", err)
|
||||||
|
}
|
||||||
|
if needsReissue {
|
||||||
|
logger.Info("Auth key missing or invalid after startup, requesting new key from operator")
|
||||||
|
return handleAuthKeyReissue(ctx, lc, kc, stateSecretName, authKey, cfgChan, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
group.Go(func() error {
|
||||||
|
return monitorAuthHealth(ctx, lc, reissueCh, logger)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Parsed.HealthCheckEnabled.EqualBool(true) || cfg.Parsed.MetricsEnabled.EqualBool(true) {
|
if cfg.Parsed.HealthCheckEnabled.EqualBool(true) || cfg.Parsed.MetricsEnabled.EqualBool(true) {
|
||||||
@@ -362,6 +431,8 @@ func run(logger *zap.SugaredLogger) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfgLogger.Infof("Config reloaded")
|
cfgLogger.Infof("Config reloaded")
|
||||||
|
case <-reissueCh:
|
||||||
|
return handleAuthKeyReissue(ctx, lc, kc, stateSecretName, authKey, cfgChan, logger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"tailscale.com/client/local"
|
||||||
|
"tailscale.com/health"
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/kube/authkey"
|
||||||
|
"tailscale.com/kube/k8s-proxy/conf"
|
||||||
|
"tailscale.com/kube/kubeapi"
|
||||||
|
"tailscale.com/kube/kubeclient"
|
||||||
|
"tailscale.com/kube/kubetypes"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
const k8sProxyFieldManager = "tailscale-k8s-proxy"
|
||||||
|
|
||||||
|
// resetState clears k8s-proxy state from previous runs and sets
|
||||||
|
// initial values. This ensures the operator doesn't use stale state when a Pod
|
||||||
|
// is first recreated.
|
||||||
|
//
|
||||||
|
// It also clears the reissue_authkey marker if the operator has actioned it
|
||||||
|
// (i.e., the config now has a different auth key than what was marked for
|
||||||
|
// reissue).
|
||||||
|
func resetState(ctx context.Context, kc kubeclient.Client, stateSecretName string, podUID string, configAuthKey string) error {
|
||||||
|
existingSecret, err := kc.GetSecret(ctx, stateSecretName)
|
||||||
|
switch {
|
||||||
|
case kubeclient.IsNotFoundErr(err):
|
||||||
|
return nil
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("failed to read state Secret %q to reset state: %w", stateSecretName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &kubeapi.Secret{
|
||||||
|
Data: map[string][]byte{
|
||||||
|
kubetypes.KeyCapVer: fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if podUID != "" {
|
||||||
|
s.Data[kubetypes.KeyPodUID] = []byte(podUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only clear reissue_authkey if the operator has actioned it.
|
||||||
|
brokenAuthkey, ok := existingSecret.Data[kubetypes.KeyReissueAuthkey]
|
||||||
|
if ok && configAuthKey != "" && string(brokenAuthkey) != configAuthKey {
|
||||||
|
s.Data[kubetypes.KeyReissueAuthkey] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return kc.StrategicMergePatchSecret(ctx, stateSecretName, s, k8sProxyFieldManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsAuthKeyReissue reports whether the given backend state and health
|
||||||
|
// warnings indicate a terminal auth failure requiring a new key from the
|
||||||
|
// operator.
|
||||||
|
func needsAuthKeyReissue(backendState string, healthWarnings []string) bool {
|
||||||
|
if backendState == ipn.NeedsLogin.String() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
loginWarnableCode := string(health.LoginStateWarnable.Code)
|
||||||
|
for _, h := range healthWarnings {
|
||||||
|
if strings.Contains(h, loginWarnableCode) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkInitialAuthState checks if the tsnet server is in an auth failure state
|
||||||
|
// immediately after coming up. Returns true if auth key reissue is needed.
|
||||||
|
func checkInitialAuthState(ctx context.Context, lc *local.Client) (bool, error) {
|
||||||
|
status, err := lc.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("error getting status: %w", err)
|
||||||
|
}
|
||||||
|
return needsAuthKeyReissue(status.BackendState, status.Health), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorAuthHealth watches the IPN bus for auth failures and triggers reissue
|
||||||
|
// when needed. Runs until context is cancelled or auth failure is detected.
|
||||||
|
func monitorAuthHealth(ctx context.Context, lc *local.Client, reissueCh chan<- struct{}, logger *zap.SugaredLogger) error {
|
||||||
|
w, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialHealthState)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to watch IPN bus for auth health: %w", err)
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
n, err := w.Next()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n.Health != nil {
|
||||||
|
if _, ok := n.Health.Warnings[health.LoginStateWarnable.Code]; ok {
|
||||||
|
logger.Info("Auth key failed to authenticate (may be expired or single-use), requesting new key from operator")
|
||||||
|
select {
|
||||||
|
case reissueCh <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuthKeyReissue orchestrates the auth key reissue flow:
|
||||||
|
// 1. Disconnect from control
|
||||||
|
// 2. Set reissue marker in state Secret
|
||||||
|
// 3. Wait for operator to provide new key
|
||||||
|
// 4. Exit cleanly (Kubernetes will restart the pod with the new key)
|
||||||
|
func handleAuthKeyReissue(ctx context.Context, lc *local.Client, kc kubeclient.Client, stateSecretName string, currentAuthKey string, cfgChan <-chan *conf.Config, logger *zap.SugaredLogger) error {
|
||||||
|
if err := lc.DisconnectControl(ctx); err != nil {
|
||||||
|
return fmt.Errorf("error disconnecting from control: %w", err)
|
||||||
|
}
|
||||||
|
if err := authkey.SetReissueAuthKey(ctx, kc, stateSecretName, currentAuthKey, k8sProxyFieldManager); err != nil {
|
||||||
|
return fmt.Errorf("failed to set reissue_authkey in Kubernetes Secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var latestAuthKey string
|
||||||
|
notify := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
// we use this go func to abstract away conf.Config from the shared function
|
||||||
|
go func() {
|
||||||
|
for cfg := range cfgChan {
|
||||||
|
if cfg.Parsed.AuthKey != nil {
|
||||||
|
mu.Lock()
|
||||||
|
latestAuthKey = *cfg.Parsed.AuthKey
|
||||||
|
mu.Unlock()
|
||||||
|
select {
|
||||||
|
case notify <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
getAuthKey := func() string {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return latestAuthKey
|
||||||
|
}
|
||||||
|
clearFn := func(ctx context.Context) error {
|
||||||
|
return authkey.ClearReissueAuthKey(ctx, kc, stateSecretName, k8sProxyFieldManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
return authkey.WaitForAuthKeyReissue(ctx, currentAuthKey, 10*time.Minute, getAuthKey, clearFn, notify)
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !plan9
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"tailscale.com/health"
|
||||||
|
"tailscale.com/kube/kubeapi"
|
||||||
|
"tailscale.com/kube/kubeclient"
|
||||||
|
"tailscale.com/kube/kubetypes"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResetState(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
existingData map[string][]byte
|
||||||
|
podUID string
|
||||||
|
configAuthKey string
|
||||||
|
wantPatched map[string][]byte
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sets_capver_and_pod_uid",
|
||||||
|
existingData: map[string][]byte{
|
||||||
|
kubetypes.KeyDeviceID: []byte("device-123"),
|
||||||
|
kubetypes.KeyDeviceFQDN: []byte("node.tailnet"),
|
||||||
|
kubetypes.KeyDeviceIPs: []byte(`["100.64.0.1"]`),
|
||||||
|
},
|
||||||
|
podUID: "pod-123",
|
||||||
|
configAuthKey: "new-key",
|
||||||
|
wantPatched: map[string][]byte{
|
||||||
|
kubetypes.KeyPodUID: []byte("pod-123"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clears_reissue_marker_when_actioned",
|
||||||
|
existingData: map[string][]byte{
|
||||||
|
kubetypes.KeyReissueAuthkey: []byte("old-key"),
|
||||||
|
},
|
||||||
|
podUID: "pod-123",
|
||||||
|
configAuthKey: "new-key",
|
||||||
|
wantPatched: map[string][]byte{
|
||||||
|
kubetypes.KeyPodUID: []byte("pod-123"),
|
||||||
|
kubetypes.KeyReissueAuthkey: nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keeps_reissue_marker_when_not_actioned",
|
||||||
|
existingData: map[string][]byte{
|
||||||
|
kubetypes.KeyReissueAuthkey: []byte("old-key"),
|
||||||
|
},
|
||||||
|
podUID: "pod-123",
|
||||||
|
configAuthKey: "old-key",
|
||||||
|
wantPatched: map[string][]byte{
|
||||||
|
kubetypes.KeyPodUID: []byte("pod-123"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tt.wantPatched[kubetypes.KeyCapVer] = fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion)
|
||||||
|
|
||||||
|
var patched map[string][]byte
|
||||||
|
kc := &kubeclient.FakeClient{
|
||||||
|
GetSecretImpl: func(ctx context.Context, name string) (*kubeapi.Secret, error) {
|
||||||
|
return &kubeapi.Secret{Data: tt.existingData}, nil
|
||||||
|
},
|
||||||
|
StrategicMergePatchSecretImpl: func(ctx context.Context, name string, s *kubeapi.Secret, fm string) error {
|
||||||
|
patched = s.Data
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := resetState(context.Background(), kc, "test-secret", tt.podUID, tt.configAuthKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resetState() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff := cmp.Diff(tt.wantPatched, patched); diff != "" {
|
||||||
|
t.Errorf("resetState() mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeedsAuthKeyReissue(t *testing.T) {
|
||||||
|
loginWarnableCode := string(health.LoginStateWarnable.Code)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
backendState string
|
||||||
|
health []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "running_healthy",
|
||||||
|
backendState: "Running",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "needs_login",
|
||||||
|
backendState: "NeedsLogin",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "running_with_login_warning",
|
||||||
|
backendState: "Running",
|
||||||
|
health: []string{"warning: " + loginWarnableCode + ": you are logged out"},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "running_with_unrelated_warning",
|
||||||
|
backendState: "Running",
|
||||||
|
health: []string{"dns-not-working"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "running_no_warnings",
|
||||||
|
backendState: "Running",
|
||||||
|
health: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := needsAuthKeyReissue(tt.backendState, tt.health)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("needsAuthKeyReissue() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-167
@@ -9,22 +9,13 @@
|
|||||||
// git-pull-oss.sh having Nix available.
|
// git-pull-oss.sh having Nix available.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
// For the format, see:
|
|
||||||
// See https://gist.github.com/jbeda/5c79d2b1434f0018d693
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"sort"
|
"tailscale.com/cmd/nardump/nardump"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sri = flag.Bool("sri", false, "print SRI")
|
var sri = flag.Bool("sri", false, "print SRI")
|
||||||
@@ -34,167 +25,16 @@ func main() {
|
|||||||
if flag.NArg() != 1 {
|
if flag.NArg() != 1 {
|
||||||
log.Fatal("usage: nardump <dir>")
|
log.Fatal("usage: nardump <dir>")
|
||||||
}
|
}
|
||||||
arg := flag.Arg(0)
|
fsys := os.DirFS(flag.Arg(0))
|
||||||
if err := os.Chdir(arg); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
if *sri {
|
if *sri {
|
||||||
hash := sha256.New()
|
s, err := nardump.SRI(fsys)
|
||||||
if err := writeNAR(hash, os.DirFS(".")); err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
fmt.Printf("sha256-%s\n", base64.StdEncoding.EncodeToString(hash.Sum(nil)))
|
fmt.Println(s)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bw := bufio.NewWriter(os.Stdout)
|
if err := nardump.WriteNAR(os.Stdout, fsys); err != nil {
|
||||||
if err := writeNAR(bw, os.DirFS(".")); err != nil {
|
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
bw.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeNARError is a sentinel panic type that's recovered by writeNAR
|
|
||||||
// and converted into the wrapped error.
|
|
||||||
type writeNARError struct{ err error }
|
|
||||||
|
|
||||||
// narWriter writes NAR files.
|
|
||||||
type narWriter struct {
|
|
||||||
w io.Writer
|
|
||||||
fs fs.FS
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeNAR writes a NAR file to w from the root of fs.
|
|
||||||
func writeNAR(w io.Writer, fs fs.FS) (err error) {
|
|
||||||
defer func() {
|
|
||||||
if e := recover(); e != nil {
|
|
||||||
if we, ok := e.(writeNARError); ok {
|
|
||||||
err = we.err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
panic(e)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
nw := &narWriter{w: w, fs: fs}
|
|
||||||
nw.str("nix-archive-1")
|
|
||||||
return nw.writeDir(".")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (nw *narWriter) writeDir(dirPath string) error {
|
|
||||||
ents, err := fs.ReadDir(nw.fs, dirPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sort.Slice(ents, func(i, j int) bool {
|
|
||||||
return ents[i].Name() < ents[j].Name()
|
|
||||||
})
|
|
||||||
nw.str("(")
|
|
||||||
nw.str("type")
|
|
||||||
nw.str("directory")
|
|
||||||
for _, ent := range ents {
|
|
||||||
nw.str("entry")
|
|
||||||
nw.str("(")
|
|
||||||
nw.str("name")
|
|
||||||
nw.str(ent.Name())
|
|
||||||
nw.str("node")
|
|
||||||
mode := ent.Type()
|
|
||||||
sub := path.Join(dirPath, ent.Name())
|
|
||||||
var err error
|
|
||||||
switch {
|
|
||||||
case mode.IsDir():
|
|
||||||
err = nw.writeDir(sub)
|
|
||||||
case mode.IsRegular():
|
|
||||||
err = nw.writeRegular(sub)
|
|
||||||
case mode&os.ModeSymlink != 0:
|
|
||||||
err = nw.writeSymlink(sub)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported file type %v at %q", sub, mode)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
nw.str(")")
|
|
||||||
}
|
|
||||||
nw.str(")")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (nw *narWriter) writeRegular(path string) error {
|
|
||||||
nw.str("(")
|
|
||||||
nw.str("type")
|
|
||||||
nw.str("regular")
|
|
||||||
fi, err := fs.Stat(nw.fs, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if fi.Mode()&0111 != 0 {
|
|
||||||
nw.str("executable")
|
|
||||||
nw.str("")
|
|
||||||
}
|
|
||||||
contents, err := fs.ReadFile(nw.fs, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
nw.str("contents")
|
|
||||||
if err := writeBytes(nw.w, contents); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
nw.str(")")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (nw *narWriter) writeSymlink(path string) error {
|
|
||||||
nw.str("(")
|
|
||||||
nw.str("type")
|
|
||||||
nw.str("symlink")
|
|
||||||
nw.str("target")
|
|
||||||
// broken symlinks are valid in a nar
|
|
||||||
// given we do os.chdir(dir) and os.dirfs(".") above
|
|
||||||
// readlink now resolves relative links even if they are broken
|
|
||||||
link, err := os.Readlink(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
nw.str(link)
|
|
||||||
nw.str(")")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (nw *narWriter) str(s string) {
|
|
||||||
if err := writeString(nw.w, s); err != nil {
|
|
||||||
panic(writeNARError{err})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeString(w io.Writer, s string) error {
|
|
||||||
var buf [8]byte
|
|
||||||
binary.LittleEndian.PutUint64(buf[:], uint64(len(s)))
|
|
||||||
if _, err := w.Write(buf[:]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := io.WriteString(w, s); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writePad(w, len(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeBytes(w io.Writer, b []byte) error {
|
|
||||||
var buf [8]byte
|
|
||||||
binary.LittleEndian.PutUint64(buf[:], uint64(len(b)))
|
|
||||||
if _, err := w.Write(buf[:]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := w.Write(b); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writePad(w, len(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func writePad(w io.Writer, n int) error {
|
|
||||||
pad := n % 8
|
|
||||||
if pad == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var zeroes [8]byte
|
|
||||||
_, err := w.Write(zeroes[:8-pad])
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// Package nardump writes a NAR (Nix Archive) representation of an
|
||||||
|
// fs.FS to an io.Writer, or summarizes it as a Subresource Integrity
|
||||||
|
// hash, as used by Nix flake.nix vendor and toolchain hashes.
|
||||||
|
//
|
||||||
|
// For the format, see:
|
||||||
|
// https://gist.github.com/jbeda/5c79d2b1434f0018d693
|
||||||
|
package nardump
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteNAR writes a NAR-encoded representation of fsys, rooted at
|
||||||
|
// the FS root, to w.
|
||||||
|
//
|
||||||
|
// The encoder issues many small writes; if w is not already a
|
||||||
|
// *bufio.Writer, WriteNAR wraps it in one and flushes on return so
|
||||||
|
// the caller doesn't have to.
|
||||||
|
//
|
||||||
|
// fsys must implement fs.ReadLinkFS to encode any symlinks it
|
||||||
|
// contains; os.DirFS satisfies this on Go 1.25+.
|
||||||
|
func WriteNAR(w io.Writer, fsys fs.FS) (err error) {
|
||||||
|
defer func() {
|
||||||
|
if e := recover(); e != nil {
|
||||||
|
if we, ok := e.(writeNARError); ok {
|
||||||
|
err = we.err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
panic(e)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
bw, ok := w.(*bufio.Writer)
|
||||||
|
if !ok {
|
||||||
|
bw = bufio.NewWriter(w)
|
||||||
|
defer func() {
|
||||||
|
if flushErr := bw.Flush(); err == nil {
|
||||||
|
err = flushErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
nw := &narWriter{w: bw, fs: fsys}
|
||||||
|
nw.str("nix-archive-1")
|
||||||
|
return nw.writeDir(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SRI returns the Subresource Integrity hash of the NAR encoding of
|
||||||
|
// fsys, in the form "sha256-<base64>". This is the format Nix
|
||||||
|
// expects for vendorHash and similar fields.
|
||||||
|
func SRI(fsys fs.FS) (string, error) {
|
||||||
|
h := sha256.New()
|
||||||
|
if err := WriteNAR(h, fsys); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "sha256-" + base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeNARError is a sentinel panic type that's recovered by
|
||||||
|
// WriteNAR and converted into the wrapped error.
|
||||||
|
type writeNARError struct{ err error }
|
||||||
|
|
||||||
|
// narWriter writes NAR files.
|
||||||
|
type narWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
fs fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nw *narWriter) writeDir(dirPath string) error {
|
||||||
|
ents, err := fs.ReadDir(nw.fs, dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sort.Slice(ents, func(i, j int) bool {
|
||||||
|
return ents[i].Name() < ents[j].Name()
|
||||||
|
})
|
||||||
|
nw.str("(")
|
||||||
|
nw.str("type")
|
||||||
|
nw.str("directory")
|
||||||
|
for _, ent := range ents {
|
||||||
|
nw.str("entry")
|
||||||
|
nw.str("(")
|
||||||
|
nw.str("name")
|
||||||
|
nw.str(ent.Name())
|
||||||
|
nw.str("node")
|
||||||
|
mode := ent.Type()
|
||||||
|
sub := path.Join(dirPath, ent.Name())
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case mode.IsDir():
|
||||||
|
err = nw.writeDir(sub)
|
||||||
|
case mode.IsRegular():
|
||||||
|
err = nw.writeRegular(sub)
|
||||||
|
case mode&fs.ModeSymlink != 0:
|
||||||
|
err = nw.writeSymlink(sub)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported file type %v at %q", sub, mode)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nw.str(")")
|
||||||
|
}
|
||||||
|
nw.str(")")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nw *narWriter) writeRegular(p string) error {
|
||||||
|
nw.str("(")
|
||||||
|
nw.str("type")
|
||||||
|
nw.str("regular")
|
||||||
|
fi, err := fs.Stat(nw.fs, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fi.Mode()&0111 != 0 {
|
||||||
|
nw.str("executable")
|
||||||
|
nw.str("")
|
||||||
|
}
|
||||||
|
contents, err := fs.ReadFile(nw.fs, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nw.str("contents")
|
||||||
|
if err := writeBytes(nw.w, contents); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nw.str(")")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nw *narWriter) writeSymlink(p string) error {
|
||||||
|
nw.str("(")
|
||||||
|
nw.str("type")
|
||||||
|
nw.str("symlink")
|
||||||
|
nw.str("target")
|
||||||
|
link, err := fs.ReadLink(nw.fs, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nw.str(link)
|
||||||
|
nw.str(")")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (nw *narWriter) str(s string) {
|
||||||
|
if err := writeString(nw.w, s); err != nil {
|
||||||
|
panic(writeNARError{err})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeString(w io.Writer, s string) error {
|
||||||
|
var buf [8]byte
|
||||||
|
binary.LittleEndian.PutUint64(buf[:], uint64(len(s)))
|
||||||
|
if _, err := w.Write(buf[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(w, s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writePad(w, len(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBytes(w io.Writer, b []byte) error {
|
||||||
|
var buf [8]byte
|
||||||
|
binary.LittleEndian.PutUint64(buf[:], uint64(len(b)))
|
||||||
|
if _, err := w.Write(buf[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writePad(w, len(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePad(w io.Writer, n int) error {
|
||||||
|
pad := n % 8
|
||||||
|
if pad == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var zeroes [8]byte
|
||||||
|
_, err := w.Write(zeroes[:8-pad])
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package nardump
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupTmpdir sets up a known golden layout, covering all allowed file/folder types in a nar.
|
||||||
|
func setupTmpdir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
tmpdir := t.TempDir()
|
||||||
|
must := func(err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
must(os.MkdirAll(filepath.Join(tmpdir, "sub/dir"), 0755))
|
||||||
|
must(os.Symlink("brokenfile", filepath.Join(tmpdir, "brokenlink")))
|
||||||
|
must(os.Symlink("sub/dir", filepath.Join(tmpdir, "dirl")))
|
||||||
|
must(os.Symlink("/abs/nonexistentdir", filepath.Join(tmpdir, "dirb")))
|
||||||
|
f, err := os.Create(filepath.Join(tmpdir, "sub/dir/file1"))
|
||||||
|
must(err)
|
||||||
|
f.Close()
|
||||||
|
f, err = os.Create(filepath.Join(tmpdir, "file2m"))
|
||||||
|
must(err)
|
||||||
|
must(f.Truncate(2 * 1024 * 1024))
|
||||||
|
f.Close()
|
||||||
|
must(os.Symlink("../file2m", filepath.Join(tmpdir, "sub/goodlink")))
|
||||||
|
return tmpdir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNAR(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Skip test on Windows as the Nix package manager is not supported on this platform
|
||||||
|
t.Skip("nix package manager is not available on Windows")
|
||||||
|
}
|
||||||
|
dir := setupTmpdir(t)
|
||||||
|
// obtained via `nix-store --dump /tmp/... | sha256sum` of the above test dir
|
||||||
|
const expected = "727613a36f41030e93a4abf2649c3ec64a2757ccff364e3f6f7d544eb976e442"
|
||||||
|
h := sha256.New()
|
||||||
|
if err := WriteNAR(h, os.DirFS(dir)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := fmt.Sprintf("%x", h.Sum(nil)); got != expected {
|
||||||
|
t.Fatalf("sha256sum of nar: got %s, want %s", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupTmpdir sets up a known golden layout, covering all allowed file/folder types in a nar
|
|
||||||
func setupTmpdir(t *testing.T) string {
|
|
||||||
tmpdir := t.TempDir()
|
|
||||||
pwd, _ := os.Getwd()
|
|
||||||
os.Chdir(tmpdir)
|
|
||||||
defer os.Chdir(pwd)
|
|
||||||
os.MkdirAll("sub/dir", 0755)
|
|
||||||
os.Symlink("brokenfile", "brokenlink")
|
|
||||||
os.Symlink("sub/dir", "dirl")
|
|
||||||
os.Symlink("/abs/nonexistentdir", "dirb")
|
|
||||||
os.Create("sub/dir/file1")
|
|
||||||
f, _ := os.Create("file2m")
|
|
||||||
_ = f.Truncate(2 * 1024 * 1024)
|
|
||||||
f.Close()
|
|
||||||
os.Symlink("../file2m", "sub/goodlink")
|
|
||||||
return tmpdir
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteNar(t *testing.T) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
// Skip test on Windows as the Nix package manager is not supported on this platform
|
|
||||||
t.Skip("nix package manager is not available on Windows")
|
|
||||||
}
|
|
||||||
dir := setupTmpdir(t)
|
|
||||||
t.Run("nar", func(t *testing.T) {
|
|
||||||
// obtained via `nix-store --dump /tmp/... | sha256sum` of the above test dir
|
|
||||||
expected := "727613a36f41030e93a4abf2649c3ec64a2757ccff364e3f6f7d544eb976e442"
|
|
||||||
h := sha256.New()
|
|
||||||
os.Chdir(dir)
|
|
||||||
err := writeNAR(h, os.DirFS("."))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
hash := fmt.Sprintf("%x", h.Sum(nil))
|
|
||||||
if expected != hash {
|
|
||||||
t.Fatal("sha256sum of nar not matched", hash, expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -291,7 +291,7 @@ func (p *proxy) serve(sessionID int64, c net.Conn) error {
|
|||||||
Certificates: p.downstreamCert,
|
Certificates: p.downstreamCert,
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
})
|
})
|
||||||
if err = uptc.HandshakeContext(ctx); err != nil {
|
if err = s.HandshakeContext(ctx); err != nil {
|
||||||
p.errors.Add("client-tls", 1)
|
p.errors.Add("client-tls", 1)
|
||||||
return fmt.Errorf("client TLS handshake: %v", err)
|
return fmt.Errorf("client TLS handshake: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,9 +138,9 @@ func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finally, start mainloop to configure app connector based on information
|
// Finally, start mainloop to configure app connector based on information
|
||||||
// in the netmap.
|
// in the self node's CapMap. We set NotifyInitialNetMap so the first
|
||||||
// We set the NotifyInitialNetMap flag so we will always get woken with the
|
// Notify carries the current self node (now via Notify.SelfChange);
|
||||||
// current netmap, before only being woken on changes.
|
// subsequent self changes wake us up too.
|
||||||
bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialNetMap)
|
bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialNetMap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("watching IPN bus: %v", err)
|
log.Fatalf("watching IPN bus: %v", err)
|
||||||
@@ -155,10 +155,13 @@ func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, pro
|
|||||||
log.Fatalf("reading IPN bus: %v", err)
|
log.Fatalf("reading IPN bus: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetMap contains app-connector configuration
|
self := msg.SelfChange
|
||||||
if nm := msg.NetMap; nm != nil && nm.SelfNode.Valid() {
|
if self == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var c appctype.AppConnectorConfig
|
var c appctype.AppConnectorConfig
|
||||||
nmConf, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorConfig](nm.SelfNode.CapMap(), configCapKey)
|
// View() lets us reuse the existing CapView decoder.
|
||||||
|
nmConf, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorConfig](self.View().CapMap(), configCapKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("failed to read app connector configuration from coordination server: %v", err)
|
log.Printf("failed to read app connector configuration from coordination server: %v", err)
|
||||||
} else if len(nmConf) > 0 {
|
} else if len(nmConf) > 0 {
|
||||||
@@ -178,7 +181,6 @@ func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, pro
|
|||||||
s.srv.Configure(&c)
|
s.srv.Configure(&c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
type sniproxy struct {
|
type sniproxy struct {
|
||||||
srv Server
|
srv Server
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var socket = flag.String("socket", paths.DefaultTailscaledSocket(), "path to tailscaled socket")
|
var socket = flag.String("socket", paths.DefaultTailscaledSocket(), "path to tailscaled socket")
|
||||||
|
var theme = flag.String("theme", "dark", "color theme for Tailscale icon: dark, dark:nobg, light, light:nobg")
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
lc := &local.Client{Socket: *socket}
|
lc := &local.Client{Socket: *socket}
|
||||||
|
systray.SetTheme(*theme)
|
||||||
new(systray.Menu).Run(lc)
|
new(systray.Menu).Run(lc)
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-10
@@ -28,6 +28,7 @@ import (
|
|||||||
"tailscale.com/feature"
|
"tailscale.com/feature"
|
||||||
"tailscale.com/paths"
|
"tailscale.com/paths"
|
||||||
"tailscale.com/util/slicesx"
|
"tailscale.com/util/slicesx"
|
||||||
|
"tailscale.com/util/testenv"
|
||||||
"tailscale.com/version/distro"
|
"tailscale.com/version/distro"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,8 +93,8 @@ var localClient = local.Client{
|
|||||||
Socket: paths.DefaultTailscaledSocket(),
|
Socket: paths.DefaultTailscaledSocket(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run runs the CLI. The args do not include the binary name.
|
// RunWithContext runs the CLI. The args do not include the binary name.
|
||||||
func Run(args []string) (err error) {
|
func RunWithContext(ctx context.Context, args []string) (err error) {
|
||||||
if runtime.GOOS == "linux" && os.Getenv("GOKRAZY_FIRST_START") == "1" && distro.Get() == distro.Gokrazy && os.Getppid() == 1 && len(args) == 0 {
|
if runtime.GOOS == "linux" && os.Getenv("GOKRAZY_FIRST_START") == "1" && distro.Get() == distro.Gokrazy && os.Getppid() == 1 && len(args) == 0 {
|
||||||
// We're running on gokrazy and the user did not specify 'up'.
|
// We're running on gokrazy and the user did not specify 'up'.
|
||||||
// Don't run the tailscale CLI and spam logs with usage; just exit.
|
// Don't run the tailscale CLI and spam logs with usage; just exit.
|
||||||
@@ -163,7 +164,7 @@ func Run(args []string) (err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = rootCmd.Run(context.Background())
|
err = rootCmd.Run(ctx)
|
||||||
if local.IsAccessDeniedError(err) && os.Getuid() != 0 && runtime.GOOS != "windows" {
|
if local.IsAccessDeniedError(err) && os.Getuid() != 0 && runtime.GOOS != "windows" {
|
||||||
return fmt.Errorf("%v\n\nUse 'sudo tailscale %s'.\nTo not require root, use 'sudo tailscale set --operator=$USER' once.", err, strings.Join(args, " "))
|
return fmt.Errorf("%v\n\nUse 'sudo tailscale %s'.\nTo not require root, use 'sudo tailscale set --operator=$USER' once.", err, strings.Join(args, " "))
|
||||||
}
|
}
|
||||||
@@ -173,6 +174,11 @@ func Run(args []string) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run is equivalent to calling [RunWithContext] with the background context.
|
||||||
|
func Run(args []string) (err error) {
|
||||||
|
return RunWithContext(context.Background(), args)
|
||||||
|
}
|
||||||
|
|
||||||
type onceFlagValue struct {
|
type onceFlagValue struct {
|
||||||
flag.Value
|
flag.Value
|
||||||
set bool
|
set bool
|
||||||
@@ -194,17 +200,39 @@ func (v *onceFlagValue) IsBoolFlag() bool {
|
|||||||
return ok && bf.IsBoolFlag()
|
return ok && bf.IsBoolFlag()
|
||||||
}
|
}
|
||||||
|
|
||||||
// noDupFlagify modifies c recursively to make all the
|
// noDupFlagify modifies c recursively to make all the flag values be
|
||||||
// flag values be wrappers that permit setting the value
|
// wrappers that permit setting the value at most once. If tb is
|
||||||
// at most once.
|
// non-nil, the original values are restored when the test completes.
|
||||||
func noDupFlagify(c *ffcli.Command) {
|
func noDupFlagify(c *ffcli.Command, tb testenv.TB) {
|
||||||
|
if tb == nil && testenv.InTest() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type restore struct {
|
||||||
|
f *flag.Flag
|
||||||
|
v flag.Value
|
||||||
|
}
|
||||||
|
var restores []restore
|
||||||
|
var walk func(*ffcli.Command)
|
||||||
|
walk = func(c *ffcli.Command) {
|
||||||
if c.FlagSet != nil {
|
if c.FlagSet != nil {
|
||||||
c.FlagSet.VisitAll(func(f *flag.Flag) {
|
c.FlagSet.VisitAll(func(f *flag.Flag) {
|
||||||
|
if tb != nil {
|
||||||
|
restores = append(restores, restore{f, f.Value})
|
||||||
|
}
|
||||||
f.Value = &onceFlagValue{Value: f.Value}
|
f.Value = &onceFlagValue{Value: f.Value}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, sub := range c.Subcommands {
|
for _, sub := range c.Subcommands {
|
||||||
noDupFlagify(sub)
|
walk(sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(c)
|
||||||
|
if tb != nil {
|
||||||
|
tb.Cleanup(func() {
|
||||||
|
for _, r := range restores {
|
||||||
|
r.f.Value = r.v
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +249,7 @@ var (
|
|||||||
_ func() *ffcli.Command
|
_ func() *ffcli.Command
|
||||||
)
|
)
|
||||||
|
|
||||||
func newRootCmd() *ffcli.Command {
|
func newRootCmd(tb ...testenv.TB) *ffcli.Command {
|
||||||
rootfs := newFlagSet("tailscale")
|
rootfs := newFlagSet("tailscale")
|
||||||
rootfs.Func("socket", "path to tailscaled socket", func(s string) error {
|
rootfs.Func("socket", "path to tailscaled socket", func(s string) error {
|
||||||
localClient.Socket = s
|
localClient.Socket = s
|
||||||
@@ -303,7 +331,11 @@ change in the future.
|
|||||||
})
|
})
|
||||||
|
|
||||||
ffcomplete.Inject(rootCmd, func(c *ffcli.Command) { c.LongHelp = hidden + c.LongHelp }, usageFunc)
|
ffcomplete.Inject(rootCmd, func(c *ffcli.Command) { c.LongHelp = hidden + c.LongHelp }, usageFunc)
|
||||||
noDupFlagify(rootCmd)
|
var t testenv.TB
|
||||||
|
if len(tb) > 0 {
|
||||||
|
t = tb[0]
|
||||||
|
}
|
||||||
|
noDupFlagify(rootCmd, t)
|
||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -779,11 +779,43 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
|||||||
wantErr: `--exit-node-allow-lan-access can only be used with --exit-node`,
|
wantErr: `--exit-node-allow-lan-access can only be used with --exit-node`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "error_tag_prefix",
|
name: "error_tag_bad_prefix",
|
||||||
args: upArgsT{
|
args: upArgsT{
|
||||||
advertiseTags: "foo",
|
advertiseTags: "notatag:foo",
|
||||||
|
},
|
||||||
|
wantErr: `tag: "notatag:foo": tags must start with 'tag:'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tag_auto_prefix",
|
||||||
|
args: upArgsFromOSArgs("linux", "--advertise-tags=foo,bar"),
|
||||||
|
want: &ipn.Prefs{
|
||||||
|
ControlURL: ipn.DefaultControlURL,
|
||||||
|
WantRunning: true,
|
||||||
|
CorpDNS: true,
|
||||||
|
AdvertiseTags: []string{"tag:foo", "tag:bar"},
|
||||||
|
NoSNAT: false,
|
||||||
|
NoStatefulFiltering: "true",
|
||||||
|
NetfilterMode: preftype.NetfilterOn,
|
||||||
|
AutoUpdate: ipn.AutoUpdatePrefs{
|
||||||
|
Check: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tag_mixed_prefix",
|
||||||
|
args: upArgsFromOSArgs("linux", "--advertise-tags=tag:foo,bar"),
|
||||||
|
want: &ipn.Prefs{
|
||||||
|
ControlURL: ipn.DefaultControlURL,
|
||||||
|
WantRunning: true,
|
||||||
|
CorpDNS: true,
|
||||||
|
AdvertiseTags: []string{"tag:foo", "tag:bar"},
|
||||||
|
NoSNAT: false,
|
||||||
|
NoStatefulFiltering: "true",
|
||||||
|
NetfilterMode: preftype.NetfilterOn,
|
||||||
|
AutoUpdate: ipn.AutoUpdatePrefs{
|
||||||
|
Check: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
wantErr: `tag: "foo": tags must start with 'tag:'`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "error_long_hostname",
|
name: "error_long_hostname",
|
||||||
@@ -1618,7 +1650,7 @@ func TestNoDups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd(t)
|
||||||
makeQuietContinueOnError(cmd)
|
makeQuietContinueOnError(cmd)
|
||||||
err := cmd.Parse(tt.args)
|
err := cmd.Parse(tt.args)
|
||||||
if got := fmt.Sprint(err); got != tt.want {
|
if got := fmt.Sprint(err); got != tt.want {
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ import (
|
|||||||
"github.com/peterbourgon/ff/v3/ffcli"
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
"k8s.io/client-go/util/homedir"
|
"k8s.io/client-go/util/homedir"
|
||||||
"sigs.k8s.io/yaml"
|
"sigs.k8s.io/yaml"
|
||||||
"tailscale.com/ipn"
|
|
||||||
"tailscale.com/ipn/ipnstate"
|
"tailscale.com/ipn/ipnstate"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/types/netmap"
|
|
||||||
"tailscale.com/util/dnsname"
|
"tailscale.com/util/dnsname"
|
||||||
"tailscale.com/version"
|
"tailscale.com/version"
|
||||||
)
|
)
|
||||||
@@ -98,12 +96,12 @@ func runConfigureKubeconfig(ctx context.Context, args []string) error {
|
|||||||
if st.BackendState != "Running" {
|
if st.BackendState != "Running" {
|
||||||
return errors.New("Tailscale is not running")
|
return errors.New("Tailscale is not running")
|
||||||
}
|
}
|
||||||
nm, err := getNetMap(ctx)
|
dnsCfg, err := getDNSConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
targetFQDN, err := nodeOrServiceDNSNameFromArg(st, nm, hostOrFQDNOrIP)
|
targetFQDN, err := nodeOrServiceDNSNameFromArg(st, dnsCfg, hostOrFQDNOrIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -240,14 +238,14 @@ func setKubeconfigForPeer(scheme, fqdn, filePath string) error {
|
|||||||
// nodeOrServiceDNSNameFromArg returns the PeerStatus.DNSName value from a peer
|
// nodeOrServiceDNSNameFromArg returns the PeerStatus.DNSName value from a peer
|
||||||
// in st that matches the input arg which can be a base name, full DNS name, or
|
// in st that matches the input arg which can be a base name, full DNS name, or
|
||||||
// an IP. If none is found, it looks for a Tailscale Service
|
// an IP. If none is found, it looks for a Tailscale Service
|
||||||
func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, nm *netmap.NetworkMap, arg string) (string, error) {
|
func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, dns *tailcfg.DNSConfig, arg string) (string, error) {
|
||||||
// First check for a node DNS name.
|
// First check for a node DNS name.
|
||||||
if dnsName, ok := nodeDNSNameFromArg(st, arg); ok {
|
if dnsName, ok := nodeDNSNameFromArg(st, arg); ok {
|
||||||
return dnsName, nil
|
return dnsName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not found, check for a Tailscale Service DNS name.
|
// If not found, check for a Tailscale Service DNS name.
|
||||||
rec, ok := serviceDNSRecordFromNetMap(nm, arg)
|
rec, ok := serviceDNSRecordFromDNSConfig(dns, arg)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("no peer found for %q", arg)
|
return "", fmt.Errorf("no peer found for %q", arg)
|
||||||
}
|
}
|
||||||
@@ -269,25 +267,13 @@ func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, nm *netmap.NetworkMap, arg
|
|||||||
return "", fmt.Errorf("%q is in MagicDNS, but is not currently reachable on any known peer", arg)
|
return "", fmt.Errorf("%q is in MagicDNS, but is not currently reachable on any known peer", arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
|
func getDNSConfig(ctx context.Context) (*tailcfg.DNSConfig, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
return localClient.DNSConfig(ctx)
|
||||||
watcher, err := localClient.WatchIPNBus(ctx, ipn.NotifyInitialNetMap)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer watcher.Close()
|
|
||||||
|
|
||||||
n, err := watcher.Next()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return n.NetMap, nil
|
func serviceDNSRecordFromDNSConfig(dns *tailcfg.DNSConfig, arg string) (rec tailcfg.DNSRecord, ok bool) {
|
||||||
}
|
|
||||||
|
|
||||||
func serviceDNSRecordFromNetMap(nm *netmap.NetworkMap, arg string) (rec tailcfg.DNSRecord, ok bool) {
|
|
||||||
argIP, _ := netip.ParseAddr(arg)
|
argIP, _ := netip.ParseAddr(arg)
|
||||||
argFQDN, err := dnsname.ToFQDN(arg)
|
argFQDN, err := dnsname.ToFQDN(arg)
|
||||||
argFQDNValid := err == nil
|
argFQDNValid := err == nil
|
||||||
@@ -295,7 +281,7 @@ func serviceDNSRecordFromNetMap(nm *netmap.NetworkMap, arg string) (rec tailcfg.
|
|||||||
return rec, false
|
return rec, false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rec := range nm.DNS.ExtraRecords {
|
for _, rec := range dns.ExtraRecords {
|
||||||
if argIP.IsValid() {
|
if argIP.IsValid() {
|
||||||
recIP, _ := netip.ParseAddr(rec.Value)
|
recIP, _ := netip.ParseAddr(rec.Value)
|
||||||
if recIP == argIP {
|
if recIP == argIP {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func init() {
|
|||||||
maybeSystrayCmd = systrayConfigCmd
|
maybeSystrayCmd = systrayConfigCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
var systrayArgs struct {
|
var configSystrayArgs struct {
|
||||||
initSystem string
|
initSystem string
|
||||||
installStartup bool
|
installStartup bool
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ func systrayConfigCmd() *ffcli.Command {
|
|||||||
Exec: configureSystray,
|
Exec: configureSystray,
|
||||||
FlagSet: (func() *flag.FlagSet {
|
FlagSet: (func() *flag.FlagSet {
|
||||||
fs := newFlagSet("systray")
|
fs := newFlagSet("systray")
|
||||||
fs.StringVar(&systrayArgs.initSystem, "enable-startup", "",
|
fs.StringVar(&configSystrayArgs.initSystem, "enable-startup", "",
|
||||||
"Install startup script for init system. Currently supported systems are [systemd, freedesktop].")
|
"Install startup script for init system. Currently supported systems are [systemd, freedesktop].")
|
||||||
return fs
|
return fs
|
||||||
})(),
|
})(),
|
||||||
@@ -40,8 +40,8 @@ func systrayConfigCmd() *ffcli.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func configureSystray(_ context.Context, _ []string) error {
|
func configureSystray(_ context.Context, _ []string) error {
|
||||||
if systrayArgs.initSystem != "" {
|
if configSystrayArgs.initSystem != "" {
|
||||||
if err := systray.InstallStartupScript(systrayArgs.initSystem); err != nil {
|
if err := systray.InstallStartupScript(configSystrayArgs.initSystem); err != nil {
|
||||||
fmt.Printf("%s\n\n", err.Error())
|
fmt.Printf("%s\n\n", err.Error())
|
||||||
return flag.ErrHelp
|
return flag.ErrHelp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -670,18 +670,11 @@ func runNetmap(ctx context.Context, args []string) error {
|
|||||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var mask ipn.NotifyWatchOpt = ipn.NotifyInitialNetMap
|
raw, err := localClient.DebugResultJSON(ctx, "current-netmap")
|
||||||
watcher, err := localClient.WatchIPNBus(ctx, mask)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer watcher.Close()
|
j, _ := json.MarshalIndent(raw, "", "\t")
|
||||||
|
|
||||||
n, err := watcher.Next()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
j, _ := json.MarshalIndent(n.NetMap, "", "\t")
|
|
||||||
fmt.Printf("%s\n", j)
|
fmt.Printf("%s\n", j)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/peterbourgon/ff/v3/ffcli"
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
"tailscale.com/cmd/tailscale/cli/jsonoutput"
|
"tailscale.com/cmd/tailscale/cli/jsonoutput"
|
||||||
"tailscale.com/ipn"
|
|
||||||
"tailscale.com/types/dnstype"
|
"tailscale.com/types/dnstype"
|
||||||
"tailscale.com/types/netmap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var dnsStatusCmd = &ffcli.Command{
|
var dnsStatusCmd = &ffcli.Command{
|
||||||
@@ -120,11 +118,10 @@ func runDNSStatus(ctx context.Context, args []string) error {
|
|||||||
SelfDNSName: s.Self.DNSName,
|
SelfDNSName: s.Self.DNSName,
|
||||||
}
|
}
|
||||||
|
|
||||||
netMap, err := fetchNetMap()
|
dnsConfig, err := localClient.DNSConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to fetch network map: %w", err)
|
return fmt.Errorf("failed to fetch DNS config: %w", err)
|
||||||
}
|
}
|
||||||
dnsConfig := netMap.DNS
|
|
||||||
|
|
||||||
for _, r := range dnsConfig.Resolvers {
|
for _, r := range dnsConfig.Resolvers {
|
||||||
data.Resolvers = append(data.Resolvers, makeDNSResolverInfo(r))
|
data.Resolvers = append(data.Resolvers, makeDNSResolverInfo(r))
|
||||||
@@ -357,19 +354,3 @@ func formatDNSStatusText(data *jsonoutput.DNSStatusResult, all bool) string {
|
|||||||
fmt.Fprintf(&sb, "[this is a preliminary version of this command; the output format may change in the future]\n")
|
fmt.Fprintf(&sb, "[this is a preliminary version of this command; the output format may change in the future]\n")
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchNetMap() (netMap *netmap.NetworkMap, err error) {
|
|
||||||
w, err := localClient.WatchIPNBus(context.Background(), ipn.NotifyInitialNetMap)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer w.Close()
|
|
||||||
notify, err := w.Next()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if notify.NetMap == nil {
|
|
||||||
return nil, fmt.Errorf("no network map yet available, please try again later")
|
|
||||||
}
|
|
||||||
return notify.NetMap, nil
|
|
||||||
}
|
|
||||||
|
|||||||
+184
-15
@@ -32,6 +32,7 @@ import (
|
|||||||
"tailscale.com/client/tailscale/apitype"
|
"tailscale.com/client/tailscale/apitype"
|
||||||
"tailscale.com/cmd/tailscale/cli/ffcomplete"
|
"tailscale.com/cmd/tailscale/cli/ffcomplete"
|
||||||
"tailscale.com/envknob"
|
"tailscale.com/envknob"
|
||||||
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnstate"
|
"tailscale.com/ipn/ipnstate"
|
||||||
"tailscale.com/net/tsaddr"
|
"tailscale.com/net/tsaddr"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -78,6 +79,7 @@ var fileCpCmd = &ffcli.Command{
|
|||||||
fs.StringVar(&cpArgs.name, "name", "", "alternate filename to use, especially useful when <file> is \"-\" (stdin)")
|
fs.StringVar(&cpArgs.name, "name", "", "alternate filename to use, especially useful when <file> is \"-\" (stdin)")
|
||||||
fs.BoolVar(&cpArgs.verbose, "verbose", false, "verbose output")
|
fs.BoolVar(&cpArgs.verbose, "verbose", false, "verbose output")
|
||||||
fs.BoolVar(&cpArgs.targets, "targets", false, "list possible file cp targets")
|
fs.BoolVar(&cpArgs.targets, "targets", false, "list possible file cp targets")
|
||||||
|
fs.DurationVar(&cpArgs.updateInterval, "update-interval", 250*time.Millisecond, "how often to repaint the progress line; zero or negative disables progress display entirely")
|
||||||
return fs
|
return fs
|
||||||
})(),
|
})(),
|
||||||
}
|
}
|
||||||
@@ -86,6 +88,7 @@ var cpArgs struct {
|
|||||||
name string
|
name string
|
||||||
verbose bool
|
verbose bool
|
||||||
targets bool
|
targets bool
|
||||||
|
updateInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCp(ctx context.Context, args []string) error {
|
func runCp(ctx context.Context, args []string) error {
|
||||||
@@ -119,9 +122,6 @@ func runCp(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("can't send to %s: %v", target, err)
|
return fmt.Errorf("can't send to %s: %v", target, err)
|
||||||
}
|
}
|
||||||
if isOffline {
|
|
||||||
fmt.Fprintf(Stderr, "# warning: %s is offline\n", target)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) > 1 {
|
if len(files) > 1 {
|
||||||
if cpArgs.name != "" {
|
if cpArgs.name != "" {
|
||||||
@@ -132,7 +132,51 @@ func runCp(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, fileArg := range files {
|
// outFiles tracks per-name push state, populated by a goroutine subscribed
|
||||||
|
// to the IPN bus. tailscaled's OutgoingFile.Sent is the bytes-pulled-toward-
|
||||||
|
// peerAPI signal; it stays at 0 until the peerAPI request body is actually
|
||||||
|
// being read, which is what we want both for the progress display and for
|
||||||
|
// disarming the offline warning. The CLI's local-side bytes counter would
|
||||||
|
// say "100% sent" the moment net/http buffers a small body into the local
|
||||||
|
// unix-socket conn to tailscaled, well before the peer has heard a thing.
|
||||||
|
type pushState struct {
|
||||||
|
sent atomic.Int64
|
||||||
|
warnTimer *time.Timer // disarmed on first byte sent to peerAPI; nil after
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
outMu sync.Mutex
|
||||||
|
outFiles = map[string]*pushState{} // keyed by file name
|
||||||
|
)
|
||||||
|
|
||||||
|
busCtx, cancelBus := context.WithCancel(ctx)
|
||||||
|
defer cancelBus()
|
||||||
|
go watchOutgoingFiles(busCtx, stableID, func(name string, sent int64) {
|
||||||
|
outMu.Lock()
|
||||||
|
ps := outFiles[name]
|
||||||
|
outMu.Unlock()
|
||||||
|
if ps == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Only ever advance ps.sent forward. Bus updates can arrive late
|
||||||
|
// (after the success path below has already written contentLength
|
||||||
|
// to ps.sent for an instant final-100% paint), so we'd otherwise
|
||||||
|
// regress the count and the progress printer would compute a
|
||||||
|
// negative delta on its next tick.
|
||||||
|
for {
|
||||||
|
old := ps.sent.Load()
|
||||||
|
if sent <= old {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ps.sent.CompareAndSwap(old, sent) {
|
||||||
|
if old == 0 && ps.warnTimer != nil {
|
||||||
|
ps.warnTimer.Stop()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for i, fileArg := range files {
|
||||||
var fileContents *countingReader
|
var fileContents *countingReader
|
||||||
var name = cpArgs.name
|
var name = cpArgs.name
|
||||||
var contentLength int64 = -1
|
var contentLength int64 = -1
|
||||||
@@ -175,16 +219,57 @@ func runCp(ctx context.Context, args []string) error {
|
|||||||
log.Printf("sending %q to %v/%v/%v ...", name, target, ip, stableID)
|
log.Printf("sending %q to %v/%v/%v ...", name, target, ip, stableID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register this file with the watcher and, for the first file only,
|
||||||
|
// arm a timer that warns the user if no bytes have flowed to peerAPI
|
||||||
|
// after a few seconds. The watcher disarms it on first byte; PushFile
|
||||||
|
// returning also disarms it (cleanup, below). We don't gate on the
|
||||||
|
// netmap's Online bit (which can lag reality), but we do use it to
|
||||||
|
// pick between two warning messages.
|
||||||
|
ps := &pushState{}
|
||||||
|
if i == 0 {
|
||||||
|
ps.warnTimer = time.AfterFunc(3*time.Second, func() {
|
||||||
|
// vtRestartLine clears whatever (possibly progress) was on
|
||||||
|
// the current line, then we print the warning + \n so the
|
||||||
|
// next progress redraw lands on a fresh line below.
|
||||||
|
const vtRestartLine = "\r\x1b[K"
|
||||||
|
if isOffline {
|
||||||
|
fmt.Fprintf(Stderr, "%s# warning: %s is reportedly offline; trying anyway\n", vtRestartLine, target)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(Stderr, "%s# warning: %s is not replying; trying anyway\n", vtRestartLine, target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
outMu.Lock()
|
||||||
|
outFiles[name] = ps
|
||||||
|
outMu.Unlock()
|
||||||
|
|
||||||
var group sync.WaitGroup
|
var group sync.WaitGroup
|
||||||
ctxProgress, cancelProgress := context.WithCancel(ctx)
|
ctxProgress, cancelProgress := context.WithCancel(ctx)
|
||||||
defer cancelProgress()
|
defer cancelProgress()
|
||||||
if isatty.IsTerminal(os.Stderr.Fd()) {
|
if cpArgs.updateInterval > 0 && isatty.IsTerminal(os.Stderr.Fd()) {
|
||||||
group.Go(func() { progressPrinter(ctxProgress, name, fileContents.n.Load, contentLength) })
|
group.Go(func() {
|
||||||
|
progressPrinter(ctxProgress, name, ps.sent.Load, contentLength, cpArgs.updateInterval)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
err := localClient.PushFile(ctx, stableID, contentLength, name, fileContents)
|
err := localClient.PushFile(ctx, stableID, contentLength, name, fileContents)
|
||||||
|
if err == nil {
|
||||||
|
// PushFile can finish faster than the IPN bus delivers a final
|
||||||
|
// OutgoingFile update, leaving the progress display stuck at 0%.
|
||||||
|
// Synthesize a "fully done" count before stopping the printer so
|
||||||
|
// its final paint shows 100%. For stdin (contentLength == -1) we
|
||||||
|
// don't know the size, so fall back to the local read count.
|
||||||
|
if contentLength >= 0 {
|
||||||
|
ps.sent.Store(contentLength)
|
||||||
|
} else {
|
||||||
|
ps.sent.Store(fileContents.n.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
cancelProgress()
|
cancelProgress()
|
||||||
group.Wait() // wait for progress printer to stop before reporting the error
|
group.Wait() // wait for progress printer to stop before reporting the error
|
||||||
|
if ps.warnTimer != nil {
|
||||||
|
ps.warnTimer.Stop()
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -195,15 +280,71 @@ func runCp(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func progressPrinter(ctx context.Context, name string, contentCount func() int64, contentLength int64) {
|
// watchOutgoingFiles subscribes to the IPN bus and invokes onUpdate once
|
||||||
|
// per OutgoingFile event for files going to peer. It runs until ctx is
|
||||||
|
// done (which runCp does on return) and is best-effort: if the bus
|
||||||
|
// subscription fails for any reason, onUpdate simply isn't called and the
|
||||||
|
// caller's progress display stays at 0 — exactly the right degradation,
|
||||||
|
// since the warning timer will then fire on its normal 3-second deadline.
|
||||||
|
func watchOutgoingFiles(ctx context.Context, peer tailcfg.StableNodeID, onUpdate func(name string, sent int64)) {
|
||||||
|
// NotifyPeerChanges opts in to per-peer add/remove notifications so the
|
||||||
|
// bus stays responsive without us also subscribing to the full NetMap,
|
||||||
|
// which we don't read here.
|
||||||
|
w, err := localClient.WatchIPNBus(ctx, ipn.NotifyInitialOutgoingFiles|ipn.NotifyPeerChanges)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
for {
|
||||||
|
n, err := w.Next()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, of := range n.OutgoingFiles {
|
||||||
|
if of.PeerID != peer {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// tailscaled keeps Finished entries in its OutgoingFiles map
|
||||||
|
// across PushFile calls (see feature/taildrop/ext.go), so a
|
||||||
|
// re-send of the same filename will see both the old completed
|
||||||
|
// (Sent == DeclaredSize) entry and the new in-progress one.
|
||||||
|
// Without this filter the watcher's monotonic CAS would latch
|
||||||
|
// onto the old entry's max value and the new transfer would
|
||||||
|
// appear stuck at 100% from the first bus tick.
|
||||||
|
if of.Finished {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
onUpdate(of.Name, of.Sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressPrinter repaints a single-line transfer progress display every
|
||||||
|
// interval. interval must be > 0; runCp's caller gates on the
|
||||||
|
// --update-interval flag and skips invoking us when it's <= 0.
|
||||||
|
//
|
||||||
|
// It returns when ctx is done OR when it detects the transfer is stuck —
|
||||||
|
// "stuck" being: contentCount has equalled contentLength with a near-zero
|
||||||
|
// rate for >2 seconds. The stuck case prints a final newline so subsequent
|
||||||
|
// output (e.g. an error from PushFile) lands on a fresh line below the
|
||||||
|
// frozen progress line, instead of being painted over by it.
|
||||||
|
func progressPrinter(ctx context.Context, name string, contentCount func() int64, contentLength int64, interval time.Duration) {
|
||||||
var rateValueFast, rateValueSlow tsrate.Value
|
var rateValueFast, rateValueSlow tsrate.Value
|
||||||
rateValueFast.HalfLife = 1 * time.Second // fast response for rate measurement
|
// tailscaled emits OutgoingFile.Sent updates at ~1 Hz, so most printer
|
||||||
rateValueSlow.HalfLife = 10 * time.Second // slow response for ETA measurement
|
// ticks see no delta. With too short a half-life the displayed rate
|
||||||
|
// roughly halves between updates and doubles back when one arrives,
|
||||||
|
// looking jumpy. 5s keeps the swing under ~15% while still settling
|
||||||
|
// within a few seconds of a real change.
|
||||||
|
rateValueFast.HalfLife = 5 * time.Second // smoothed rate for display
|
||||||
|
rateValueSlow.HalfLife = 10 * time.Second // even slower, for ETA measurement
|
||||||
var prevContentCount int64
|
var prevContentCount int64
|
||||||
print := func() {
|
print := func() {
|
||||||
currContentCount := contentCount()
|
currContentCount := contentCount()
|
||||||
rateValueFast.Add(float64(currContentCount - prevContentCount))
|
// Clamp so a regression (which shouldn't happen, but tsrate.Value.Add
|
||||||
rateValueSlow.Add(float64(currContentCount - prevContentCount))
|
// panics on a negative count) can't take down the CLI.
|
||||||
|
delta := max(currContentCount-prevContentCount, 0)
|
||||||
|
rateValueFast.Add(float64(delta))
|
||||||
|
rateValueSlow.Add(float64(delta))
|
||||||
prevContentCount = currContentCount
|
prevContentCount = currContentCount
|
||||||
|
|
||||||
const vtRestartLine = "\r\x1b[K"
|
const vtRestartLine = "\r\x1b[K"
|
||||||
@@ -215,16 +356,23 @@ func progressPrinter(ctx context.Context, name string, contentCount func() int64
|
|||||||
if contentLength >= 0 {
|
if contentLength >= 0 {
|
||||||
currContentCount = min(currContentCount, contentLength) // cap at 100%
|
currContentCount = min(currContentCount, contentLength) // cap at 100%
|
||||||
ratioRemain := float64(currContentCount) / float64(contentLength)
|
ratioRemain := float64(currContentCount) / float64(contentLength)
|
||||||
|
etaStr := "ETA -"
|
||||||
|
if rate := rateValueSlow.Rate(); rate > 0 {
|
||||||
bytesRemain := float64(contentLength - currContentCount)
|
bytesRemain := float64(contentLength - currContentCount)
|
||||||
secsRemain := bytesRemain / rateValueSlow.Rate()
|
secsRemain := bytesRemain / rate
|
||||||
secs := int(min(max(0, secsRemain), 99*60*60+59+60+59))
|
secs := int(min(max(0, secsRemain), 99*60*60+59+60+59))
|
||||||
|
etaStr = fmt.Sprintf("ETA %02d:%02d:%02d", secs/60/60, (secs/60)%60, secs%60)
|
||||||
|
}
|
||||||
fmt.Fprintf(os.Stderr, " %s %s",
|
fmt.Fprintf(os.Stderr, " %s %s",
|
||||||
leftPad(fmt.Sprintf("%0.2f%%", 100.0*ratioRemain), len("100.00%")),
|
leftPad(fmt.Sprintf("%0.2f%%", 100.0*ratioRemain), len("100.00%")),
|
||||||
fmt.Sprintf("ETA %02d:%02d:%02d", secs/60/60, (secs/60)%60, secs%60))
|
etaStr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tc := time.NewTicker(250 * time.Millisecond)
|
const stuckAfter = 2 * time.Second
|
||||||
|
var fullStartedAt time.Time // when we first observed currCount==contentLength with ~zero rate
|
||||||
|
|
||||||
|
tc := time.NewTicker(interval)
|
||||||
defer tc.Stop()
|
defer tc.Stop()
|
||||||
print()
|
print()
|
||||||
for {
|
for {
|
||||||
@@ -235,6 +383,24 @@ func progressPrinter(ctx context.Context, name string, contentCount func() int64
|
|||||||
return
|
return
|
||||||
case <-tc.C:
|
case <-tc.C:
|
||||||
print()
|
print()
|
||||||
|
if contentLength < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currCount := contentCount()
|
||||||
|
rate := rateValueFast.Rate()
|
||||||
|
if currCount >= contentLength && rate < 1 {
|
||||||
|
if fullStartedAt.IsZero() {
|
||||||
|
fullStartedAt = time.Now()
|
||||||
|
} else if time.Since(fullStartedAt) >= stuckAfter {
|
||||||
|
// Transfer is stuck at 100% with no movement. Stop
|
||||||
|
// repainting so we don't keep clobbering anything the
|
||||||
|
// rest of runCp prints (warnings, errors).
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fullStartedAt = time.Time{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +494,10 @@ peerLoop:
|
|||||||
return "", isOffline, errors.New("cannot send files: missing required Taildrop capability")
|
return "", isOffline, errors.New("cannot send files: missing required Taildrop capability")
|
||||||
|
|
||||||
case ipnstate.TaildropTargetOffline:
|
case ipnstate.TaildropTargetOffline:
|
||||||
return "", isOffline, errors.New("cannot send files: peer is offline")
|
// Don't gate on the server-reported Online bit (which lags reality
|
||||||
|
// and isn't always accurate). runCp probes reachability itself with
|
||||||
|
// TSMP pings.
|
||||||
|
return foundPeer.ID, isOffline, nil
|
||||||
|
|
||||||
case ipnstate.TaildropTargetNoPeerInfo:
|
case ipnstate.TaildropTargetNoPeerInfo:
|
||||||
return "", isOffline, errors.New("cannot send files: invalid or unrecognized peer")
|
return "", isOffline, errors.New("cannot send files: invalid or unrecognized peer")
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ type expandedAUMV1 struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// tkaKeyV1 is the expanded version of a [tka.Key], which describes
|
// tkaKeyV1 is the expanded version of a [tka.Key], which describes
|
||||||
// the public components of a key known to network-lock.
|
// the public components of a key known to tailnet-lock.
|
||||||
type tkaKeyV1 struct {
|
type tkaKeyV1 struct {
|
||||||
Kind string `json:"Kind,omitzero"`
|
Kind string `json:"Kind,omitzero"`
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ type tailnetLockStatusV1Base struct {
|
|||||||
// Enabled is true if Tailnet Lock is enabled.
|
// Enabled is true if Tailnet Lock is enabled.
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
|
||||||
// PublicKey describes the node's network-lock public key.
|
// PublicKey describes the node's tailnet-lock public key.
|
||||||
PublicKey string `json:"PublicKey,omitzero"`
|
PublicKey string `json:"PublicKey,omitzero"`
|
||||||
|
|
||||||
// NodeKey describes the node's current node-key. This field is not
|
// NodeKey describes the node's current node-key. This field is not
|
||||||
@@ -144,7 +144,7 @@ type tailnetLockEnabledStatusV1 struct {
|
|||||||
NodeKeySignature *tkaNodeKeySignatureV1
|
NodeKeySignature *tkaNodeKeySignatureV1
|
||||||
|
|
||||||
// TrustedKeys describes the keys currently trusted to make changes
|
// TrustedKeys describes the keys currently trusted to make changes
|
||||||
// to network-lock.
|
// to tailnet-lock.
|
||||||
TrustedKeys []tkaKeyV1
|
TrustedKeys []tkaKeyV1
|
||||||
|
|
||||||
// VisiblePeers describes peers which are visible in the netmap that
|
// VisiblePeers describes peers which are visible in the netmap that
|
||||||
|
|||||||
@@ -848,10 +848,10 @@ func (e *serveEnv) enableFeatureInteractive(ctx context.Context, feature string,
|
|||||||
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1)
|
e.lc.IncrementCounter(ctx, fmt.Sprintf("%s_enablement_lost_connection", feature), 1)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if nm := n.NetMap; nm != nil && nm.SelfNode.Valid() {
|
if self := n.SelfChange; self != nil {
|
||||||
gotAll := true
|
gotAll := true
|
||||||
for _, c := range caps {
|
for _, c := range caps {
|
||||||
if !nm.SelfNode.HasCap(c) {
|
if _, has := self.CapMap[c]; !has {
|
||||||
// The feature is not yet enabled.
|
// The feature is not yet enabled.
|
||||||
// Continue blocking until it is.
|
// Continue blocking until it is.
|
||||||
gotAll = false
|
gotAll = false
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
|
|
||||||
"github.com/peterbourgon/ff/v3/ffcli"
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
"tailscale.com/client/systray"
|
"tailscale.com/client/systray"
|
||||||
@@ -17,10 +18,20 @@ var systrayCmd = &ffcli.Command{
|
|||||||
ShortUsage: "tailscale systray",
|
ShortUsage: "tailscale systray",
|
||||||
ShortHelp: "Run a systray application to manage Tailscale",
|
ShortHelp: "Run a systray application to manage Tailscale",
|
||||||
LongHelp: "Run a systray application to manage Tailscale.",
|
LongHelp: "Run a systray application to manage Tailscale.",
|
||||||
|
FlagSet: (func() *flag.FlagSet {
|
||||||
|
fs := newFlagSet("systray")
|
||||||
|
fs.StringVar(&systrayArgs.theme, "theme", "dark", "color theme for Tailscale icon: dark, dark:nobg, light, light:nobg")
|
||||||
|
return fs
|
||||||
|
})(),
|
||||||
Exec: runSystray,
|
Exec: runSystray,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var systrayArgs struct {
|
||||||
|
theme string
|
||||||
|
}
|
||||||
|
|
||||||
func runSystray(ctx context.Context, _ []string) error {
|
func runSystray(ctx context.Context, _ []string) error {
|
||||||
|
systray.SetTheme(systrayArgs.theme)
|
||||||
new(systray.Menu).Run(&localClient)
|
new(systray.Menu).Run(&localClient)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-6
@@ -113,12 +113,12 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
|
|||||||
upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
|
upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
|
||||||
upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
|
upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
|
||||||
upf.BoolVar(&upArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy")
|
upf.BoolVar(&upArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy")
|
||||||
upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request; each must start with \"tag:\" (e.g. \"tag:eng,tag:montreal,tag:ssh\")")
|
upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request (e.g. \"tag:eng,tag:montreal,tag:ssh\"); the \"tag:\" prefix is optional and added automatically when omitted (e.g. \"eng,montreal,ssh\")")
|
||||||
upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
|
upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
|
||||||
upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes")
|
upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes")
|
||||||
upf.BoolVar(&upArgs.advertiseConnector, "advertise-connector", false, "advertise this node as an app connector")
|
upf.BoolVar(&upArgs.advertiseConnector, "advertise-connector", false, "advertise this node as an app connector")
|
||||||
upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
|
upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
|
||||||
upf.BoolVar(&upArgs.postureChecking, "report-posture", false, hidden+"allow management plane to gather device posture information")
|
upf.BoolVar(&upArgs.postureChecking, "report-posture", false, "allow management plane to gather device posture information")
|
||||||
|
|
||||||
if safesocket.GOOSUsesPeerCreds(goos) {
|
if safesocket.GOOSUsesPeerCreds(goos) {
|
||||||
upf.StringVar(&upArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
|
upf.StringVar(&upArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
|
||||||
@@ -309,9 +309,15 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
|
|||||||
var tags []string
|
var tags []string
|
||||||
if upArgs.advertiseTags != "" {
|
if upArgs.advertiseTags != "" {
|
||||||
tags = strings.Split(upArgs.advertiseTags, ",")
|
tags = strings.Split(upArgs.advertiseTags, ",")
|
||||||
for _, tag := range tags {
|
for i, tag := range tags {
|
||||||
err := tailcfg.CheckTag(tag)
|
// Allow users to omit the "tag:" prefix; if the tag has no
|
||||||
if err != nil {
|
// colon at all, add it for them. Tags with a colon must be
|
||||||
|
// fully qualified ("tag:foo") and are validated as-is.
|
||||||
|
if !strings.Contains(tag, ":") {
|
||||||
|
tag = "tag:" + tag
|
||||||
|
tags[i] = tag
|
||||||
|
}
|
||||||
|
if err := tailcfg.CheckTag(tag); err != nil {
|
||||||
return nil, fmt.Errorf("tag: %q: %s", tag, err)
|
return nil, fmt.Errorf("tag: %q: %s", tag, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -726,7 +732,7 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
|
|||||||
if s := n.State; s != nil {
|
if s := n.State; s != nil {
|
||||||
ipnIsRunning = *s == ipn.Running
|
ipnIsRunning = *s == ipn.Running
|
||||||
}
|
}
|
||||||
if n.NetMap != nil && n.NetMap.NodeKey != origNodeKey {
|
if n.SelfChange != nil && n.SelfChange.Key != origNodeKey {
|
||||||
waitingForKeyChange = false
|
waitingForKeyChange = false
|
||||||
}
|
}
|
||||||
if ipnIsRunning && !waitingForKeyChange {
|
if ipnIsRunning && !waitingForKeyChange {
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
|||||||
tailscale.com/tstime from tailscale.com/control/controlhttp+
|
tailscale.com/tstime from tailscale.com/control/controlhttp+
|
||||||
tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||||
tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli
|
tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli
|
||||||
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/tsweb/varz from tailscale.com/util/usermetric+
|
||||||
tailscale.com/types/appctype from tailscale.com/client/local+
|
tailscale.com/types/appctype from tailscale.com/client/local+
|
||||||
tailscale.com/types/dnstype from tailscale.com/tailcfg+
|
tailscale.com/types/dnstype from tailscale.com/tailcfg+
|
||||||
@@ -331,7 +331,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
|||||||
golang.org/x/net/icmp from tailscale.com/net/ping
|
golang.org/x/net/icmp from tailscale.com/net/ping
|
||||||
golang.org/x/net/idna from golang.org/x/net/http/httpproxy+
|
golang.org/x/net/idna from golang.org/x/net/http/httpproxy+
|
||||||
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
|
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
|
||||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
|
||||||
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
||||||
golang.org/x/net/ipv4 from golang.org/x/net/icmp+
|
golang.org/x/net/ipv4 from golang.org/x/net/icmp+
|
||||||
golang.org/x/net/ipv6 from golang.org/x/net/icmp+
|
golang.org/x/net/ipv6 from golang.org/x/net/icmp+
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
golang.org/x/net/icmp from tailscale.com/net/ping
|
golang.org/x/net/icmp from tailscale.com/net/ping
|
||||||
golang.org/x/net/idna from golang.org/x/net/http/httpguts
|
golang.org/x/net/idna from golang.org/x/net/http/httpguts
|
||||||
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
|
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
|
||||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
|
||||||
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
|
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/ipv6 from github.com/tailscale/wireguard-go/conn+
|
||||||
golang.org/x/sync/errgroup from github.com/mdlayher/socket
|
golang.org/x/sync/errgroup from github.com/mdlayher/socket
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
golang.org/x/net/icmp from tailscale.com/net/ping
|
golang.org/x/net/icmp from tailscale.com/net/ping
|
||||||
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
|
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
|
||||||
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
|
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
|
||||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
|
||||||
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
|
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/ipv6 from github.com/tailscale/wireguard-go/conn+
|
||||||
golang.org/x/sync/errgroup from github.com/mdlayher/socket
|
golang.org/x/sync/errgroup from github.com/mdlayher/socket
|
||||||
|
|||||||
+12
-10
@@ -130,7 +130,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
L github.com/google/nftables/expr from github.com/google/nftables+
|
L github.com/google/nftables/expr from github.com/google/nftables+
|
||||||
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
|
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
|
||||||
L github.com/google/nftables/xt from github.com/google/nftables/expr+
|
L github.com/google/nftables/xt from github.com/google/nftables/expr+
|
||||||
DW github.com/google/uuid from tailscale.com/clientupdate+
|
W github.com/google/uuid from tailscale.com/clientupdate
|
||||||
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
|
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
|
||||||
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
|
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
|
||||||
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
||||||
@@ -173,9 +173,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal
|
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal
|
||||||
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
|
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
|
||||||
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
|
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
|
||||||
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
|
|
||||||
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf+
|
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf+
|
||||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||||
LD github.com/tailscale/gliderssh from tailscale.com/ssh/tailssh
|
LD github.com/tailscale/gliderssh from tailscale.com/ssh/tailssh
|
||||||
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
|
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/fs from github.com/tailscale/go-winio
|
||||||
@@ -259,6 +258,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
tailscale.com/client/web from tailscale.com/ipn/ipnlocal
|
tailscale.com/client/web from tailscale.com/ipn/ipnlocal
|
||||||
tailscale.com/clientupdate from tailscale.com/feature/clientupdate
|
tailscale.com/clientupdate from tailscale.com/feature/clientupdate
|
||||||
LW tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
|
LW tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
|
||||||
|
tailscale.com/cmd/tailscale/cli/jsonoutput from tailscale.com/feature/tailnetlock
|
||||||
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+
|
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+
|
||||||
tailscale.com/cmd/tailscaled/tailscaledhooks from tailscale.com/cmd/tailscaled+
|
tailscale.com/cmd/tailscaled/tailscaledhooks from tailscale.com/cmd/tailscaled+
|
||||||
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
|
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
|
||||||
@@ -303,10 +303,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
|
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
|
||||||
tailscale.com/feature/posture from tailscale.com/feature/condregister
|
tailscale.com/feature/posture from tailscale.com/feature/condregister
|
||||||
tailscale.com/feature/relayserver from tailscale.com/feature/condregister
|
tailscale.com/feature/relayserver from tailscale.com/feature/condregister
|
||||||
|
tailscale.com/feature/routecheck from tailscale.com/feature/condregister
|
||||||
L tailscale.com/feature/sdnotify from tailscale.com/feature/condregister
|
L tailscale.com/feature/sdnotify from tailscale.com/feature/condregister
|
||||||
LD tailscale.com/feature/ssh from tailscale.com/cmd/tailscaled
|
LD tailscale.com/feature/ssh from tailscale.com/cmd/tailscaled
|
||||||
tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+
|
tailscale.com/feature/syspolicy from tailscale.com/feature/condregister+
|
||||||
tailscale.com/feature/taildrop from tailscale.com/feature/condregister
|
tailscale.com/feature/taildrop from tailscale.com/feature/condregister
|
||||||
|
tailscale.com/feature/tailnetlock from tailscale.com/feature/condregister
|
||||||
L tailscale.com/feature/tap from tailscale.com/feature/condregister
|
L tailscale.com/feature/tap from tailscale.com/feature/condregister
|
||||||
tailscale.com/feature/tpm from tailscale.com/feature/condregister
|
tailscale.com/feature/tpm from tailscale.com/feature/condregister
|
||||||
L 💣 tailscale.com/feature/tundevstats from tailscale.com/feature/condregister
|
L 💣 tailscale.com/feature/tundevstats from tailscale.com/feature/condregister
|
||||||
@@ -402,7 +404,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
tailscale.com/tstime from tailscale.com/control/controlclient+
|
tailscale.com/tstime from tailscale.com/control/controlclient+
|
||||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
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/cmd/tailscaled+
|
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled+
|
||||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
||||||
tailscale.com/types/bools from tailscale.com/wgengine/netlog
|
tailscale.com/types/bools from tailscale.com/wgengine/netlog
|
||||||
@@ -525,13 +527,13 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
golang.org/x/net/dns/dnsmessage from tailscale.com/appc+
|
golang.org/x/net/dns/dnsmessage from tailscale.com/appc+
|
||||||
golang.org/x/net/http/httpguts from tailscale.com/ipn/ipnlocal
|
golang.org/x/net/http/httpguts from tailscale.com/ipn/ipnlocal
|
||||||
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
|
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
|
||||||
golang.org/x/net/icmp from tailscale.com/net/ping+
|
golang.org/x/net/icmp from tailscale.com/net/ping
|
||||||
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
|
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
|
||||||
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
|
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
|
||||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
|
||||||
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
||||||
golang.org/x/net/ipv4 from github.com/prometheus-community/pro-bing+
|
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
|
||||||
golang.org/x/net/ipv6 from github.com/prometheus-community/pro-bing+
|
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
|
||||||
golang.org/x/net/proxy from tailscale.com/net/netns
|
golang.org/x/net/proxy from tailscale.com/net/netns
|
||||||
D golang.org/x/net/route from tailscale.com/net/netmon+
|
D golang.org/x/net/route from tailscale.com/net/netmon+
|
||||||
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
|
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
|
||||||
@@ -642,7 +644,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
crypto/x509 from crypto/tls+
|
crypto/x509 from crypto/tls+
|
||||||
D crypto/x509/internal/macos from crypto/x509
|
D crypto/x509/internal/macos from crypto/x509
|
||||||
crypto/x509/pkix from crypto/x509+
|
crypto/x509/pkix from crypto/x509+
|
||||||
DW database/sql/driver from github.com/google/uuid
|
W database/sql/driver from github.com/google/uuid
|
||||||
W debug/dwarf from debug/pe
|
W debug/dwarf from debug/pe
|
||||||
W debug/pe from github.com/dblohm7/wingoes/pe
|
W debug/pe from github.com/dblohm7/wingoes/pe
|
||||||
embed from github.com/tailscale/web-client-prebuilt+
|
embed from github.com/tailscale/web-client-prebuilt+
|
||||||
@@ -732,7 +734,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
mime/quotedprintable from mime/multipart
|
mime/quotedprintable from mime/multipart
|
||||||
net from crypto/tls+
|
net from crypto/tls+
|
||||||
net/http from expvar+
|
net/http from expvar+
|
||||||
net/http/httptrace from github.com/prometheus-community/pro-bing+
|
net/http/httptrace from github.com/aws/smithy-go/transport/http+
|
||||||
net/http/httputil from github.com/aws/smithy-go/transport/http+
|
net/http/httputil from github.com/aws/smithy-go/transport/http+
|
||||||
net/http/internal from net/http+
|
net/http/internal from net/http+
|
||||||
net/http/internal/ascii from net/http+
|
net/http/internal/ascii from net/http+
|
||||||
|
|||||||
@@ -202,6 +202,19 @@ func TestOmitPortlist(t *testing.T) {
|
|||||||
}.Check(t)
|
}.Check(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOmitRouteCheck(t *testing.T) {
|
||||||
|
deptest.DepChecker{
|
||||||
|
GOOS: "linux",
|
||||||
|
GOARCH: "amd64",
|
||||||
|
Tags: "ts_omit_routecheck,ts_include_cli",
|
||||||
|
OnDep: func(dep string) {
|
||||||
|
if strings.Contains(dep, "routecheck") {
|
||||||
|
t.Errorf("unexpected dep: %q", dep)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}.Check(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestOmitGRO(t *testing.T) {
|
func TestOmitGRO(t *testing.T) {
|
||||||
deptest.DepChecker{
|
deptest.DepChecker{
|
||||||
GOOS: "linux",
|
GOOS: "linux",
|
||||||
|
|||||||
@@ -828,7 +828,6 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return onlyNetstack, err
|
return onlyNetstack, err
|
||||||
}
|
}
|
||||||
e = wgengine.NewWatchdog(e)
|
|
||||||
sys.Set(e)
|
sys.Set(e)
|
||||||
sys.NetstackRouter.Set(netstackSubnetRouter)
|
sys.NetstackRouter.Set(netstackSubnetRouter)
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ func main() {
|
|||||||
if cached {
|
if cached {
|
||||||
lastCol = "(cached)"
|
lastCol = "(cached)"
|
||||||
} else {
|
} else {
|
||||||
lastCol = fmt.Sprintf("%.3f", testDur.Seconds())
|
lastCol = fmt.Sprintf("%.3fs", testDur.Seconds())
|
||||||
}
|
}
|
||||||
fmt.Printf("%s\t%s\t%v\n", outcome, pkg, lastCol)
|
fmt.Printf("%s\t%s\t%v\n", outcome, pkg, lastCol)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,12 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
|||||||
if n.State != nil {
|
if n.State != nil {
|
||||||
notifyState(*n.State)
|
notifyState(*n.State)
|
||||||
}
|
}
|
||||||
if nm := n.NetMap; nm != nil {
|
if n.SelfChange != nil {
|
||||||
|
// Self changed: rebuild the JS-side NetMap snapshot. Peers
|
||||||
|
// don't ride on the bus anymore, so fetch them on demand
|
||||||
|
// from LocalBackend.
|
||||||
|
nm := i.lb.NetMapWithPeers()
|
||||||
|
if nm != nil {
|
||||||
jsNetMap := jsNetMap{
|
jsNetMap := jsNetMap{
|
||||||
Self: jsNetMapSelfNode{
|
Self: jsNetMapSelfNode{
|
||||||
jsNetMapNode: jsNetMapNode{
|
jsNetMapNode: jsNetMapNode{
|
||||||
@@ -298,6 +303,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
|||||||
log.Printf("Could not generate JSON netmap: %v", err)
|
log.Printf("Could not generate JSON netmap: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if n.BrowseToURL != nil {
|
if n.BrowseToURL != nil {
|
||||||
jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
|
jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-87
@@ -6,77 +6,6 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
|
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/internal/common from github.com/alexbrainman/sspi/negotiate
|
||||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
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/coder/websocket from tailscale.com/util/eventbus
|
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/errd from github.com/coder/websocket
|
||||||
github.com/coder/websocket/internal/util from github.com/coder/websocket
|
github.com/coder/websocket/internal/util from github.com/coder/websocket
|
||||||
@@ -105,7 +34,6 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns
|
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns
|
||||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||||
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/transport/tcp
|
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/transport/tcp
|
||||||
D github.com/google/uuid from github.com/prometheus-community/pro-bing
|
|
||||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
||||||
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
|
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
|
||||||
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
||||||
@@ -128,9 +56,8 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink+
|
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink+
|
||||||
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
|
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
|
||||||
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal
|
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal
|
||||||
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
|
|
||||||
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
|
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
|
||||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||||
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
|
W 💣 github.com/tailscale/go-winio 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/fs from github.com/tailscale/go-winio
|
||||||
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
|
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
|
||||||
@@ -223,11 +150,9 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
|
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
|
||||||
tailscale.com/feature/c2n from tailscale.com/tsnet
|
tailscale.com/feature/c2n from tailscale.com/tsnet
|
||||||
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
|
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
|
||||||
tailscale.com/feature/condregister/identityfederation from tailscale.com/tsnet
|
|
||||||
tailscale.com/feature/condregister/oauthkey 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/portmapper from tailscale.com/tsnet
|
||||||
tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet
|
tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet
|
||||||
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
|
|
||||||
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
|
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
|
||||||
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
|
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
|
||||||
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
|
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
|
||||||
@@ -309,7 +234,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
tailscale.com/tstime from tailscale.com/control/controlclient+
|
tailscale.com/tstime from tailscale.com/control/controlclient+
|
||||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
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/tsweb+
|
tailscale.com/tsweb/varz from tailscale.com/tsweb+
|
||||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
||||||
tailscale.com/types/bools from tailscale.com/tsnet+
|
tailscale.com/types/bools from tailscale.com/tsnet+
|
||||||
@@ -399,7 +324,6 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
tailscale.com/wgengine/wgcfg/nmcfg 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/wgint from tailscale.com/wgengine+
|
||||||
tailscale.com/wgengine/wglog 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/argon2 from tailscale.com/tka
|
||||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
||||||
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
||||||
@@ -421,16 +345,16 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
golang.org/x/net/dns/dnsmessage from tailscale.com/appc+
|
golang.org/x/net/dns/dnsmessage from tailscale.com/appc+
|
||||||
golang.org/x/net/http/httpguts from tailscale.com/ipn/ipnlocal
|
golang.org/x/net/http/httpguts from tailscale.com/ipn/ipnlocal
|
||||||
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
|
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
|
||||||
golang.org/x/net/icmp from github.com/prometheus-community/pro-bing+
|
golang.org/x/net/icmp from tailscale.com/net/ping
|
||||||
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
|
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
|
||||||
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
|
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
|
||||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
|
||||||
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
||||||
golang.org/x/net/ipv4 from github.com/prometheus-community/pro-bing+
|
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
|
||||||
golang.org/x/net/ipv6 from github.com/prometheus-community/pro-bing+
|
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
|
||||||
golang.org/x/net/proxy from tailscale.com/net/netns
|
golang.org/x/net/proxy from tailscale.com/net/netns
|
||||||
D golang.org/x/net/route from tailscale.com/net/netmon+
|
D golang.org/x/net/route from tailscale.com/net/netmon+
|
||||||
golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials+
|
golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials
|
||||||
golang.org/x/oauth2/clientcredentials from tailscale.com/feature/oauthkey
|
golang.org/x/oauth2/clientcredentials from tailscale.com/feature/oauthkey
|
||||||
golang.org/x/oauth2/internal from golang.org/x/oauth2+
|
golang.org/x/oauth2/internal from golang.org/x/oauth2+
|
||||||
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
|
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
|
||||||
@@ -533,12 +457,11 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
crypto/sha3 from crypto/internal/fips140hash+
|
crypto/sha3 from crypto/internal/fips140hash+
|
||||||
crypto/sha512 from crypto/ecdsa+
|
crypto/sha512 from crypto/ecdsa+
|
||||||
crypto/subtle from crypto/cipher+
|
crypto/subtle from crypto/cipher+
|
||||||
crypto/tls from github.com/prometheus-community/pro-bing+
|
crypto/tls from net/http+
|
||||||
crypto/tls/internal/fips140tls from crypto/tls
|
crypto/tls/internal/fips140tls from crypto/tls
|
||||||
crypto/x509 from crypto/tls+
|
crypto/x509 from crypto/tls+
|
||||||
D crypto/x509/internal/macos from crypto/x509
|
D crypto/x509/internal/macos from crypto/x509
|
||||||
crypto/x509/pkix from crypto/x509+
|
crypto/x509/pkix from crypto/x509+
|
||||||
D database/sql/driver from github.com/google/uuid
|
|
||||||
W debug/dwarf from debug/pe
|
W debug/dwarf from debug/pe
|
||||||
W debug/pe from github.com/dblohm7/wingoes/pe
|
W debug/pe from github.com/dblohm7/wingoes/pe
|
||||||
embed from github.com/tailscale/web-client-prebuilt+
|
embed from github.com/tailscale/web-client-prebuilt+
|
||||||
@@ -627,7 +550,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
mime/quotedprintable from mime/multipart
|
mime/quotedprintable from mime/multipart
|
||||||
net from crypto/tls+
|
net from crypto/tls+
|
||||||
net/http from expvar+
|
net/http from expvar+
|
||||||
net/http/httptrace from github.com/prometheus-community/pro-bing+
|
net/http/httptrace from net/http+
|
||||||
net/http/httputil from tailscale.com/client/web+
|
net/http/httputil from tailscale.com/client/web+
|
||||||
net/http/internal from net/http+
|
net/http/internal from net/http+
|
||||||
net/http/internal/ascii from net/http+
|
net/http/internal/ascii from net/http+
|
||||||
@@ -642,7 +565,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
os/user from github.com/godbus/dbus/v5+
|
os/user from github.com/godbus/dbus/v5+
|
||||||
path from debug/dwarf+
|
path from debug/dwarf+
|
||||||
path/filepath from crypto/x509+
|
path/filepath from crypto/x509+
|
||||||
reflect from database/sql/driver+
|
reflect from encoding/asn1+
|
||||||
regexp from github.com/huin/goupnp/httpu+
|
regexp from github.com/huin/goupnp/httpu+
|
||||||
regexp/syntax from regexp
|
regexp/syntax from regexp
|
||||||
runtime from crypto/internal/fips140+
|
runtime from crypto/internal/fips140+
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// The tsnet-proxy command exposes a local port on the tailnet under a
|
||||||
|
// chosen hostname. By default it proxies raw TCP; pass --http to reverse
|
||||||
|
// proxy as HTTP, or --https to reverse proxy as HTTPS with an auto-issued
|
||||||
|
// Tailscale cert. Both HTTP modes inject Tailscale-User-* identity headers
|
||||||
|
// from WhoIs.
|
||||||
|
//
|
||||||
|
// Arguments are <name> <local> [tailnet]: local is the port on localhost
|
||||||
|
// to proxy to and tailnet is the port to expose on the tailnet. If tailnet
|
||||||
|
// is omitted, it defaults to 443 for --https, 80 for --http, and the local
|
||||||
|
// port otherwise.
|
||||||
|
//
|
||||||
|
// go run ./cmd/tsnet-proxy myapp 8080 # raw TCP, tailnet :8080
|
||||||
|
// go run ./cmd/tsnet-proxy myapp 22 2222 # raw TCP, tailnet :2222
|
||||||
|
// go run ./cmd/tsnet-proxy --http myapp 8080 # tailnet :80
|
||||||
|
// go run ./cmd/tsnet-proxy --https myapp 8080 # tailnet :443
|
||||||
|
//
|
||||||
|
// Or run directly from the module, no checkout required:
|
||||||
|
//
|
||||||
|
// go run tailscale.com/cmd/tsnet-proxy@latest myapp 8080
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"tailscale.com/client/local"
|
||||||
|
"tailscale.com/tsnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
asHTTP := flag.Bool("http", false, "reverse proxy as HTTP and inject Tailscale-User-* headers")
|
||||||
|
asHTTPS := flag.Bool("https", false, "reverse proxy as HTTPS with an auto-issued Tailscale cert; implies --http")
|
||||||
|
dir := flag.String("dir", "", "directory to persist tsnet state (default: per-user config dir)")
|
||||||
|
verbose := flag.Bool("v", false, "verbose tsnet backend logs")
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(flag.CommandLine.Output(), "usage: %s [flags] <name> <local> [tailnet]\n", flag.CommandLine.Name())
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if n := flag.NArg(); n != 2 && n != 3 {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
name := flag.Arg(0)
|
||||||
|
localPort, err := parsePort(flag.Arg(1))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid local port %q: %v", flag.Arg(1), err)
|
||||||
|
}
|
||||||
|
tailnetPort := defaultTailnetPort(localPort, *asHTTP, *asHTTPS)
|
||||||
|
if flag.NArg() == 3 {
|
||||||
|
tailnetPort, err = parsePort(flag.Arg(2))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid tailnet port %q: %v", flag.Arg(2), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target := "localhost:" + strconv.Itoa(localPort)
|
||||||
|
addr := ":" + strconv.Itoa(tailnetPort)
|
||||||
|
|
||||||
|
s := &tsnet.Server{Hostname: name, Dir: *dir}
|
||||||
|
if *verbose {
|
||||||
|
s.Logf = log.Printf
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
var ln net.Listener
|
||||||
|
if *asHTTPS {
|
||||||
|
ln, err = s.ListenTLS("tcp", addr)
|
||||||
|
} else {
|
||||||
|
ln, err = s.Listen("tcp", addr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
log.Printf("proxying %s -> %s on tailnet", target, name+addr)
|
||||||
|
|
||||||
|
if *asHTTP || *asHTTPS {
|
||||||
|
lc, err := s.LocalClient()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
targetURL := &url.URL{Scheme: "http", Host: target}
|
||||||
|
rp := &httputil.ReverseProxy{
|
||||||
|
Rewrite: func(r *httputil.ProxyRequest) {
|
||||||
|
r.SetURL(targetURL)
|
||||||
|
r.SetXForwarded()
|
||||||
|
addTailscaleIdentityHeaders(lc, r)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
log.Fatal(http.Serve(ln, rp))
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
go proxyTCP(c, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePort(s string) (int, error) {
|
||||||
|
p, err := strconv.Atoi(s)
|
||||||
|
if err != nil || p <= 0 || p > 65535 {
|
||||||
|
return 0, fmt.Errorf("bad port")
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultTailnetPort returns the tailnet port when the user didn't
|
||||||
|
// specify one: 443 for HTTPS, 80 for HTTP, else the local port.
|
||||||
|
func defaultTailnetPort(local int, asHTTP, asHTTPS bool) int {
|
||||||
|
switch {
|
||||||
|
case asHTTPS:
|
||||||
|
return 443
|
||||||
|
case asHTTP:
|
||||||
|
return 80
|
||||||
|
}
|
||||||
|
return local
|
||||||
|
}
|
||||||
|
|
||||||
|
func proxyTCP(c net.Conn, target string) {
|
||||||
|
defer c.Close()
|
||||||
|
d, err := net.Dial("tcp", target)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("dial %s: %v", target, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer d.Close()
|
||||||
|
go io.Copy(d, c)
|
||||||
|
io.Copy(c, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTailscaleIdentityHeaders(lc *local.Client, r *httputil.ProxyRequest) {
|
||||||
|
r.Out.Header.Del("Tailscale-User-Login")
|
||||||
|
r.Out.Header.Del("Tailscale-User-Name")
|
||||||
|
r.Out.Header.Del("Tailscale-User-Profile-Pic")
|
||||||
|
r.Out.Header.Del("Tailscale-Funnel-Request")
|
||||||
|
r.Out.Header.Del("Tailscale-Headers-Info")
|
||||||
|
|
||||||
|
who, err := lc.WhoIs(r.In.Context(), r.In.RemoteAddr)
|
||||||
|
if err != nil || who == nil || who.Node.IsTagged() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Out.Header.Set("Tailscale-User-Login", encHeader(who.UserProfile.LoginName))
|
||||||
|
r.Out.Header.Set("Tailscale-User-Name", encHeader(who.UserProfile.DisplayName))
|
||||||
|
r.Out.Header.Set("Tailscale-User-Profile-Pic", who.UserProfile.ProfilePicURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encHeader mirrors the encoding tailscaled's serve path applies to
|
||||||
|
// user-provided strings destined for HTTP headers.
|
||||||
|
func encHeader(v string) string {
|
||||||
|
if !utf8.ValidString(v) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return mime.QEncoding.Encode("utf-8", v)
|
||||||
|
}
|
||||||
+513
@@ -0,0 +1,513 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// Program tsp is a low-level Tailscale protocol tool for performing
|
||||||
|
// composable building block operations like generating keys and
|
||||||
|
// registering nodes.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
|
"tailscale.com/control/tsp"
|
||||||
|
"tailscale.com/hostinfo"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/types/key"
|
||||||
|
)
|
||||||
|
|
||||||
|
var globalArgs struct {
|
||||||
|
// serverURL is the base URL of the coordination server (-s flag).
|
||||||
|
// If empty, tsp.DefaultServerURL is used.
|
||||||
|
serverURL string
|
||||||
|
|
||||||
|
// controlKeyFile is a path to a file containing the server's
|
||||||
|
// MachinePublic key in MarshalText form (--control-key flag).
|
||||||
|
// When set, server key discovery is skipped.
|
||||||
|
controlKeyFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
args := os.Args[1:]
|
||||||
|
if err := rootCmd.Parse(args); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
err := rootCmd.Run(context.Background())
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootCmd = &ffcli.Command{
|
||||||
|
Name: "tsp",
|
||||||
|
ShortUsage: "tsp [-s url] <subcommand> [flags]",
|
||||||
|
ShortHelp: "Low-level Tailscale protocol tool.",
|
||||||
|
FlagSet: (func() *flag.FlagSet {
|
||||||
|
fs := flag.NewFlagSet("tsp", flag.ExitOnError)
|
||||||
|
fs.StringVar(&globalArgs.serverURL, "s", "", "base URL of coordination server (default: "+tsp.DefaultServerURL+")")
|
||||||
|
fs.StringVar(&globalArgs.controlKeyFile, "control-key", "", "file containing the server's public key (skips discovery)")
|
||||||
|
return fs
|
||||||
|
})(),
|
||||||
|
Subcommands: []*ffcli.Command{
|
||||||
|
newMachineKeyCmd,
|
||||||
|
newNodeKeyCmd,
|
||||||
|
newNodeCmd,
|
||||||
|
registerCmd,
|
||||||
|
mapCmd,
|
||||||
|
discoverServerKeyCmd,
|
||||||
|
},
|
||||||
|
Exec: func(ctx context.Context, args []string) error {
|
||||||
|
return flag.ErrHelp
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var newMachineKeyArgs struct {
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
|
var newMachineKeyCmd = &ffcli.Command{
|
||||||
|
Name: "new-machine-key",
|
||||||
|
ShortUsage: "tsp new-machine-key [-o file]",
|
||||||
|
ShortHelp: "Generate a new machine key.",
|
||||||
|
FlagSet: (func() *flag.FlagSet {
|
||||||
|
fs := flag.NewFlagSet("new-machine-key", flag.ExitOnError)
|
||||||
|
fs.StringVar(&newMachineKeyArgs.output, "o", "", "output file (default: stdout)")
|
||||||
|
return fs
|
||||||
|
})(),
|
||||||
|
Exec: runNewMachineKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNewMachineKey(ctx context.Context, args []string) error {
|
||||||
|
k := key.NewMachine()
|
||||||
|
text, err := k.MarshalText()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text = append(text, '\n')
|
||||||
|
return writeOutput(newMachineKeyArgs.output, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newNodeKeyArgs struct {
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
|
var newNodeKeyCmd = &ffcli.Command{
|
||||||
|
Name: "new-node-key",
|
||||||
|
ShortUsage: "tsp new-node-key [-o file]",
|
||||||
|
ShortHelp: "Generate a new node key.",
|
||||||
|
FlagSet: (func() *flag.FlagSet {
|
||||||
|
fs := flag.NewFlagSet("new-node-key", flag.ExitOnError)
|
||||||
|
fs.StringVar(&newNodeKeyArgs.output, "o", "", "output file (default: stdout)")
|
||||||
|
return fs
|
||||||
|
})(),
|
||||||
|
Exec: runNewNodeKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNewNodeKey(ctx context.Context, args []string) error {
|
||||||
|
k := key.NewNode()
|
||||||
|
text, err := k.MarshalText()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text = append(text, '\n')
|
||||||
|
return writeOutput(newNodeKeyArgs.output, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
var discoverServerKeyArgs struct {
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
|
var discoverServerKeyCmd = &ffcli.Command{
|
||||||
|
Name: "discover-server-key",
|
||||||
|
ShortUsage: "tsp [-s url] discover-server-key [-o file]",
|
||||||
|
ShortHelp: "Discover and print the coordination server's public key.",
|
||||||
|
FlagSet: (func() *flag.FlagSet {
|
||||||
|
fs := flag.NewFlagSet("discover-server-key", flag.ExitOnError)
|
||||||
|
fs.StringVar(&discoverServerKeyArgs.output, "o", "", "output file (default: stdout)")
|
||||||
|
return fs
|
||||||
|
})(),
|
||||||
|
Exec: runDiscoverServerKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDiscoverServerKey(ctx context.Context, args []string) error {
|
||||||
|
k, err := tsp.DiscoverServerKey(ctx, globalArgs.serverURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text, err := k.MarshalText()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling server key: %w", err)
|
||||||
|
}
|
||||||
|
text = append(text, '\n')
|
||||||
|
return writeOutput(discoverServerKeyArgs.output, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newNodeArgs struct {
|
||||||
|
nodeKeyFile string
|
||||||
|
machineKeyFile string
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
|
var newNodeCmd = &ffcli.Command{
|
||||||
|
Name: "new-node",
|
||||||
|
ShortUsage: "tsp [-s url] [--control-key file] new-node [-n node-key-file] [-m machine-key-file] [-o output]",
|
||||||
|
ShortHelp: "Generate a new node JSON file with keys and server info.",
|
||||||
|
FlagSet: (func() *flag.FlagSet {
|
||||||
|
fs := flag.NewFlagSet("new-node", flag.ExitOnError)
|
||||||
|
fs.StringVar(&newNodeArgs.nodeKeyFile, "n", "", "existing node key file (default: generate new)")
|
||||||
|
fs.StringVar(&newNodeArgs.machineKeyFile, "m", "", "existing machine key file (default: generate new)")
|
||||||
|
fs.StringVar(&newNodeArgs.output, "o", "", "output file (default: stdout)")
|
||||||
|
return fs
|
||||||
|
})(),
|
||||||
|
Exec: runNewNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNewNode(ctx context.Context, args []string) error {
|
||||||
|
var nodeKey key.NodePrivate
|
||||||
|
if newNodeArgs.nodeKeyFile != "" {
|
||||||
|
var err error
|
||||||
|
nodeKey, err = readNodeKeyFile(newNodeArgs.nodeKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading node key: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nodeKey = key.NewNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var machineKey key.MachinePrivate
|
||||||
|
if newNodeArgs.machineKeyFile != "" {
|
||||||
|
var err error
|
||||||
|
machineKey, err = readMachineKeyFile(newNodeArgs.machineKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading machine key: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
machineKey = key.NewMachine()
|
||||||
|
}
|
||||||
|
|
||||||
|
serverURL := cmp.Or(globalArgs.serverURL, tsp.DefaultServerURL)
|
||||||
|
|
||||||
|
var serverKey key.MachinePublic
|
||||||
|
if globalArgs.controlKeyFile != "" {
|
||||||
|
var err error
|
||||||
|
serverKey, err = readControlKeyFile(globalArgs.controlKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading control key: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
serverKey, err = tsp.DiscoverServerKey(ctx, serverURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("discovering server key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nf := tsp.NodeFile{
|
||||||
|
NodeKey: nodeKey,
|
||||||
|
MachineKey: machineKey,
|
||||||
|
ServerInfo: tsp.ServerInfo{URL: serverURL, Key: serverKey},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := json.MarshalIndent(nf, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encoding node file: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, '\n')
|
||||||
|
return writeOutput(newNodeArgs.output, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var registerArgs struct {
|
||||||
|
nodeFile string
|
||||||
|
output string
|
||||||
|
hostname string
|
||||||
|
ephemeral bool
|
||||||
|
authKey string
|
||||||
|
tags string
|
||||||
|
}
|
||||||
|
|
||||||
|
var registerCmd = &ffcli.Command{
|
||||||
|
Name: "register",
|
||||||
|
ShortUsage: "tsp [-s url] register -n <node-file> [flags]",
|
||||||
|
ShortHelp: "Register a node key with a coordination server.",
|
||||||
|
FlagSet: (func() *flag.FlagSet {
|
||||||
|
fs := flag.NewFlagSet("register", flag.ExitOnError)
|
||||||
|
fs.StringVar(®isterArgs.nodeFile, "n", "", "node JSON file (required)")
|
||||||
|
fs.StringVar(®isterArgs.output, "o", "", "output file (default: stdout)")
|
||||||
|
fs.StringVar(®isterArgs.hostname, "hostname", "", "hostname to register")
|
||||||
|
fs.BoolVar(®isterArgs.ephemeral, "ephemeral", false, "register as ephemeral node")
|
||||||
|
fs.StringVar(®isterArgs.authKey, "auth-key", "", "pre-authorized auth key or file containing one")
|
||||||
|
fs.StringVar(®isterArgs.tags, "tags", "", "comma-separated ACL tags")
|
||||||
|
return fs
|
||||||
|
})(),
|
||||||
|
Exec: runRegister,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRegister(ctx context.Context, args []string) error {
|
||||||
|
if registerArgs.nodeFile == "" {
|
||||||
|
return fmt.Errorf("flag -n (node file) is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
nf, err := tsp.ReadNodeFile(registerArgs.nodeFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading node file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hi := hostinfo.New()
|
||||||
|
if registerArgs.hostname != "" {
|
||||||
|
hi.Hostname = registerArgs.hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags []string
|
||||||
|
if registerArgs.tags != "" {
|
||||||
|
tags = strings.Split(registerArgs.tags, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
authKey, err := resolveAuthKey(registerArgs.authKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := tsp.NewClient(tsp.ClientOpts{
|
||||||
|
ServerURL: cmp.Or(globalArgs.serverURL, nf.URL),
|
||||||
|
MachineKey: nf.MachineKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating client: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if globalArgs.controlKeyFile != "" {
|
||||||
|
controlKey, err := readControlKeyFile(globalArgs.controlKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading control key: %w", err)
|
||||||
|
}
|
||||||
|
client.SetControlPublicKey(controlKey)
|
||||||
|
} else {
|
||||||
|
client.SetControlPublicKey(nf.ServerInfo.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Register(ctx, tsp.RegisterOpts{
|
||||||
|
NodeKey: nf.NodeKey,
|
||||||
|
Hostinfo: hi,
|
||||||
|
Ephemeral: registerArgs.ephemeral,
|
||||||
|
AuthKey: authKey,
|
||||||
|
Tags: tags,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := json.MarshalIndent(resp, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encoding response: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, '\n')
|
||||||
|
|
||||||
|
if err := writeOutput(registerArgs.output, out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.AuthURL != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "AuthURL: %s\n", resp.AuthURL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapArgs struct {
|
||||||
|
nodeFile string
|
||||||
|
stream bool
|
||||||
|
peers bool
|
||||||
|
quiet bool
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapCmd = &ffcli.Command{
|
||||||
|
Name: "map",
|
||||||
|
ShortUsage: "tsp [-s url] map -n <node-file> [-stream]",
|
||||||
|
ShortHelp: "Send a map request to the coordination server.",
|
||||||
|
FlagSet: (func() *flag.FlagSet {
|
||||||
|
fs := flag.NewFlagSet("map", flag.ExitOnError)
|
||||||
|
fs.StringVar(&mapArgs.nodeFile, "n", "", "node JSON file (required)")
|
||||||
|
fs.BoolVar(&mapArgs.stream, "stream", false, "stream map responses")
|
||||||
|
fs.BoolVar(&mapArgs.peers, "peers", true, "include peers in map response")
|
||||||
|
fs.BoolVar(&mapArgs.quiet, "quiet", true, "suppress keepalives and handled c2n ping requests from output")
|
||||||
|
fs.StringVar(&mapArgs.output, "o", "", "output file (default: stdout)")
|
||||||
|
return fs
|
||||||
|
})(),
|
||||||
|
Exec: runMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMap(ctx context.Context, args []string) error {
|
||||||
|
if mapArgs.nodeFile == "" {
|
||||||
|
return fmt.Errorf("flag -n (node file) is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
nf, err := tsp.ReadNodeFile(mapArgs.nodeFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading node file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if globalArgs.serverURL != "" && globalArgs.serverURL != nf.URL {
|
||||||
|
return fmt.Errorf("server URL mismatch: -s flag is %q but node file is for %q", globalArgs.serverURL, nf.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
hi := hostinfo.New()
|
||||||
|
|
||||||
|
client, err := tsp.NewClient(tsp.ClientOpts{
|
||||||
|
ServerURL: cmp.Or(globalArgs.serverURL, nf.URL),
|
||||||
|
MachineKey: nf.MachineKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating client: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if globalArgs.controlKeyFile != "" {
|
||||||
|
controlKey, err := readControlKeyFile(globalArgs.controlKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading control key: %w", err)
|
||||||
|
}
|
||||||
|
client.SetControlPublicKey(controlKey)
|
||||||
|
} else {
|
||||||
|
client.SetControlPublicKey(nf.ServerInfo.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := client.Map(ctx, tsp.MapOpts{
|
||||||
|
NodeKey: nf.NodeKey,
|
||||||
|
Hostinfo: hi,
|
||||||
|
Stream: mapArgs.stream,
|
||||||
|
OmitPeers: !mapArgs.peers,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
gotResponse := false
|
||||||
|
for {
|
||||||
|
resp, err := session.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
if !gotResponse {
|
||||||
|
return fmt.Errorf("server returned no map response")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading map response: %w", err)
|
||||||
|
}
|
||||||
|
gotResponse = true
|
||||||
|
|
||||||
|
if pr := resp.PingRequest; pr != nil && pr.Types == "c2n" {
|
||||||
|
if client.AnswerC2NPing(ctx, pr, session.NoiseRoundTrip) && mapArgs.quiet {
|
||||||
|
resp.PingRequest = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mapArgs.quiet {
|
||||||
|
resp.KeepAlive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if isZeroMapResponse(resp) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := json.MarshalIndent(resp, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encoding response: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, '\n')
|
||||||
|
if err := writeOutput(mapArgs.output, out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMachineKeyFile reads a machine private key from a file.
|
||||||
|
func readMachineKeyFile(path string) (key.MachinePrivate, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return key.MachinePrivate{}, err
|
||||||
|
}
|
||||||
|
var k key.MachinePrivate
|
||||||
|
if err := k.UnmarshalText(bytes.TrimSpace(data)); err != nil {
|
||||||
|
return key.MachinePrivate{}, fmt.Errorf("parsing machine key from %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readNodeKeyFile reads a node private key from a file.
|
||||||
|
func readNodeKeyFile(path string) (key.NodePrivate, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return key.NodePrivate{}, err
|
||||||
|
}
|
||||||
|
var k key.NodePrivate
|
||||||
|
if err := k.UnmarshalText(bytes.TrimSpace(data)); err != nil {
|
||||||
|
return key.NodePrivate{}, fmt.Errorf("parsing node key from %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readControlKeyFile reads a file containing a server's MachinePublic key
|
||||||
|
// in its MarshalText form (e.g. "mkey:...").
|
||||||
|
func readControlKeyFile(path string) (key.MachinePublic, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return key.MachinePublic{}, err
|
||||||
|
}
|
||||||
|
var k key.MachinePublic
|
||||||
|
if err := k.UnmarshalText(bytes.TrimSpace(data)); err != nil {
|
||||||
|
return key.MachinePublic{}, fmt.Errorf("parsing control key from %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAuthKey returns the auth key from v. If v is empty, it returns "".
|
||||||
|
// If v starts with "tskey-", it's used directly. Otherwise v is treated as a
|
||||||
|
// filename and its contents are read and trimmed.
|
||||||
|
func resolveAuthKey(v string) (string, error) {
|
||||||
|
if v == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(v), "tskey-") {
|
||||||
|
return strings.TrimSpace(v), nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading auth key file: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOutput(path string, data []byte) error {
|
||||||
|
if path == "" {
|
||||||
|
_, err := os.Stdout.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isZeroMapResponse reports whether all fields of resp are zero values.
|
||||||
|
func isZeroMapResponse(resp *tailcfg.MapResponse) bool {
|
||||||
|
v := reflect.ValueOf(*resp)
|
||||||
|
for i := range v.NumField() {
|
||||||
|
if !v.Field(i).IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
"tailscale.com/net/netmon"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bypassControlFunc is set as net.Dialer.Control so that sockets dialed by
|
||||||
|
// TTA bypass tailscaled's policy routing. Without it, sockets opened before
|
||||||
|
// tailscaled installs an exit-node route would have their packets rerouted
|
||||||
|
// via the exit node when the route is later installed, breaking the
|
||||||
|
// existing connection.
|
||||||
|
//
|
||||||
|
// We bind the socket to the default route's interface (typically the VM's
|
||||||
|
// LAN-facing NIC) rather than relying on the bypass fwmark. The fwmark
|
||||||
|
// approach is conditional on tailscaled having configured SO_MARK-based
|
||||||
|
// policy routing; binding to the underlying interface is unconditional.
|
||||||
|
func bypassControlFunc(network, address string, c syscall.RawConn) error {
|
||||||
|
ifc, err := netmon.DefaultRouteInterface()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("netmon.DefaultRouteInterface: %w", err)
|
||||||
|
}
|
||||||
|
var sockErr error
|
||||||
|
if err := c.Control(func(fd uintptr) {
|
||||||
|
sockErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, ifc)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sockErr != nil {
|
||||||
|
return fmt.Errorf("setting SO_BINDTODEVICE on %q: %w", ifc, sockErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "syscall"
|
||||||
|
|
||||||
|
// bypassControlFunc is a no-op on non-Linux platforms; SO_MARK is a Linux
|
||||||
|
// concept and exit-node routing only matters here for Linux VMs in vmtest.
|
||||||
|
func bypassControlFunc(network, address string, c syscall.RawConn) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
"tailscale.com/tstest/natlab/vnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
afVSOCK = 40 // AF_VSOCK on macOS
|
||||||
|
vmaddrCIDHost = 2 // VMADDR_CID_HOST
|
||||||
|
vsockPort = 51011 // port for IP assignment protocol
|
||||||
|
)
|
||||||
|
|
||||||
|
// sockaddrVM is the Go equivalent of struct sockaddr_vm from <sys/vsock.h>.
|
||||||
|
type sockaddrVM struct {
|
||||||
|
Len uint8
|
||||||
|
Family uint8
|
||||||
|
Reserved1 uint16
|
||||||
|
Port uint32
|
||||||
|
CID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type netConfig struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Mask string `json:"mask"`
|
||||||
|
GW string `json:"gw"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// startIPAssignLoop starts a background goroutine that polls the host
|
||||||
|
// via the virtio socket for an IP assignment. When the host responds
|
||||||
|
// with a JSON config (rather than "wait"), TTA sets the IP statically
|
||||||
|
// using ifconfig and stops polling.
|
||||||
|
func startIPAssignLoop() {
|
||||||
|
go ipAssignLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipAssignLoop() {
|
||||||
|
log.Printf("ipassign: starting vsock poll loop")
|
||||||
|
var lastErr string
|
||||||
|
for attempt := 0; ; attempt++ {
|
||||||
|
resp, err := askHostForIP()
|
||||||
|
if err != nil {
|
||||||
|
if e := err.Error(); e != lastErr {
|
||||||
|
log.Printf("ipassign: attempt %d: %v", attempt, err)
|
||||||
|
lastErr = e
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp == "wait" {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var nc netConfig
|
||||||
|
if err := json.Unmarshal([]byte(resp), &nc); err != nil {
|
||||||
|
log.Printf("ipassign: bad config: %v", err)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := setStaticIP(nc); err != nil {
|
||||||
|
log.Printf("ipassign: %v", err)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("ipassign: configured en0 with %s/%s gw %s", nc.IP, nc.Mask, nc.GW)
|
||||||
|
|
||||||
|
// Switch the driver address from the DNS name to the IP directly
|
||||||
|
// (avoids DNS resolution delay) and kick the dial-out loop so it
|
||||||
|
// retries immediately with the new address.
|
||||||
|
ipAddr := net.JoinHostPort(vnet.TestDriverIPv4().String(), strconv.Itoa(vnet.TestDriverPort))
|
||||||
|
*driverAddr = ipAddr
|
||||||
|
log.Printf("ipassign: switched driver addr to %s", ipAddr)
|
||||||
|
resetDialCancels()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// askHostForIP connects to the host via AF_VSOCK and reads the response.
|
||||||
|
func askHostForIP() (string, error) {
|
||||||
|
fd, err := unix.Socket(afVSOCK, unix.SOCK_STREAM, 0)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("socket: %w", err)
|
||||||
|
}
|
||||||
|
defer unix.Close(fd)
|
||||||
|
|
||||||
|
// Set a short connect+read timeout via SO_RCVTIMEO.
|
||||||
|
tv := unix.Timeval{Sec: 1}
|
||||||
|
unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv)
|
||||||
|
|
||||||
|
addr := sockaddrVM{
|
||||||
|
Len: uint8(unsafe.Sizeof(sockaddrVM{})),
|
||||||
|
Family: afVSOCK,
|
||||||
|
Port: vsockPort,
|
||||||
|
CID: vmaddrCIDHost,
|
||||||
|
}
|
||||||
|
_, _, errno := unix.RawSyscall(unix.SYS_CONNECT, uintptr(fd),
|
||||||
|
uintptr(unsafe.Pointer(&addr)), unsafe.Sizeof(addr))
|
||||||
|
if errno != 0 {
|
||||||
|
return "", fmt.Errorf("connect: %w", errno)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf [1024]byte
|
||||||
|
n, err := unix.Read(fd, buf[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read: %w", err)
|
||||||
|
}
|
||||||
|
return string(buf[:n]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setStaticIP configures en0 with a static IP address and default route.
|
||||||
|
func setStaticIP(nc netConfig) error {
|
||||||
|
out, err := exec.Command("ifconfig", "en0", nc.IP, "netmask", nc.Mask, "up").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ifconfig: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
out, err = exec.Command("route", "add", "default", nc.GW).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("route add: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// startIPAssignLoop is a no-op on non-macOS platforms.
|
||||||
|
// macOS VMs use vsock-based IP assignment to bypass slow DHCP.
|
||||||
|
func startIPAssignLoop() {}
|
||||||
|
|
||||||
|
// Reference resetDialCancels to prevent unused-function lint errors.
|
||||||
|
// It's called from ipassign_darwin.go on macOS builds.
|
||||||
|
var _ = resetDialCancels
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
restartTailscaled = restartTailscaledLinux
|
||||||
|
}
|
||||||
|
|
||||||
|
// restartTailscaledLinux finds the tailscaled process by walking /proc and
|
||||||
|
// sends it SIGKILL. On gokrazy, the supervisor will restart tailscaled within
|
||||||
|
// a few seconds. The PID of the process that was killed is returned.
|
||||||
|
func restartTailscaledLinux() (int, error) {
|
||||||
|
ents, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, e := range ents {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
comm, err := os.ReadFile("/proc/" + e.Name() + "/comm")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(comm)) != "tailscaled" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
proc, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := proc.Kill(); err != nil {
|
||||||
|
return 0, fmt.Errorf("killing tailscaled pid %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
return pid, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("tailscaled process not found in /proc")
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user