Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52cae45f81 | |||
| 7fd2507611 | |||
| 8514045909 | |||
| 7f5983eaab | |||
| 143581c955 | |||
| d9efc3bae2 | |||
| 9e36a7f27f | |||
| 8277fc0f1d | |||
| e32520659d | |||
| e8eb9d71c2 | |||
| c4ff4c4835 | |||
| 68ecc4b033 | |||
| 9f96b7434c | |||
| b04b4f7751 | |||
| f961db8925 | |||
| fde5f11895 | |||
| 756ba1d5ec | |||
| 68670f938b |
@@ -37,6 +37,8 @@ jobs:
|
||||
- "elementary/docker:stable"
|
||||
- "elementary/docker:unstable"
|
||||
- "parrotsec/core:latest"
|
||||
- "kalilinux/kali-rolling"
|
||||
- "kalilinux/kali-dev"
|
||||
- "oraclelinux:9"
|
||||
- "oraclelinux:8"
|
||||
- "fedora:latest"
|
||||
@@ -59,9 +61,6 @@ jobs:
|
||||
- { image: "debian:stable-slim", deps: "curl" }
|
||||
- { image: "ubuntu:24.04", deps: "curl" }
|
||||
- { image: "fedora:latest", deps: "curl" }
|
||||
# Kali doesn't have ca-certificates installed by default anymore
|
||||
- { image: "kalilinux/kali-dev", "deps": "curl ca-certificates"}
|
||||
- { image: "kalilinux/kali-rolling", "deps": "curl ca-certificates"}
|
||||
# Test TAILSCALE_VERSION pinning on a subset of distros.
|
||||
# Skip Alpine as community repos don't reliably keep old versions.
|
||||
- { image: "debian:stable-slim", deps: "curl", version: "1.80.0" }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Run a single natlab smoke test on every PR. The full natlab suite
|
||||
# is opt-in and lives in .github/workflows/natlab-test.yml.
|
||||
# Run some natlab integration tests.
|
||||
# See https://github.com/tailscale/tailscale/issues/13038
|
||||
name: "natlab-basic"
|
||||
name: "natlab-integrationtest"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
@@ -18,28 +17,17 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
jobs:
|
||||
EasyEasy:
|
||||
natlab-integrationtest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
- name: Install qemu
|
||||
run: |
|
||||
sudo rm -f /var/lib/man-db/auto-update
|
||||
sudo apt-get -y update
|
||||
sudo apt-get -y remove man-db
|
||||
sudo apt-get install -y qemu-system-x86 qemu-utils
|
||||
- name: Build VM image
|
||||
# The test will build this if missing, but we do it explicitly
|
||||
# to avoid cutting into the go test -timeout budget, and to
|
||||
# fail earlier with a clearer error if the image build breaks.
|
||||
run: |
|
||||
make -C gokrazy natlab
|
||||
- name: Run natlab integration tests
|
||||
run: |
|
||||
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/natlab/vmtest --run-vm-tests
|
||||
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/integration/nat --run-vm-tests
|
||||
@@ -1,182 +0,0 @@
|
||||
# Run the full natlab/vmtest opt-in test suite. These tests boot QEMU VMs
|
||||
# (gokrazy, Ubuntu, FreeBSD) and exercise vnet-driven networking scenarios.
|
||||
# They are gated behind --run-vm-tests because they need KVM and are slow.
|
||||
#
|
||||
# This workflow runs:
|
||||
# - on demand (workflow_dispatch)
|
||||
# - on PRs that carry the "run-natlab-tests" label
|
||||
# - on main, every 12 hours, via cron
|
||||
#
|
||||
# Layout:
|
||||
# - "prepare" builds the gokrazy VM image, downloads the cloud images
|
||||
# (Ubuntu, FreeBSD), and discovers every Test* function in the two
|
||||
# opt-in packages.
|
||||
# - "test" is a per-TestFoo matrix that depends on prepare. Each matrix
|
||||
# job restores the shared caches and runs a single test. Adding a new
|
||||
# TestFoo automatically gets its own job — no workflow edits needed.
|
||||
#
|
||||
# A separate workflow (.github/workflows/natlab-basic.yml) runs a single
|
||||
# canary natlab test on every PR; this one runs the full suite.
|
||||
name: "natlab-test"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [labeled, synchronize, reopened]
|
||||
schedule:
|
||||
# Every 12 hours, off-the-hour to avoid GitHub's :00 cron-stampede window.
|
||||
- cron: "23 3,15 * * *"
|
||||
|
||||
jobs:
|
||||
# prepare warms the per-workflow-run caches (gokrazy image, cloud VM
|
||||
# images) and emits the dynamic matrix of test names. By doing the work
|
||||
# once here, the matrix test jobs never race to rebuild or re-download
|
||||
# the same artifacts on a cold cache.
|
||||
prepare:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-natlab-tests'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
matrix: ${{ steps.list.outputs.matrix }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# The cloud VM image cache is keyed only on images.go (image URLs and
|
||||
# SHAs), so it survives across workflow runs and is invalidated only
|
||||
# when a new image source is added.
|
||||
- name: Cache cloud VM images
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: ~/.cache/tailscale/vmtest/images
|
||||
key: natlab-vmimages-${{ hashFiles('tstest/natlab/vmtest/images.go') }}
|
||||
|
||||
# The gokrazy VM image is keyed by github.sha. That means we rebuild
|
||||
# it once per commit but matrix test jobs in the same run all share
|
||||
# the result. Per-PR re-runs of the same sha (e.g. a rerun-failed)
|
||||
# also get the cache.
|
||||
- name: Cache gokrazy VM image
|
||||
id: gokrazy-cache
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: gokrazy/natlabapp.qcow2
|
||||
key: natlab-gokrazy-${{ github.sha }}
|
||||
|
||||
# qemu-utils provides qemu-img, which the gokrazy Makefile uses to
|
||||
# convert natlabapp.img to qcow2. Only install if we need it (cache
|
||||
# miss); the test matrix jobs install qemu separately for the runtime.
|
||||
- name: Install qemu-utils
|
||||
if: steps.gokrazy-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
sudo rm -f /var/lib/man-db/auto-update
|
||||
sudo apt-get -y update
|
||||
sudo apt-get -y remove man-db
|
||||
sudo apt-get install -y qemu-utils
|
||||
|
||||
- name: Download cloud VM images
|
||||
# natlabprep is idempotent: it checks the cache before downloading.
|
||||
run: |
|
||||
./tool/go run ./tstest/natlab/vmtest/cmd/natlabprep
|
||||
|
||||
- name: Build gokrazy VM image
|
||||
if: steps.gokrazy-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
make -C gokrazy natlab
|
||||
|
||||
- name: Discover tests
|
||||
id: list
|
||||
# Grep the test files directly rather than invoking `go test -list`
|
||||
# so we don't pay the cost of compiling the test binaries here. The
|
||||
# only test functions in these packages use the canonical
|
||||
# `func TestFoo(t *testing.T)` signature.
|
||||
#
|
||||
# exclude is the set of tests that need special invocation
|
||||
# (extra flags, a specific environment) and don't fit the
|
||||
# single-test-per-matrix-job model. They stay runnable locally.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
exclude='^(TestGrid)$'
|
||||
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 in various Docker containers.
|
||||
# These tests can also be run locally via `make sshintegrationtest`.
|
||||
# Run the ssh integration tests with `make sshintegrationtest`.
|
||||
# These tests can also be running locally.
|
||||
name: "ssh-integrationtest"
|
||||
|
||||
concurrency:
|
||||
@@ -15,25 +15,9 @@ on:
|
||||
jobs:
|
||||
ssh-integrationtest:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- base: "ubuntu:focal"
|
||||
tag: "ssh-ubuntu-focal"
|
||||
- base: "ubuntu:jammy"
|
||||
tag: "ssh-ubuntu-jammy"
|
||||
- base: "ubuntu:noble"
|
||||
tag: "ssh-ubuntu-noble"
|
||||
- base: "alpine:latest"
|
||||
tag: "ssh-alpine-latest"
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Build test binaries
|
||||
- name: Run SSH integration tests
|
||||
run: |
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled
|
||||
- name: Run SSH integration tests (${{ matrix.base }})
|
||||
run: |
|
||||
docker build --build-arg="BASE=${{ matrix.base }}" -t "${{ matrix.tag }}" ssh/tailssh/testcontainers
|
||||
make sshintegrationtest
|
||||
@@ -361,7 +361,7 @@ jobs:
|
||||
run: chown -R $(id -u):$(id -g) $PWD
|
||||
- name: privileged tests
|
||||
working-directory: src
|
||||
run: ./tool/go test $(./tool/go run ./tool/listpkgs --has-root-tests)
|
||||
run: ./tool/go test ./util/linuxfw ./derp/xdp
|
||||
|
||||
vm:
|
||||
needs: gomod-cache
|
||||
@@ -787,14 +787,6 @@ jobs:
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
|
||||
- name: check that 'genreadme' is clean
|
||||
working-directory: src
|
||||
run: |
|
||||
./tool/go run ./misc/genreadme
|
||||
git add -N . # ensure untracked files are noticed
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run './tool/go run ./misc/genreadme'."; exit 1)
|
||||
|
||||
make_tidy:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -23,8 +23,8 @@ jobs:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Run updateflakes
|
||||
run: ./tool/go run ./tool/updateflakes
|
||||
- name: Run update-flakes
|
||||
run: ./update-flake.sh
|
||||
|
||||
- name: Get access token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
@@ -41,8 +41,8 @@ jobs:
|
||||
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
committer: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
branch: flakes
|
||||
commit-message: "flakehashes.json: update SRI hash for go.mod changes"
|
||||
title: "flakehashes.json: update SRI hash for go.mod changes"
|
||||
commit-message: "go.mod.sri: update SRI hash for go.mod changes"
|
||||
title: "go.mod.sri: update SRI hash for go.mod changes"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
|
||||
+1
-4
@@ -1,15 +1,12 @@
|
||||
# Binaries for programs and plugins
|
||||
*~
|
||||
*.tmp
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.spk
|
||||
|
||||
*.exe
|
||||
# tool/go.exe is built specially and committed.
|
||||
!/tool/go.exe
|
||||
|
||||
cmd/tailscale/tailscale
|
||||
cmd/tailscaled/tailscaled
|
||||
ssh/tailssh/testcontainers/tailscaled
|
||||
|
||||
@@ -10,7 +10,7 @@ vet: ## Run go vet
|
||||
|
||||
tidy: ## Run go mod tidy and update nix flake hashes
|
||||
./tool/go mod tidy
|
||||
./tool/go run ./tool/updateflakes
|
||||
./update-flake.sh
|
||||
|
||||
lint: ## Run golangci-lint
|
||||
./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run
|
||||
@@ -137,12 +137,10 @@ publishdevproxy: check-image-repo ## Build and publish k8s-proxy image to locati
|
||||
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
|
||||
@GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
|
||||
echo "Testing on ubuntu:focal, ubuntu:jammy, ubuntu:noble, alpine:latest (in parallel)" && \
|
||||
docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers & \
|
||||
docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers & \
|
||||
docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers & \
|
||||
docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers & \
|
||||
wait
|
||||
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
|
||||
echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers
|
||||
|
||||
.PHONY: generate
|
||||
generate: ## Generate code
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.99.0
|
||||
1.97.0
|
||||
|
||||
@@ -736,7 +736,6 @@ func TestRateLogger(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouteStoreMetrics(t *testing.T) {
|
||||
clientmetric.ResetForTest(t)
|
||||
metricStoreRoutes(1, 1)
|
||||
metricStoreRoutes(1, 1) // the 1 buckets value should be 2
|
||||
metricStoreRoutes(5, 5) // the 5 buckets value should be 1
|
||||
|
||||
+7
-29
@@ -6,7 +6,6 @@ package appc
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/ipn/ipnext"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -17,7 +16,7 @@ import (
|
||||
|
||||
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
|
||||
|
||||
func isPeerEligibleConnector(peer tailcfg.NodeView) bool {
|
||||
func isEligibleConnector(peer tailcfg.NodeView) bool {
|
||||
if !peer.Valid() || !peer.Hostinfo().Valid() {
|
||||
return false
|
||||
}
|
||||
@@ -40,7 +39,7 @@ func sortByPreference(ns []tailcfg.NodeView) {
|
||||
func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.NodeView {
|
||||
appTagsSet := set.SetOf(app.Connectors)
|
||||
matches := nb.AppendMatchingPeers(nil, func(n tailcfg.NodeView) bool {
|
||||
if !isPeerEligibleConnector(n) {
|
||||
if !isEligibleConnector(n) {
|
||||
return false
|
||||
}
|
||||
for _, t := range n.Tags().All() {
|
||||
@@ -56,7 +55,7 @@ func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.Nod
|
||||
|
||||
// PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers
|
||||
// want to be connectors for which domains.
|
||||
func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView, peers map[tailcfg.NodeID]tailcfg.NodeView, isSelfEligibleConnector bool) map[string][]tailcfg.NodeView {
|
||||
func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView, peers map[tailcfg.NodeID]tailcfg.NodeView) map[string][]tailcfg.NodeView {
|
||||
var m map[string][]tailcfg.NodeView
|
||||
if !hasCap(AppConnectorsExperimentalAttrName) {
|
||||
return m
|
||||
@@ -65,43 +64,22 @@ func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
|
||||
// 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]{}
|
||||
tagToDomain := make(map[string][]string)
|
||||
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 {
|
||||
if tagToDomain[tag] == nil {
|
||||
tagToDomain[tag] = set.Set[string]{}
|
||||
}
|
||||
tagToDomain[tag].AddSet(domains)
|
||||
if isSelfEligibleConnector && selfTags.Contains(tag) {
|
||||
selfRoutedDomains.AddSet(domains)
|
||||
}
|
||||
tagToDomain[tag] = append(tagToDomain[tag], app.Domains...)
|
||||
}
|
||||
}
|
||||
// NodeIDs are Comparable, and we have a map of NodeID to NodeView anyway, so
|
||||
// use a Set of NodeIDs to deduplicate, and populate into a []NodeView later.
|
||||
var work map[string]set.Set[tailcfg.NodeID]
|
||||
for _, peer := range peers {
|
||||
if !isPeerEligibleConnector(peer) {
|
||||
if !isEligibleConnector(peer) {
|
||||
continue
|
||||
}
|
||||
for _, t := range peer.Tags().All() {
|
||||
domains := tagToDomain[t]
|
||||
for domain := range domains {
|
||||
if selfRoutedDomains.Contains(domain) {
|
||||
continue
|
||||
}
|
||||
for _, domain := range domains {
|
||||
if work[domain] == nil {
|
||||
mak.Set(&work, domain, set.Set[tailcfg.NodeID]{})
|
||||
}
|
||||
|
||||
+2
-130
@@ -32,8 +32,6 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
||||
appTwoBytes := getBytesForAttr("app2", []string{"a.example.com"}, []string{"tag:two"})
|
||||
appThreeBytes := getBytesForAttr("app3", []string{"woo.b.example.com", "hoo.b.example.com"}, []string{"tag:three1", "tag:three2"})
|
||||
appFourBytes := getBytesForAttr("app4", []string{"woo.b.example.com", "c.example.com"}, []string{"tag:four1", "tag:four2"})
|
||||
appFiveBytes := getBytesForAttr("app5", []string{"*.example.com", "example.com"}, []string{"tag:one"})
|
||||
appSixBytes := getBytesForAttr("app6", []string{"*.Example.com", "EXAMPLE.com", "EXAMPLE.COM"}, []string{"tag:one"})
|
||||
|
||||
makeNodeView := func(id tailcfg.NodeID, name string, tags []string) tailcfg.NodeView {
|
||||
return (&tailcfg.Node{
|
||||
@@ -50,11 +48,9 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
want map[string][]tailcfg.NodeView
|
||||
peers []tailcfg.NodeView
|
||||
config []tailcfg.RawMessage
|
||||
isEligibleConnector bool
|
||||
selfTags []string
|
||||
want map[string][]tailcfg.NodeView
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
@@ -115,128 +111,6 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
||||
"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) {
|
||||
selfNode := &tailcfg.Node{}
|
||||
@@ -245,7 +119,6 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
||||
tailcfg.NodeCapability(AppConnectorsExperimentalAttrName): tt.config,
|
||||
}
|
||||
}
|
||||
selfNode.Tags = append(selfNode.Tags, tt.selfTags...)
|
||||
selfView := selfNode.View()
|
||||
peers := map[tailcfg.NodeID]tailcfg.NodeView{}
|
||||
for _, p := range tt.peers {
|
||||
@@ -253,8 +126,7 @@ func TestPickSplitDNSPeers(t *testing.T) {
|
||||
}
|
||||
got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool {
|
||||
return true
|
||||
}, selfView, peers, tt.isEligibleConnector)
|
||||
|
||||
}, selfView, peers)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailscaleroot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/util/cibuild"
|
||||
)
|
||||
|
||||
// TestTsgoRevInCacheKey verifies that the Tailscale Go toolchain's git
|
||||
// revision (from go.toolchain.rev) is blended into Go build cache keys.
|
||||
// Without this, bumping the toolchain to a new commit that doesn't change
|
||||
// the Go version number would silently reuse stale cached build artifacts.
|
||||
//
|
||||
// See https://github.com/tailscale/tailscale/issues/36589.
|
||||
func TestTsgoRevInCacheKey(t *testing.T) {
|
||||
goRoot := goEnv(t, "GOROOT")
|
||||
isTsgo := strings.Contains(goRoot, "/.cache/tsgo/")
|
||||
if !cibuild.OnTailscaleCI() && !isTsgo {
|
||||
t.Skip("skipping; not in Tailscale CI and not using the Tailscale Go toolchain")
|
||||
}
|
||||
|
||||
rev := strings.TrimSpace(GoToolchainRev)
|
||||
if rev == "" {
|
||||
t.Fatal("go.toolchain.rev is empty")
|
||||
}
|
||||
|
||||
// Build the small stdlib "errors" package with GODEBUG=gocachehash=1,
|
||||
// which causes cmd/go to log its cache key computations to stderr.
|
||||
cmd := exec.Command("go", "build", "errors")
|
||||
cmd.Env = append(os.Environ(), "GODEBUG=gocachehash=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("go build errors failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// The cache key output should contain the toolchain rev alongside the
|
||||
// Go version, e.g.:
|
||||
// HASH[moduleIndex]: "go1.26.2 dfe2a5fd8ee2e68b08ce5ff259269f50ecadf2f4"
|
||||
if !strings.Contains(string(out), rev) {
|
||||
t.Errorf("go.toolchain.rev %q not found in GODEBUG=gocachehash=1 output:\n%s", rev, out)
|
||||
}
|
||||
}
|
||||
|
||||
func goEnv(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("go", "env", key).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go env %s: %v", key, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
@@ -327,35 +327,6 @@ func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsR
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
}
|
||||
|
||||
// WhoIsForService is like [Client.WhoIs] but scopes the returned CapMap to
|
||||
// capabilities that apply to the named VIP service. This enables per-service
|
||||
// capability resolution on hosts that advertise multiple VIP services.
|
||||
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
|
||||
// [Client.WhoIsProto] when a peer is not found.
|
||||
var ErrPeerNotFound = errors.New("peer not found")
|
||||
@@ -636,24 +607,6 @@ func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, erro
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// GetDebugResultJSON invokes a debug action and decodes the JSON response
|
||||
// into a value of type T. It avoids the marshal/unmarshal roundtrip that
|
||||
// callers of [Client.DebugResultJSON] otherwise need to do to get a typed
|
||||
// value.
|
||||
//
|
||||
// These are development tools 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.
|
||||
func (lc *Client) QueryOptionalFeatures(ctx context.Context) (*apitype.OptionalFeatures, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-optional-features", 200, nil)
|
||||
@@ -1019,19 +972,6 @@ func (lc *Client) UserDial(ctx context.Context, network, host string, port uint1
|
||||
if res.StatusCode != http.StatusSwitchingProtocols {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if res.StatusCode == http.StatusOK && res.Header.Get("Dial-Self") == "true" {
|
||||
// Server told us to dial the address ourselves rather than
|
||||
// proxying through the daemon. This happens for non-Tailscale
|
||||
// addresses where the daemon shouldn't dial as root on the
|
||||
// client's behalf. The server provides the resolved address
|
||||
// to avoid a TOCTOU race with DNS re-resolution.
|
||||
addr := res.Header.Get("Dial-Addr")
|
||||
if addr == "" {
|
||||
return nil, errors.New("server returned Dial-Self without Dial-Addr")
|
||||
}
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body)
|
||||
}
|
||||
// From here on, the underlying net.Conn is ours to use, but there
|
||||
@@ -1069,44 +1009,6 @@ func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error)
|
||||
return &derpMap, nil
|
||||
}
|
||||
|
||||
// CertDomains returns the list of domains for which the local tailscaled can
|
||||
// fetch TLS certificates, equivalent to the DNS.CertDomains field of the
|
||||
// current netmap. The returned list is sorted in ascending order, and is
|
||||
// empty if no netmap has been received yet.
|
||||
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.
|
||||
//
|
||||
// The zero value is valid, which means to use defaults.
|
||||
@@ -1520,13 +1422,3 @@ func (lc *Client) GetAppConnectorRouteInfo(ctx context.Context) (appctype.RouteI
|
||||
}
|
||||
return decodeJSON[appctype.RouteInfo](body)
|
||||
}
|
||||
|
||||
// GetServices returns the Services visible to this node,
|
||||
// including their names, IP addresses, and ports, keyed by service name.
|
||||
func (lc *Client) GetServices(ctx context.Context) (map[tailcfg.ServiceName]tailcfg.ServiceDetails, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/services")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[map[tailcfg.ServiceName]tailcfg.ServiceDetails](body)
|
||||
}
|
||||
|
||||
@@ -61,57 +61,6 @@ func TestWhoIsPeerNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDialSelf(t *testing.T) {
|
||||
// Start a real TCP listener that the client should dial directly
|
||||
// when the server tells it to dial-self.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Write([]byte("hello"))
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
targetAddr := ln.Addr().(*net.TCPAddr)
|
||||
|
||||
// Mock LocalAPI server that returns Dial-Self response.
|
||||
nw := nettest.GetNetwork(t)
|
||||
ts := nettest.NewHTTPServer(nw, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Dial-Self", "true")
|
||||
w.Header().Set("Dial-Addr", targetAddr.String())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
lc := &Client{
|
||||
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return nw.Dial(ctx, network, ts.Listener.Addr().String())
|
||||
},
|
||||
}
|
||||
|
||||
conn, err := lc.UserDial(context.Background(), "tcp", targetAddr.IP.String(), uint16(targetAddr.Port))
|
||||
if err != nil {
|
||||
t.Fatalf("UserDial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
buf := make([]byte, 5)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if got := string(buf[:n]); got != "hello" {
|
||||
t.Errorf("got %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
BadDeps: map[string]string{
|
||||
|
||||
@@ -117,7 +117,7 @@ func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.Key
|
||||
return decodeJSON[[]tkatype.MarshaledSignature](body)
|
||||
}
|
||||
|
||||
// NetworkLockLog returns up to maxEntries number of changes to tailnet-lock state.
|
||||
// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
|
||||
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
|
||||
v := url.Values{}
|
||||
v.Set("limit", fmt.Sprint(maxEntries))
|
||||
@@ -128,7 +128,7 @@ func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstat
|
||||
return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
|
||||
}
|
||||
|
||||
// NetworkLockForceLocalDisable forcibly shuts down tailnet lock on this node.
|
||||
// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
|
||||
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
|
||||
// This endpoint expects an empty JSON stanza as the payload.
|
||||
var b bytes.Buffer
|
||||
@@ -142,7 +142,7 @@ func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetworkLockVerifySigningDeeplink verifies the tailnet lock deeplink contained
|
||||
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
|
||||
// in url and returns information extracted from it.
|
||||
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
|
||||
vr := struct {
|
||||
@@ -193,7 +193,7 @@ func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetworkLockDisable shuts down tailnet-lock across the tailnet.
|
||||
// NetworkLockDisable shuts down network-lock across the tailnet.
|
||||
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
|
||||
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
|
||||
return fmt.Errorf("error: %w", err)
|
||||
|
||||
+3
-41
@@ -11,7 +11,6 @@ import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -205,49 +204,12 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
black = color.NRGBA{0, 0, 0, 255}
|
||||
white = color.NRGBA{255, 255, 255, 255}
|
||||
darkGray = color.NRGBA{102, 102, 102, 255}
|
||||
lightGray = color.NRGBA{153, 153, 153, 255}
|
||||
bg = color.NRGBA{0, 0, 0, 255}
|
||||
fg = color.NRGBA{255, 255, 255, 255}
|
||||
gray = color.NRGBA{255, 255, 255, 102}
|
||||
red = color.NRGBA{229, 111, 74, 255}
|
||||
transparent = color.NRGBA{}
|
||||
|
||||
// default values to dark theme
|
||||
bg = black
|
||||
fg = white
|
||||
gray = darkGray
|
||||
)
|
||||
|
||||
// SetTheme sets the color theme of the systray icon.
|
||||
//
|
||||
// Supported themes are:
|
||||
// - dark - white and gray dots over black background
|
||||
// - dark:nobg - white and grey dots over transparent background
|
||||
// - light - black and gray dots over white background
|
||||
// - light:nobg - black and grey dots over transparent background
|
||||
func SetTheme(theme string) {
|
||||
switch theme {
|
||||
case "dark":
|
||||
bg = black
|
||||
fg = white
|
||||
gray = darkGray
|
||||
case "dark:nobg":
|
||||
bg = transparent
|
||||
fg = white
|
||||
gray = darkGray
|
||||
case "light":
|
||||
bg = white
|
||||
fg = black
|
||||
gray = lightGray
|
||||
case "light:nobg":
|
||||
bg = transparent
|
||||
fg = black
|
||||
gray = lightGray
|
||||
default:
|
||||
log.Printf("unknown theme: %q", theme)
|
||||
}
|
||||
}
|
||||
|
||||
// render returns a PNG image of the logo.
|
||||
func (logo tsLogo) render() *bytes.Buffer {
|
||||
const borderUnits = 1
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
//go:build cgo || !darwin
|
||||
|
||||
// Package systray provides a minimal Tailscale systray application.
|
||||
package systray
|
||||
|
||||
import (
|
||||
|
||||
@@ -621,9 +621,11 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
||||
title += strings.Split(sugg.Name, ".")[0]
|
||||
}
|
||||
menu.exitNodes.AddSeparator()
|
||||
active := recommendedIsActive(status, sugg.ID, sugg.Location.CountryCode(), sugg.Location.City())
|
||||
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", active)
|
||||
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
|
||||
setExitNodeOnClick(rm, sugg.ID)
|
||||
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
|
||||
rm.Check()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,11 +647,13 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
||||
if !ps.Online {
|
||||
name += " (offline)"
|
||||
}
|
||||
active := status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID
|
||||
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", active)
|
||||
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", false)
|
||||
if !ps.Online {
|
||||
sm.Disable()
|
||||
}
|
||||
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
|
||||
sm.Check()
|
||||
}
|
||||
setExitNodeOnClick(sm, ps.ID)
|
||||
}
|
||||
}
|
||||
@@ -739,30 +743,6 @@ func (mc *mvCountry) sortedCities() []*mvCity {
|
||||
return cities
|
||||
}
|
||||
|
||||
// recommendedIsActive reports whether the suggested exit node corresponds to
|
||||
// the currently active exit node in status.
|
||||
func recommendedIsActive(status *ipnstate.Status, suggID tailcfg.StableNodeID, suggCountry, suggCity string) bool {
|
||||
if status == nil || status.ExitNodeStatus == nil || status.ExitNodeStatus.ID.IsZero() {
|
||||
return false
|
||||
}
|
||||
if suggID == status.ExitNodeStatus.ID {
|
||||
return true
|
||||
}
|
||||
if suggCountry == "" || suggCity == "" {
|
||||
return false
|
||||
}
|
||||
for _, p := range status.Peer {
|
||||
if p.ID != status.ExitNodeStatus.ID {
|
||||
continue
|
||||
}
|
||||
if loc := p.Location; loc != nil && loc.CountryCode == suggCountry && loc.City == suggCity {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// countryFlag takes a 2-character ASCII string and returns the corresponding emoji flag.
|
||||
// It returns the empty string on error.
|
||||
func countryFlag(code string) string {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build cgo || !darwin
|
||||
|
||||
package systray
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+119
-78
@@ -35,10 +35,8 @@ import (
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/ctxkey"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/syspolicy/policyclient"
|
||||
"tailscale.com/version"
|
||||
@@ -529,40 +527,45 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleJSON manages decoding the request's body JSON as data and passing it
|
||||
// on to the provided handler function.
|
||||
func handleJSON[data any](h func(ctx context.Context, data data) error) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
var body data
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
type apiHandler[data any] struct {
|
||||
s *Server
|
||||
w http.ResponseWriter
|
||||
r *http.Request
|
||||
|
||||
// permissionCheck allows for defining whether a requesting peer's
|
||||
// capabilities grant them access to make the given data update.
|
||||
// If permissionCheck reports false, the request fails as unauthorized.
|
||||
permissionCheck func(data data, peer peerCapabilities) bool
|
||||
}
|
||||
if err := h(r.Context(), body); err != nil {
|
||||
if httpErr, ok := errors.AsType[tsweb.HTTPError](err); ok {
|
||||
tsweb.WriteHTTPError(w, r, httpErr)
|
||||
} else {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// newHandler constructs a new api handler which restricts the given request
|
||||
// to the specified permission check. If the permission check fails for
|
||||
// the peer associated with the request, an unauthorized error is returned
|
||||
// to the client.
|
||||
func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
|
||||
return &apiHandler[data]{
|
||||
s: s,
|
||||
w: w,
|
||||
r: r,
|
||||
permissionCheck: permissionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
var contextKeyPeer = ctxkey.New("peer-capabilities", peerCapabilities{})
|
||||
// alwaysAllowed can be passed as the permissionCheck argument to newHandler
|
||||
// for requests that are always allowed to complete regardless of a peer's
|
||||
// capabilities.
|
||||
func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
|
||||
|
||||
func (s *Server) setPeer(r *http.Request) (*http.Request, error) {
|
||||
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
|
||||
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
|
||||
// WhoIs when originally checking for a session from authorizeRequest.
|
||||
// Would be nice if we could pipe those through to here so we don't end
|
||||
// up having to re-call them to grab the peer capabilities.
|
||||
status, err := s.lc.StatusWithoutPeers(r.Context())
|
||||
status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -570,11 +573,56 @@ func (s *Server) setPeer(r *http.Request) (*http.Request, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.WithContext(contextKeyPeer.WithValue(r.Context(), peer)), nil
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
func (s *Server) getPeer(ctx context.Context) peerCapabilities {
|
||||
return contextKeyPeer.Value(ctx)
|
||||
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
|
||||
|
||||
// handle runs the given handler if the source peer satisfies the
|
||||
// constraints for running this request.
|
||||
//
|
||||
// handle is expected for use when `data` type is empty, or set to
|
||||
// `noBodyData` in practice. For requests that expect JSON body data
|
||||
// to be attached, use handleJSON instead.
|
||||
func (a *apiHandler[data]) handle(h http.HandlerFunc) {
|
||||
peer, err := a.getPeer()
|
||||
if err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var body data // not used
|
||||
if !a.permissionCheck(body, peer) {
|
||||
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h(a.w, a.r)
|
||||
}
|
||||
|
||||
// handleJSON manages decoding the request's body JSON and passing
|
||||
// it on to the provided function if the source peer satisfies the
|
||||
// constraints for running this request.
|
||||
func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
|
||||
defer a.r.Body.Close()
|
||||
var body data
|
||||
if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
peer, err := a.getPeer()
|
||||
if err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !a.permissionCheck(body, peer) {
|
||||
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h(a.r.Context(), body); err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
@@ -589,44 +637,67 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
r, err = s.setPeer(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case path == "/data" && r.Method == httpm.GET:
|
||||
s.serveGetNodeData(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveGetNodeData)
|
||||
return
|
||||
case path == "/exit-nodes" && r.Method == httpm.GET:
|
||||
s.serveGetExitNodes(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveGetExitNodes)
|
||||
return
|
||||
case path == "/routes" && r.Method == httpm.POST:
|
||||
handleJSON[postRoutesRequest](s.servePostRoutes)(w, r)
|
||||
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
|
||||
if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
|
||||
return false
|
||||
} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
newHandler[postRoutesRequest](s, w, r, peerAllowed).
|
||||
handleJSON(s.servePostRoutes)
|
||||
return
|
||||
case path == "/device-details-click" && r.Method == httpm.POST:
|
||||
s.serveDeviceDetailsClick(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveDeviceDetailsClick)
|
||||
return
|
||||
case path == "/local/v0/logout" && r.Method == httpm.POST:
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
||||
return peer.canEdit(capFeatureAccount)
|
||||
}
|
||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
|
||||
handleJSON[maskedPrefs](s.serveUpdatePrefs)(w, r)
|
||||
peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
|
||||
if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
newHandler[maskedPrefs](s, w, r, peerAllowed).
|
||||
handleJSON(s.serveUpdatePrefs)
|
||||
return
|
||||
case path == "/local/v0/update/check" && r.Method == httpm.GET:
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/update/check" && r.Method == httpm.POST:
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
||||
return peer.canEdit(capFeatureAccount)
|
||||
}
|
||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
@@ -1051,11 +1122,6 @@ type maskedPrefs struct {
|
||||
}
|
||||
|
||||
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
|
||||
peer := s.getPeer(ctx)
|
||||
if prefs.RunSSHSet && !peer.canEdit(capFeatureSSH) {
|
||||
return tsweb.Error(http.StatusUnauthorized, "RunSSHSet not allowed", nil)
|
||||
}
|
||||
|
||||
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
RunSSHSet: prefs.RunSSHSet,
|
||||
Prefs: ipn.Prefs{
|
||||
@@ -1074,17 +1140,6 @@ type postRoutesRequest struct {
|
||||
}
|
||||
|
||||
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
|
||||
if !data.SetExitNode && !data.SetRoutes {
|
||||
return tsweb.Error(http.StatusBadRequest, "must specify SetExitNode or SetRoutes", nil)
|
||||
}
|
||||
peer := s.getPeer(ctx)
|
||||
if data.SetExitNode && !peer.canEdit(capFeatureExitNodes) {
|
||||
return tsweb.Error(http.StatusUnauthorized, "SetExitNode not allowed", nil)
|
||||
}
|
||||
if data.SetRoutes && !peer.canEdit(capFeatureSubnets) {
|
||||
return tsweb.Error(http.StatusUnauthorized, "SetRoutes not allowed", nil)
|
||||
}
|
||||
|
||||
prefs, err := s.lc.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1098,14 +1153,13 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
|
||||
}
|
||||
currNonExitRoutes = append(currNonExitRoutes, r.String())
|
||||
}
|
||||
// For each group of fields not being set, preserve the current prefs.
|
||||
if !data.SetExitNode {
|
||||
// Set non-edited fields to their current values.
|
||||
if data.SetExitNode {
|
||||
data.AdvertiseRoutes = currNonExitRoutes
|
||||
} else if data.SetRoutes {
|
||||
data.AdvertiseExitNode = currAdvertisingExitNode
|
||||
data.UseExitNode = prefs.ExitNodeID
|
||||
}
|
||||
if !data.SetRoutes {
|
||||
data.AdvertiseRoutes = currNonExitRoutes
|
||||
}
|
||||
|
||||
// Calculate routes.
|
||||
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
||||
@@ -1282,19 +1336,6 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
switch path {
|
||||
case "/v0/logout":
|
||||
if !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
|
||||
http.Error(w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
case "/v0/update/check":
|
||||
if r.Method == httpm.POST && !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
|
||||
http.Error(w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
|
||||
if err != nil {
|
||||
|
||||
+2
-148
@@ -191,7 +191,7 @@ func TestServeAPI(t *testing.T) {
|
||||
reqBody: "{\"setExitNode\":true}",
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "SetExitNode not allowed",
|
||||
wantResponse: "not allowed",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
@@ -204,7 +204,7 @@ func TestServeAPI(t *testing.T) {
|
||||
reqContentType: "application/json",
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "RunSSHSet not allowed",
|
||||
wantResponse: "not allowed",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
@@ -1604,149 +1604,3 @@ func TestCSRFProtect(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServePostRoutes(t *testing.T) {
|
||||
existingExitNodeID := tailcfg.StableNodeID("existing-exit-node")
|
||||
existingRoute := netip.MustParsePrefix("192.168.1.0/24")
|
||||
|
||||
existingPrefs := &ipn.Prefs{
|
||||
ExitNodeID: existingExitNodeID,
|
||||
AdvertiseRoutes: []netip.Prefix{existingRoute},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data postRoutesRequest
|
||||
peerCaps peerCapabilities
|
||||
wantErr bool
|
||||
wantEditPrefs bool // whether EditPrefs (PATCH /prefs) should be called
|
||||
wantExitNodeID tailcfg.StableNodeID
|
||||
wantRoutes []netip.Prefix
|
||||
}{
|
||||
{
|
||||
name: "empty-request",
|
||||
data: postRoutesRequest{},
|
||||
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||
wantErr: true,
|
||||
wantEditPrefs: false,
|
||||
},
|
||||
{
|
||||
name: "SetExitNode-only",
|
||||
data: postRoutesRequest{
|
||||
SetExitNode: true,
|
||||
UseExitNode: "new-exit-node",
|
||||
},
|
||||
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||
wantEditPrefs: true,
|
||||
wantExitNodeID: "new-exit-node",
|
||||
wantRoutes: []netip.Prefix{existingRoute},
|
||||
},
|
||||
{
|
||||
name: "SetExitNode-not-allowed",
|
||||
data: postRoutesRequest{
|
||||
SetExitNode: true,
|
||||
UseExitNode: "new-exit-node",
|
||||
},
|
||||
peerCaps: peerCapabilities{capFeatureSubnets: true},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "SetRoutes-only",
|
||||
data: postRoutesRequest{
|
||||
SetRoutes: true,
|
||||
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||
},
|
||||
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||
wantEditPrefs: true,
|
||||
wantExitNodeID: existingExitNodeID,
|
||||
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
},
|
||||
{
|
||||
name: "SetRoutes-not-allowed",
|
||||
data: postRoutesRequest{
|
||||
SetRoutes: true,
|
||||
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||
},
|
||||
peerCaps: peerCapabilities{capFeatureExitNodes: true},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "SetExitNode-and-SetRoutes",
|
||||
data: postRoutesRequest{
|
||||
SetExitNode: true,
|
||||
SetRoutes: true,
|
||||
UseExitNode: "new-exit-node",
|
||||
AdvertiseRoutes: []string{"10.0.0.0/8"},
|
||||
},
|
||||
peerCaps: peerCapabilities{capFeatureExitNodes: true, capFeatureSubnets: true},
|
||||
wantEditPrefs: true,
|
||||
wantExitNodeID: "new-exit-node",
|
||||
wantRoutes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotPrefs *ipn.MaskedPrefs
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
|
||||
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/localapi/v0/prefs" {
|
||||
t.Errorf("unexpected localapi call to %q", r.URL.Path)
|
||||
http.Error(w, "unexpected localapi call", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
writeJSON(w, existingPrefs)
|
||||
case httpm.PATCH:
|
||||
var mp ipn.MaskedPrefs
|
||||
if err := json.NewDecoder(r.Body).Decode(&mp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gotPrefs = &mp
|
||||
writeJSON(w, gotPrefs.Prefs)
|
||||
default:
|
||||
t.Errorf("unexpected method %q on /prefs", r.Method)
|
||||
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})}
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &local.Client{Dial: lal.Dial},
|
||||
}
|
||||
|
||||
ctx := contextKeyPeer.WithValue(t.Context(), tt.peerCaps)
|
||||
err := s.servePostRoutes(ctx, tt.data)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("wanted error, got nil")
|
||||
}
|
||||
if gotPrefs != nil {
|
||||
t.Error("EditPrefs should not have been called on error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if gotPrefs == nil {
|
||||
t.Fatal("expected EditPrefs to be called")
|
||||
}
|
||||
if diff := cmp.Diff(tt.wantExitNodeID, gotPrefs.ExitNodeID); diff != "" {
|
||||
t.Errorf("ExitNodeID mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff(tt.wantRoutes, gotPrefs.AdvertiseRoutes, cmp.Comparer(func(a, b netip.Prefix) bool { return a.Compare(b) == 0 })); diff != "" {
|
||||
t.Errorf("AdvertiseRoutes mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,12 @@ const (
|
||||
updaterPrefix = "tailscale-updater"
|
||||
)
|
||||
|
||||
func makeCmdTailscaleCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
srcExe, err := findCmdTailscale()
|
||||
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
f, err := os.Open(srcExe)
|
||||
f, err := os.Open(selfExe)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -59,25 +59,7 @@ func makeCmdTailscaleCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
f2.Close()
|
||||
return "", "", err
|
||||
}
|
||||
return srcExe, f2.Name(), f2.Close()
|
||||
}
|
||||
|
||||
// findCmdTailscale returns the path to the binary that should be copied for the update
|
||||
// re-execution. The copy is re-executed with "update" as a subcommand, so it must be
|
||||
// a binary that handles "update" (ie tailscale.exe, not tailscaled.exe)
|
||||
func findCmdTailscale() (string, error) {
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.EqualFold(filepath.Base(selfExe), "tailscale.exe") {
|
||||
return selfExe, nil
|
||||
}
|
||||
ts := filepath.Join(filepath.Dir(selfExe), "tailscale.exe")
|
||||
if _, err := os.Stat(ts); err != nil {
|
||||
return "", fmt.Errorf("cannot find tailscale.exe alongside %s: %w", selfExe, err)
|
||||
}
|
||||
return ts, nil
|
||||
return selfExe, f2.Name(), f2.Close()
|
||||
}
|
||||
|
||||
func markTempFileWindows(name string) error {
|
||||
@@ -177,14 +159,14 @@ you can run the command prompt as Administrator one of these ways:
|
||||
|
||||
up.Logf("making tailscale.exe copy to switch to...")
|
||||
up.cleanupOldDownloads(filepath.Join(os.TempDir(), updaterPrefix+"-*.exe"))
|
||||
_, cmdTailscaleCopy, err := makeCmdTailscaleCopy()
|
||||
_, selfCopy, err := makeSelfCopy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(cmdTailscaleCopy)
|
||||
defer os.Remove(selfCopy)
|
||||
up.Logf("running tailscale.exe copy for final install...")
|
||||
|
||||
cmd := exec.Command(cmdTailscaleCopy, "update")
|
||||
cmd := exec.Command(selfCopy, "update")
|
||||
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winVersionEnv+"="+ver)
|
||||
cmd.Stdout = up.Stderr
|
||||
cmd.Stderr = up.Stderr
|
||||
|
||||
+19
-45
@@ -143,9 +143,25 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("if src.%s != nil {", fname)
|
||||
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
|
||||
writef("for i := range dst.%s {", fname)
|
||||
writeSliceElemClone(writef, ft.Elem(),
|
||||
fmt.Sprintf("src.%s[i]", fname),
|
||||
fmt.Sprintf("dst.%s[i]", fname))
|
||||
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
|
||||
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname)
|
||||
if codegen.ContainsPointers(ptr.Elem()) {
|
||||
if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface {
|
||||
writef("\tdst.%s[i] = new((*src.%s[i]).Clone())", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
} else {
|
||||
writef("\tdst.%s[i] = new(*src.%s[i])", fname, fname)
|
||||
}
|
||||
writef("}")
|
||||
} else if ft.Elem().String() == "encoding/json.RawMessage" {
|
||||
writef("\tdst.%s[i] = append(src.%s[i][:0:0], src.%s[i]...)", fname, fname, fname)
|
||||
} else if _, isIface := ft.Elem().Underlying().(*types.Interface); isIface {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
writef("}")
|
||||
writef("}")
|
||||
} else {
|
||||
@@ -173,28 +189,11 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
n := it.QualifiedName(sliceType.Elem())
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
|
||||
if codegen.ContainsPointers(sliceType.Elem()) {
|
||||
writef("\tfor k, sv := range src.%s {", fname)
|
||||
writef("\t\tif sv == nil {")
|
||||
writef("\t\t\tdst.%s[k] = nil", fname)
|
||||
writef("\t\t\tcontinue")
|
||||
writef("\t\t}")
|
||||
writef("\t\tdst.%s[k] = make([]%s, len(sv))", fname, n)
|
||||
writef("\t\tfor i := range sv {")
|
||||
innerWritef := func(format string, args ...any) {
|
||||
writef("\t\t"+format, args...)
|
||||
}
|
||||
writeSliceElemClone(innerWritef, sliceType.Elem(),
|
||||
"sv[i]", fmt.Sprintf("dst.%s[k][i]", fname))
|
||||
writef("\t\t}")
|
||||
writef("\t}")
|
||||
} else {
|
||||
writef("\tfor k := range src.%s {", fname)
|
||||
// use zero-length slice instead of nil to ensure
|
||||
// the key is always copied.
|
||||
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
|
||||
writef("\t}")
|
||||
}
|
||||
writef("}")
|
||||
} else if codegen.IsViewType(elem) || !codegen.ContainsPointers(elem) {
|
||||
// If the map values are view types (which are
|
||||
@@ -243,31 +242,6 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
buf.Write(codegen.AssertStructUnchanged(t, name, typeParams, "Clone", it))
|
||||
}
|
||||
|
||||
// writeSliceElemClone generates code to deep-clone a single slice element
|
||||
// from srcExpr to dstExpr. It handles pointer, json.RawMessage, interface,
|
||||
// and named struct element types.
|
||||
func writeSliceElemClone(writef func(string, ...any), elemType types.Type, srcExpr, dstExpr string) {
|
||||
if ptr, isPtr := elemType.(*types.Pointer); isPtr {
|
||||
writef("if %s == nil { %s = nil } else {", srcExpr, dstExpr)
|
||||
if codegen.ContainsPointers(ptr.Elem()) {
|
||||
if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface {
|
||||
writef("\t%s = new((*%s).Clone())", dstExpr, srcExpr)
|
||||
} else {
|
||||
writef("\t%s = %s.Clone()", dstExpr, srcExpr)
|
||||
}
|
||||
} else {
|
||||
writef("\t%s = new(*%s)", dstExpr, srcExpr)
|
||||
}
|
||||
writef("}")
|
||||
} else if elemType.String() == "encoding/json.RawMessage" {
|
||||
writef("%s = append(%s[:0:0], %s...)", dstExpr, srcExpr, srcExpr)
|
||||
} else if _, isIface := elemType.Underlying().(*types.Interface); isIface {
|
||||
writef("%s = %s.Clone()", dstExpr, srcExpr)
|
||||
} else {
|
||||
writef("%s = *%s.Clone()", dstExpr, srcExpr)
|
||||
}
|
||||
}
|
||||
|
||||
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
|
||||
func hasBasicUnderlying(typ types.Type) bool {
|
||||
switch typ.Underlying().(type) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/cmd/cloner/clonerex"
|
||||
)
|
||||
|
||||
@@ -183,46 +182,6 @@ func TestNamedMapContainer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapSlicePointerContainer(t *testing.T) {
|
||||
num := 42
|
||||
orig := &clonerex.MapSlicePointerContainer{
|
||||
Routes: map[string][]*clonerex.SliceContainer{
|
||||
"route1": {
|
||||
{Slice: []*int{&num}},
|
||||
{Slice: []*int{&num, &num}},
|
||||
},
|
||||
"route2": {
|
||||
{Slice: []*int{&num}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cloned := orig.Clone()
|
||||
if !reflect.DeepEqual(orig, cloned) {
|
||||
t.Errorf("Clone() = %v, want %v", cloned, orig)
|
||||
}
|
||||
|
||||
// Mutate cloned.Routes pointer values
|
||||
*cloned.Routes["route1"][0].Slice[0] = 999
|
||||
if *orig.Routes["route1"][0].Slice[0] == 999 {
|
||||
t.Errorf("Clone() aliased memory in Routes: original was modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapSlicePointerContainerNilValue(t *testing.T) {
|
||||
num := 7
|
||||
orig := &clonerex.MapSlicePointerContainer{
|
||||
Routes: map[string][]*clonerex.SliceContainer{
|
||||
"nil-value": nil,
|
||||
"non-nil": {{Slice: []*int{&num}}},
|
||||
},
|
||||
}
|
||||
cloned := orig.Clone()
|
||||
if diff := cmp.Diff(orig.Routes, cloned.Routes); diff != "" {
|
||||
t.Errorf("Clone() Routes mismatch (-orig +cloned):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeeplyNestedMap(t *testing.T) {
|
||||
num := 123
|
||||
orig := &clonerex.DeeplyNestedMap{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer
|
||||
|
||||
// Package clonerex is an example package for the cloner tool.
|
||||
package clonerex
|
||||
@@ -60,13 +60,6 @@ type NamedMapContainer struct {
|
||||
Attrs NamedMap
|
||||
}
|
||||
|
||||
// MapSlicePointerContainer has a map whose values are slices of pointers.
|
||||
// This tests that the cloner deep-clones the pointer elements in the slice,
|
||||
// not just the slice itself (which would leave aliased pointers).
|
||||
type MapSlicePointerContainer struct {
|
||||
Routes map[string][]*SliceContainer
|
||||
}
|
||||
|
||||
// DeeplyNestedMap tests arbitrary depth of map nesting (3+ levels)
|
||||
type DeeplyNestedMap struct {
|
||||
ThreeLevels map[string]map[string]map[string]int
|
||||
|
||||
@@ -176,42 +176,9 @@ var _NamedMapContainerCloneNeedsRegeneration = NamedMapContainer(struct {
|
||||
Attrs NamedMap
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of MapSlicePointerContainer.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *MapSlicePointerContainer) Clone() *MapSlicePointerContainer {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(MapSlicePointerContainer)
|
||||
*dst = *src
|
||||
if dst.Routes != nil {
|
||||
dst.Routes = map[string][]*SliceContainer{}
|
||||
for k, sv := range src.Routes {
|
||||
if sv == nil {
|
||||
dst.Routes[k] = nil
|
||||
continue
|
||||
}
|
||||
dst.Routes[k] = make([]*SliceContainer, len(sv))
|
||||
for i := range sv {
|
||||
if sv[i] == nil {
|
||||
dst.Routes[k][i] = nil
|
||||
} else {
|
||||
dst.Routes[k][i] = sv[i].Clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _MapSlicePointerContainerCloneNeedsRegeneration = MapSlicePointerContainer(struct {
|
||||
Routes map[string][]*SliceContainer
|
||||
}{})
|
||||
|
||||
// Clone duplicates src into dst and reports whether it succeeded.
|
||||
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
|
||||
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer.
|
||||
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer.
|
||||
func Clone(dst, src any) bool {
|
||||
switch src := src.(type) {
|
||||
case *SliceContainer:
|
||||
@@ -259,15 +226,6 @@ func Clone(dst, src any) bool {
|
||||
*dst = src.Clone()
|
||||
return true
|
||||
}
|
||||
case *MapSlicePointerContainer:
|
||||
switch dst := dst.(type) {
|
||||
case *MapSlicePointerContainer:
|
||||
*dst = *src.Clone()
|
||||
return true
|
||||
case **MapSlicePointerContainer:
|
||||
*dst = src.Clone()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -22,12 +22,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/linuxfw"
|
||||
"tailscale.com/util/mak"
|
||||
@@ -55,7 +54,7 @@ type egressProxy struct {
|
||||
|
||||
tsClient *local.Client // never nil
|
||||
|
||||
netmapChan chan *netmap.NetworkMap // chan to receive netmap updates on
|
||||
netmapChan chan ipn.Notify // chan to receive netmap updates on
|
||||
|
||||
podIPv4 string // never empty string, currently only IPv4 is supported
|
||||
|
||||
@@ -87,7 +86,7 @@ type httpClient interface {
|
||||
// - the mounted egress config has changed
|
||||
// - the proxy's tailnet IP addresses have changed
|
||||
// - tailnet IPs have changed for any backend targets specified by tailnet FQDN
|
||||
func (ep *egressProxy) run(ctx context.Context, nm *netmap.NetworkMap, opts egressProxyRunOpts) error {
|
||||
func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRunOpts) error {
|
||||
ep.configure(opts)
|
||||
var tickChan <-chan time.Time
|
||||
var eventChan <-chan fsnotify.Event
|
||||
@@ -106,7 +105,7 @@ func (ep *egressProxy) run(ctx context.Context, nm *netmap.NetworkMap, opts egre
|
||||
eventChan = w.Events
|
||||
}
|
||||
|
||||
if err := ep.sync(ctx, nm); err != nil {
|
||||
if err := ep.sync(ctx, n); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
@@ -117,14 +116,14 @@ func (ep *egressProxy) run(ctx context.Context, nm *netmap.NetworkMap, opts egre
|
||||
log.Printf("periodic sync, ensuring firewall config is up to date...")
|
||||
case <-eventChan:
|
||||
log.Printf("config file change detected, ensuring firewall config is up to date...")
|
||||
case nm = <-ep.netmapChan:
|
||||
shouldResync := ep.shouldResync(nm)
|
||||
case n = <-ep.netmapChan:
|
||||
shouldResync := ep.shouldResync(n)
|
||||
if !shouldResync {
|
||||
continue
|
||||
}
|
||||
log.Printf("netmap change detected, ensuring firewall config is up to date...")
|
||||
}
|
||||
if err := ep.sync(ctx, nm); err != nil {
|
||||
if err := ep.sync(ctx, n); err != nil {
|
||||
return fmt.Errorf("error syncing egress service config: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -136,7 +135,7 @@ type egressProxyRunOpts struct {
|
||||
kc kubeclient.Client
|
||||
tsClient *local.Client
|
||||
stateSecret string
|
||||
netmapChan chan *netmap.NetworkMap
|
||||
netmapChan chan ipn.Notify
|
||||
podIPv4 string
|
||||
tailnetAddrs []netip.Prefix
|
||||
}
|
||||
@@ -165,7 +164,7 @@ func (ep *egressProxy) configure(opts egressProxyRunOpts) {
|
||||
// any firewall rules need to be updated. Currently using status in state Secret as a reference for what is the current
|
||||
// firewall configuration is good enough because - the status is keyed by the Pod IP - we crash the Pod on errors such
|
||||
// as failed firewall update
|
||||
func (ep *egressProxy) sync(ctx context.Context, nm *netmap.NetworkMap) error {
|
||||
func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
|
||||
cfgs, err := ep.getConfigs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving egress service configs: %w", err)
|
||||
@@ -174,12 +173,12 @@ func (ep *egressProxy) sync(ctx context.Context, nm *netmap.NetworkMap) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving current egress proxy status: %w", err)
|
||||
}
|
||||
newStatus, err := ep.syncEgressConfigs(cfgs, status, nm)
|
||||
newStatus, err := ep.syncEgressConfigs(cfgs, status, n)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error syncing egress service configs: %w", err)
|
||||
}
|
||||
if !servicesStatusIsEqual(newStatus, status) {
|
||||
if err := ep.setStatus(ctx, newStatus, nm); err != nil {
|
||||
if err := ep.setStatus(ctx, newStatus, n); err != nil {
|
||||
return fmt.Errorf("error setting egress proxy status: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -188,14 +187,14 @@ func (ep *egressProxy) sync(ctx context.Context, nm *netmap.NetworkMap) error {
|
||||
|
||||
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
|
||||
// Netmap must not be nil.
|
||||
func (ep *egressProxy) addrsHaveChanged(nm *netmap.NetworkMap) bool {
|
||||
return !reflect.DeepEqual(ep.tailnetAddrs, nm.SelfNode.Addresses())
|
||||
func (ep *egressProxy) addrsHaveChanged(n ipn.Notify) bool {
|
||||
return !reflect.DeepEqual(ep.tailnetAddrs, n.NetMap.SelfNode.Addresses())
|
||||
}
|
||||
|
||||
// syncEgressConfigs adds and deletes firewall rules to match the desired
|
||||
// configuration. It uses the provided status to determine what is currently
|
||||
// applied and updates the status after a successful sync.
|
||||
func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *egressservices.Status, nm *netmap.NetworkMap) (*egressservices.Status, error) {
|
||||
func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *egressservices.Status, n ipn.Notify) (*egressservices.Status, error) {
|
||||
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -213,8 +212,8 @@ func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *eg
|
||||
// Add new services, update rules for any that have changed.
|
||||
rulesPerSvcToAdd := make(map[string][]rule, 0)
|
||||
rulesPerSvcToDelete := make(map[string][]rule, 0)
|
||||
for svcName, cfg := range cfgs {
|
||||
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, nm)
|
||||
for svcName, cfg := range *cfgs {
|
||||
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error determining tailnet target IPs: %w", err)
|
||||
}
|
||||
@@ -229,12 +228,12 @@ func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *eg
|
||||
if len(rulesToDelete) != 0 {
|
||||
mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete)
|
||||
}
|
||||
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(nm) {
|
||||
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(n) {
|
||||
// For each tailnet target, set up SNAT from the local tailnet device address of the matching
|
||||
// family.
|
||||
for _, t := range tailnetTargetIPs {
|
||||
var local netip.Addr
|
||||
for _, pfx := range nm.SelfNode.Addresses().All() {
|
||||
for _, pfx := range n.NetMap.SelfNode.Addresses().All() {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
@@ -353,7 +352,7 @@ func updatesForCfg(svcName string, cfg egressservices.Config, status *egressserv
|
||||
|
||||
// deleteUnneccessaryServices ensure that any services found on status, but not
|
||||
// present in config are deleted.
|
||||
func (ep *egressProxy) deleteUnnecessaryServices(cfgs egressservices.Configs, status *egressservices.Status) error {
|
||||
func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, status *egressservices.Status) error {
|
||||
if !hasServicesConfigured(status) {
|
||||
return nil
|
||||
}
|
||||
@@ -368,7 +367,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs egressservices.Configs, st
|
||||
}
|
||||
|
||||
for svcName, svc := range status.Services {
|
||||
if _, ok := cfgs[svcName]; !ok {
|
||||
if _, ok := (*cfgs)[svcName]; !ok {
|
||||
log.Printf("service %s is no longer required, deleting", svcName)
|
||||
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
|
||||
return fmt.Errorf("error deleting service %s: %w", svcName, err)
|
||||
@@ -380,7 +379,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs egressservices.Configs, st
|
||||
}
|
||||
|
||||
// getConfigs gets the mounted egress service configuration.
|
||||
func (ep *egressProxy) getConfigs() (egressservices.Configs, error) {
|
||||
func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) {
|
||||
svcsCfg := filepath.Join(ep.cfgPath, egressservices.KeyEgressServices)
|
||||
j, err := os.ReadFile(svcsCfg)
|
||||
if os.IsNotExist(err) {
|
||||
@@ -392,7 +391,7 @@ func (ep *egressProxy) getConfigs() (egressservices.Configs, error) {
|
||||
if len(j) == 0 || string(j) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cfg := egressservices.Configs{}
|
||||
cfg := &egressservices.Configs{}
|
||||
if err := json.Unmarshal(j, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -424,7 +423,7 @@ func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, e
|
||||
|
||||
// setStatus writes egress proxy's currently configured firewall to the state
|
||||
// Secret and updates proxy's tailnet addresses.
|
||||
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, nm *netmap.NetworkMap) error {
|
||||
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, n ipn.Notify) error {
|
||||
// Pod IP is used to determine if a stored status applies to THIS proxy Pod.
|
||||
if status == nil {
|
||||
status = &egressservices.Status{}
|
||||
@@ -447,7 +446,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
|
||||
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
|
||||
return fmt.Errorf("error patching state Secret: %w", err)
|
||||
}
|
||||
ep.tailnetAddrs = nm.SelfNode.Addresses().AsSlice()
|
||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -457,7 +456,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
|
||||
// FQDN, resolve the FQDN and return the resolved IPs. It checks if the
|
||||
// netfilter runner supports IPv6 NAT and skips any IPv6 addresses if it
|
||||
// doesn't.
|
||||
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm *netmap.NetworkMap) (addrs []netip.Addr, err error) {
|
||||
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.Notify) (addrs []netip.Addr, err error) {
|
||||
if svc.TailnetTarget.IP != "" {
|
||||
addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
|
||||
if err != nil {
|
||||
@@ -473,11 +472,11 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm *net
|
||||
if svc.TailnetTarget.FQDN == "" {
|
||||
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
|
||||
}
|
||||
if nm == nil {
|
||||
if n.NetMap == nil {
|
||||
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
|
||||
return addrs, nil
|
||||
}
|
||||
egressAddrs, err := resolveTailnetFQDN(nm, svc.TailnetTarget.FQDN)
|
||||
egressAddrs, err := resolveTailnetFQDN(n.NetMap, svc.TailnetTarget.FQDN)
|
||||
if err != nil {
|
||||
log.Printf("error fetching backend addresses for %q: %v", svc.TailnetTarget.FQDN, err)
|
||||
return addrs, nil
|
||||
@@ -503,22 +502,22 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm *net
|
||||
|
||||
// shouldResync parses netmap update and returns true if the update contains
|
||||
// changes for which the egress proxy's firewall should be reconfigured.
|
||||
func (ep *egressProxy) shouldResync(nm *netmap.NetworkMap) bool {
|
||||
if nm == nil {
|
||||
func (ep *egressProxy) shouldResync(n ipn.Notify) bool {
|
||||
if n.NetMap == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If proxy's tailnet addresses have changed, resync.
|
||||
if !reflect.DeepEqual(nm.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
|
||||
if !reflect.DeepEqual(n.NetMap.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
|
||||
log.Printf("node addresses have changed, trigger egress config resync")
|
||||
ep.tailnetAddrs = nm.SelfNode.Addresses().AsSlice()
|
||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
return true
|
||||
}
|
||||
|
||||
// If the IPs for any of the egress services configured via FQDN have
|
||||
// changed, resync.
|
||||
for fqdn, ips := range ep.targetFQDNs {
|
||||
for _, nn := range nm.Peers {
|
||||
for _, nn := range n.NetMap.Peers {
|
||||
if equalFQDNs(nn.Name(), fqdn) {
|
||||
if !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())
|
||||
@@ -603,8 +602,8 @@ type rule struct {
|
||||
protocol string
|
||||
}
|
||||
|
||||
func wantsServicesConfigured(cfgs egressservices.Configs) bool {
|
||||
return cfgs != nil && len(cfgs) != 0
|
||||
func wantsServicesConfigured(cfgs *egressservices.Configs) bool {
|
||||
return cfgs != nil && len(*cfgs) != 0
|
||||
}
|
||||
|
||||
func hasServicesConfigured(status *egressservices.Status) bool {
|
||||
@@ -658,13 +657,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
|
||||
// backends and eventually kube proxy routing rules should be updated to no longer route traffic for the Service to this
|
||||
// Pod.
|
||||
func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs egressservices.Configs, hp int) {
|
||||
if cfgs == nil || len(cfgs) == 0 { // avoid sleeping if no services are configured
|
||||
func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs *egressservices.Configs, hp int) {
|
||||
if cfgs == nil || len(*cfgs) == 0 { // avoid sleeping if no services are configured
|
||||
return
|
||||
}
|
||||
log.Printf("Ensuring that cluster traffic for egress targets is no longer routed via this Pod...")
|
||||
var wg sync.WaitGroup
|
||||
for s, cfg := range cfgs {
|
||||
for s, cfg := range *cfgs {
|
||||
hep := cfg.HealthCheckEndpoint
|
||||
if hep == "" {
|
||||
log.Printf("Tailnet target %q does not have a cluster healthcheck specified, unable to verify if cluster traffic for the target is still routed via this Pod", s)
|
||||
|
||||
@@ -255,13 +255,13 @@ func TestWaitTillSafeToShutdown(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfgs := egressservices.Configs{}
|
||||
cfgs := &egressservices.Configs{}
|
||||
switches := make(map[string]int)
|
||||
|
||||
for svc, callsToSwitch := range tt.services {
|
||||
endpoint := fmt.Sprintf("http://%s.local", svc)
|
||||
if tt.healthCheckSet {
|
||||
cfgs[svc] = egressservices.Config{
|
||||
(*cfgs)[svc] = egressservices.Config{
|
||||
HealthCheckEndpoint: endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
+83
-26
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/authkey"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/kube/ingressservices"
|
||||
"tailscale.com/kube/kubeapi"
|
||||
@@ -33,6 +32,7 @@ import (
|
||||
)
|
||||
|
||||
const fieldManager = "tailscale-container"
|
||||
const kubeletMountedConfigLn = "..data"
|
||||
|
||||
// kubeClient is a wrapper around Tailscale's internal kube client that knows how to talk to the kube API server. We use
|
||||
// this rather than any of the upstream Kubernetes client libaries to avoid extra imports.
|
||||
@@ -127,9 +127,6 @@ func (kc *kubeClient) deleteAuthKey(ctx context.Context) error {
|
||||
|
||||
// resetContainerbootState resets state from previous runs of containerboot to
|
||||
// ensure the operator doesn't use stale state when a Pod is first recreated.
|
||||
//
|
||||
// Device identity keys (device_id, device_fqdn, device_ips) are preserved so
|
||||
// the operator can clean up the old device from the control plane.
|
||||
func (kc *kubeClient) resetContainerbootState(ctx context.Context, podUID string, tailscaledConfigAuthkey string) error {
|
||||
existingSecret, err := kc.GetSecret(ctx, kc.stateSecret)
|
||||
switch {
|
||||
@@ -143,6 +140,11 @@ func (kc *kubeClient) resetContainerbootState(ctx context.Context, podUID string
|
||||
s := &kubeapi.Secret{
|
||||
Data: map[string][]byte{
|
||||
kubetypes.KeyCapVer: fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion),
|
||||
|
||||
// TODO(tomhjp): Perhaps shouldn't clear device ID and use a different signal, as this could leak tailnet devices.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -167,18 +169,47 @@ func (kc *kubeClient) setAndWaitForAuthKeyReissue(ctx context.Context, client *l
|
||||
return fmt.Errorf("error disconnecting from control: %w", err)
|
||||
}
|
||||
|
||||
err = authkey.SetReissueAuthKey(ctx, kc.Client, kc.stateSecret, tailscaledConfigAuthKey, authkey.TailscaleContainerFieldManager)
|
||||
err = kc.setReissueAuthKey(ctx, tailscaledConfigAuthKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set reissue_authkey in Kubernetes Secret: %w", err)
|
||||
}
|
||||
|
||||
clearFn := func(ctx context.Context) error {
|
||||
return authkey.ClearReissueAuthKey(ctx, kc.Client, kc.stateSecret, authkey.TailscaleContainerFieldManager)
|
||||
err = kc.waitForAuthKeyReissue(ctx, cfg.TailscaledConfigFilePath, tailscaledConfigAuthKey, 10*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to receive new auth key: %w", err)
|
||||
}
|
||||
|
||||
getAuthKey := func() string { return authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath) }
|
||||
tailscaledCfgDir := filepath.Dir(cfg.TailscaledConfigFilePath)
|
||||
var notify <-chan struct{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (kc *kubeClient) setReissueAuthKey(ctx context.Context, authKey string) error {
|
||||
s := &kubeapi.Secret{
|
||||
Data: map[string][]byte{
|
||||
kubetypes.KeyReissueAuthkey: []byte(authKey),
|
||||
},
|
||||
}
|
||||
|
||||
log.Printf("Requesting a new auth key from operator")
|
||||
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, fieldManager)
|
||||
}
|
||||
|
||||
func (kc *kubeClient) waitForAuthKeyReissue(ctx context.Context, configPath string, oldAuthKey string, maxWait time.Duration) error {
|
||||
log.Printf("Waiting for operator to provide new auth key (max wait: %v)", maxWait)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, maxWait)
|
||||
defer cancel()
|
||||
|
||||
tailscaledCfgDir := filepath.Dir(configPath)
|
||||
toWatch := filepath.Join(tailscaledCfgDir, kubeletMountedConfigLn)
|
||||
|
||||
var (
|
||||
pollTicker <-chan time.Time
|
||||
eventChan <-chan fsnotify.Event
|
||||
)
|
||||
|
||||
pollInterval := 5 * time.Second
|
||||
|
||||
// Try to use fsnotify for faster notification
|
||||
if w, err := fsnotify.NewWatcher(); err != nil {
|
||||
log.Printf("auth key reissue: fsnotify unavailable, using polling: %v", err)
|
||||
} else if err := w.Add(tailscaledCfgDir); err != nil {
|
||||
@@ -186,30 +217,56 @@ func (kc *kubeClient) setAndWaitForAuthKeyReissue(ctx context.Context, client *l
|
||||
log.Printf("auth key reissue: fsnotify watch failed, using polling: %v", err)
|
||||
} else {
|
||||
defer w.Close()
|
||||
ch := make(chan struct{}, 1)
|
||||
toWatch := filepath.Join(tailscaledCfgDir, "..data")
|
||||
go func() {
|
||||
for ev := range w.Events {
|
||||
if ev.Name == toWatch {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
notify = ch
|
||||
log.Printf("auth key reissue: watching for config changes via fsnotify")
|
||||
eventChan = w.Events
|
||||
}
|
||||
|
||||
err = authkey.WaitForAuthKeyReissue(ctx, tailscaledConfigAuthKey, 10*time.Minute, getAuthKey, clearFn, notify)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to receive new auth key: %w", err)
|
||||
// still keep polling if using fsnotify, for logging and in case fsnotify fails
|
||||
pt := time.NewTicker(pollInterval)
|
||||
defer pt.Stop()
|
||||
pollTicker = pt.C
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timeout waiting for auth key reissue after %v", maxWait)
|
||||
case <-pollTicker: // Waits for polling tick, continues when received
|
||||
case event := <-eventChan:
|
||||
if event.Name != toWatch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
newAuthKey := authkeyFromTailscaledConfig(configPath)
|
||||
if newAuthKey != "" && newAuthKey != oldAuthKey {
|
||||
log.Printf("New auth key received from operator after %v", time.Since(start).Round(time.Second))
|
||||
|
||||
if err := kc.clearReissueAuthKeyRequest(ctx); err != nil {
|
||||
log.Printf("Warning: failed to clear reissue request: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if eventChan == nil && pollTicker != nil {
|
||||
log.Printf("Waiting for new auth key from operator (%v elapsed)", time.Since(start).Round(time.Second))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearReissueAuthKeyRequest removes the reissue_authkey marker from the Secret
|
||||
// to signal to the operator that we've successfully received the new key.
|
||||
func (kc *kubeClient) clearReissueAuthKeyRequest(ctx context.Context) error {
|
||||
s := &kubeapi.Secret{
|
||||
Data: map[string][]byte{
|
||||
kubetypes.KeyReissueAuthkey: nil,
|
||||
},
|
||||
}
|
||||
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, fieldManager)
|
||||
}
|
||||
|
||||
// waitForConsistentState waits for tailscaled to finish writing state if it
|
||||
// looks like it's started. It is designed to reduce the likelihood that
|
||||
// tailscaled gets shut down in the window between authenticating to control
|
||||
|
||||
@@ -259,6 +259,10 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
expected: map[string][]byte{
|
||||
kubetypes.KeyCapVer: capver,
|
||||
kubetypes.KeyPodUID: []byte("1234"),
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -268,6 +272,10 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
initial: map[string][]byte{},
|
||||
expected: map[string][]byte{
|
||||
kubetypes.KeyCapVer: capver,
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -295,6 +303,9 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
kubetypes.KeyCapVer: capver,
|
||||
kubetypes.KeyPodUID: []byte("1234"),
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -310,6 +321,9 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
kubetypes.KeyCapVer: capver,
|
||||
kubetypes.KeyReissueAuthkey: nil,
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -324,6 +338,9 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
kubetypes.KeyCapVer: capver,
|
||||
// reissue_authkey not cleared.
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
@@ -338,6 +355,9 @@ func TestResetContainerbootState(t *testing.T) {
|
||||
kubetypes.KeyCapVer: capver,
|
||||
// reissue_authkey not cleared.
|
||||
// Cleared keys.
|
||||
kubetypes.KeyDeviceID: nil,
|
||||
kubetypes.KeyDeviceFQDN: nil,
|
||||
kubetypes.KeyDeviceIPs: nil,
|
||||
kubetypes.KeyHTTPSEndpoint: nil,
|
||||
egressservices.KeyEgressServices: nil,
|
||||
ingressservices.IngressConfigKey: nil,
|
||||
|
||||
+47
-70
@@ -137,11 +137,10 @@ import (
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/conffile"
|
||||
kubeutils "tailscale.com/k8s-operator"
|
||||
"tailscale.com/kube/authkey"
|
||||
healthz "tailscale.com/kube/health"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
klc "tailscale.com/kube/localclient"
|
||||
@@ -210,7 +209,7 @@ func run() error {
|
||||
|
||||
var tailscaledConfigAuthkey string
|
||||
if isOneStepConfig(cfg) {
|
||||
tailscaledConfigAuthkey = authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath)
|
||||
tailscaledConfigAuthkey = authkeyFromTailscaledConfig(cfg.TailscaledConfigFilePath)
|
||||
}
|
||||
|
||||
var kc *kubeClient
|
||||
@@ -375,7 +374,7 @@ authLoop:
|
||||
if hasKubeStateStore(cfg) {
|
||||
log.Printf("Auth key missing or invalid (NeedsLogin state), disconnecting from control and requesting new key from operator")
|
||||
|
||||
err := kc.setAndWaitForAuthKeyReissue(ctx, client, cfg, tailscaledConfigAuthkey)
|
||||
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get a reissued authkey: %w", err)
|
||||
}
|
||||
@@ -415,7 +414,7 @@ authLoop:
|
||||
if isOneStepConfig(cfg) && hasKubeStateStore(cfg) {
|
||||
log.Printf("Auth key failed to authenticate (may be expired or single-use), disconnecting from control and requesting new key from operator")
|
||||
|
||||
err := kc.setAndWaitForAuthKeyReissue(ctx, client, cfg, tailscaledConfigAuthkey)
|
||||
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get a reissued authkey: %w", err)
|
||||
}
|
||||
@@ -537,7 +536,7 @@ authLoop:
|
||||
failedResolveAttempts++
|
||||
}
|
||||
|
||||
var egressSvcsNotify chan *netmap.NetworkMap
|
||||
var egressSvcsNotify chan ipn.Notify
|
||||
notifyChan := make(chan ipn.Notify)
|
||||
errChan := make(chan error)
|
||||
go func() {
|
||||
@@ -551,17 +550,10 @@ 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
|
||||
|
||||
runLoop:
|
||||
for {
|
||||
var processNetmap bool
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Although killTailscaled() is deferred earlier, if we
|
||||
@@ -574,8 +566,6 @@ runLoop:
|
||||
return fmt.Errorf("failed to read from tailscaled: %w", err)
|
||||
case err := <-cfgWatchErrChan:
|
||||
return fmt.Errorf("failed to watch tailscaled config: %w", err)
|
||||
case <-peerPoll.C:
|
||||
processNetmap = true
|
||||
case n := <-notifyChan:
|
||||
// TODO: (ChaosInTheCRD) Add node removed check when supported by ipn
|
||||
if n.State != nil && *n.State != ipn.Running {
|
||||
@@ -586,43 +576,8 @@ runLoop:
|
||||
// whereupon we'll go through initial auth again.
|
||||
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", *n.State)
|
||||
}
|
||||
if n.SelfChange != nil {
|
||||
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()
|
||||
if n.NetMap != nil {
|
||||
addrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
newCurrentIPs := deephash.Hash(&addrs)
|
||||
ipsHaveChanged := newCurrentIPs != currentIPs
|
||||
|
||||
@@ -634,14 +589,14 @@ runLoop:
|
||||
// Kubernetes Secret to clean up tailnet nodes
|
||||
// for proxies whose route setup continuously
|
||||
// fails.
|
||||
deviceID := nm.SelfNode.StableID()
|
||||
deviceID := n.NetMap.SelfNode.StableID()
|
||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceID, &deviceID) {
|
||||
if err := kc.storeDeviceID(ctx, nm.SelfNode.StableID()); err != nil {
|
||||
if err := kc.storeDeviceID(ctx, n.NetMap.SelfNode.StableID()); err != nil {
|
||||
return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err)
|
||||
}
|
||||
}
|
||||
if cfg.TailnetTargetFQDN != "" {
|
||||
egressAddrs, err := resolveTailnetFQDN(nm, cfg.TailnetTargetFQDN)
|
||||
egressAddrs, err := resolveTailnetFQDN(n.NetMap, cfg.TailnetTargetFQDN)
|
||||
if err != nil {
|
||||
log.Print(err.Error())
|
||||
break
|
||||
@@ -697,7 +652,7 @@ runLoop:
|
||||
backendAddrs = newBackendAddrs
|
||||
}
|
||||
if cfg.ServeConfigPath != "" {
|
||||
cd := certDomainFromNetmap(nm)
|
||||
cd := certDomainFromNetmap(n.NetMap)
|
||||
if cd == "" {
|
||||
cd = kubetypes.ValueNoHTTPS
|
||||
}
|
||||
@@ -740,9 +695,9 @@ runLoop:
|
||||
// set up ensures that the operator does not
|
||||
// advertize endpoints of broken proxies.
|
||||
// TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
|
||||
deviceEndpoints := []any{nm.SelfNode.Name(), nm.SelfNode.Addresses()}
|
||||
deviceEndpoints := []any{n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses()}
|
||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceEndpoints, &deviceEndpoints) {
|
||||
if err := kc.storeDeviceEndpoints(ctx, nm.SelfNode.Name(), nm.SelfNode.Addresses().AsSlice()); err != nil {
|
||||
if err := kc.storeDeviceEndpoints(ctx, n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
|
||||
return fmt.Errorf("storing device IPs and FQDN in Kubernetes Secret: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -771,7 +726,7 @@ runLoop:
|
||||
}
|
||||
|
||||
if egressSvcsNotify != nil {
|
||||
egressSvcsNotify <- nm
|
||||
egressSvcsNotify <- n
|
||||
}
|
||||
}
|
||||
if !startupTasksDone {
|
||||
@@ -793,7 +748,7 @@ runLoop:
|
||||
// will crash this node.
|
||||
if cfg.EgressProxiesCfgPath != "" {
|
||||
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath)
|
||||
egressSvcsNotify = make(chan *netmap.NetworkMap)
|
||||
egressSvcsNotify = make(chan ipn.Notify)
|
||||
opts := egressProxyRunOpts{
|
||||
cfgPath: cfg.EgressProxiesCfgPath,
|
||||
nfr: nfr,
|
||||
@@ -805,7 +760,7 @@ runLoop:
|
||||
tailnetAddrs: addrs,
|
||||
}
|
||||
go func() {
|
||||
if err := ep.run(ctx, nm, opts); err != nil {
|
||||
if err := ep.run(ctx, n, opts); err != nil {
|
||||
egressSvcsErrorChan <- err
|
||||
}
|
||||
}()
|
||||
@@ -851,6 +806,29 @@ runLoop:
|
||||
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()
|
||||
|
||||
@@ -985,15 +963,6 @@ 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
|
||||
// can be either a peer device or a Tailscale Service.
|
||||
func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, error) {
|
||||
@@ -1055,3 +1024,11 @@ func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Pref
|
||||
|
||||
return prefixes
|
||||
}
|
||||
|
||||
func authkeyFromTailscaledConfig(path string) string {
|
||||
if cfg, err := conffile.Load(path); err == nil && cfg.Parsed.AuthKey != nil {
|
||||
return *cfg.Parsed.AuthKey
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/cmd/testwrapper/flakytest"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/egressservices"
|
||||
@@ -46,7 +45,6 @@ import (
|
||||
const configFileAuthKey = "some-auth-key"
|
||||
|
||||
func TestContainerBoot(t *testing.T) {
|
||||
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/19380")
|
||||
boot := filepath.Join(t.TempDir(), "containerboot")
|
||||
if err := exec.Command("go", "build", "-ldflags", "-X main.testSleepDuration=1ms", "-o", boot, "tailscale.com/cmd/containerboot").Run(); err != nil {
|
||||
t.Fatalf("Building containerboot: %v", err)
|
||||
@@ -71,12 +69,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
// Waits below to be true before proceeding to the next phase.
|
||||
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 []string
|
||||
|
||||
@@ -111,10 +103,12 @@ func TestContainerBoot(t *testing.T) {
|
||||
}
|
||||
runningNotify := &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
SelfChange: &tailcfg.Node{
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
}).View(),
|
||||
},
|
||||
}
|
||||
type testCase struct {
|
||||
@@ -387,12 +381,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
SelfChange: &tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
},
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
@@ -407,6 +395,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
}).View(),
|
||||
},
|
||||
},
|
||||
},
|
||||
WantLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
|
||||
WantExitCode: new(1),
|
||||
},
|
||||
@@ -640,12 +629,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
SelfChange: &tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("newID"),
|
||||
Name: "new-name.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
},
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("newID"),
|
||||
@@ -653,6 +636,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
}).View(),
|
||||
},
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "new-name.test.ts.net.",
|
||||
@@ -1109,12 +1093,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: new(ipn.Running),
|
||||
SelfChange: &tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net.",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
},
|
||||
},
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
@@ -1129,6 +1107,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
}).View(),
|
||||
},
|
||||
},
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"egress-services": string(mustJSON(t, egressStatus)),
|
||||
"authkey": "tskey-key",
|
||||
@@ -1295,18 +1274,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
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)
|
||||
if p.Signal != nil {
|
||||
cmd.Process.Signal(*p.Signal)
|
||||
@@ -1499,7 +1466,6 @@ type localAPI struct {
|
||||
sync.Mutex
|
||||
cond *sync.Cond
|
||||
notify *ipn.Notify
|
||||
netmap *netmap.NetworkMap // served by /localapi/v0/netmap
|
||||
}
|
||||
|
||||
func (lc *localAPI) Start() error {
|
||||
@@ -1536,44 +1502,8 @@ func (lc *localAPI) Notify(n *ipn.Notify) {
|
||||
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) {
|
||||
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":
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
|
||||
@@ -41,28 +41,8 @@ func (b *bitbucketResponseWriter) Write(p []byte) (int, error) { return len(p),
|
||||
|
||||
func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {}
|
||||
|
||||
// setDNSCache sets the published DNS cache for tests.
|
||||
func setDNSCache(tb testing.TB, m *dnsEntryMap) {
|
||||
tb.Helper()
|
||||
j, err := json.Marshal(m.IPs)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
tstest.AssertNotParallel(tb)
|
||||
dnsCache.Store(m)
|
||||
dnsCacheBytes.Store(j)
|
||||
tb.Cleanup(func() {
|
||||
dnsCache.Store(nil)
|
||||
dnsCacheBytes.Store(nil)
|
||||
})
|
||||
}
|
||||
|
||||
func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP {
|
||||
t.Helper()
|
||||
tstest.AssertNotParallel(t)
|
||||
if dnsCache.Load() == nil {
|
||||
t.Fatal("dnsCache not initialized; call setDNSCache before getBootstrapDNS")
|
||||
}
|
||||
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleBootstrapDNS(w, req)
|
||||
@@ -120,8 +100,7 @@ func TestUnpublishedDNS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func resetMetrics(tb testing.TB) {
|
||||
tstest.AssertNotParallel(tb)
|
||||
func resetMetrics() {
|
||||
publishedDNSHits.Set(0)
|
||||
publishedDNSMisses.Set(0)
|
||||
unpublishedDNSHits.Set(0)
|
||||
@@ -135,7 +114,8 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
pub := &dnsEntryMap{
|
||||
IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}},
|
||||
}
|
||||
setDNSCache(t, pub)
|
||||
dnsCache.Store(pub)
|
||||
dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`))
|
||||
|
||||
unpublishedDNSCache.Store(&dnsEntryMap{
|
||||
IPs: map[string][]net.IP{
|
||||
@@ -151,7 +131,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
t.Run("CacheMiss", func(t *testing.T) {
|
||||
// One domain in map but empty, one not in map at all
|
||||
for _, q := range []string{"log.tailscale.com", "login.tailscale.com"} {
|
||||
resetMetrics(t)
|
||||
resetMetrics()
|
||||
ips := getBootstrapDNS(t, q)
|
||||
|
||||
// Expected our public map to be returned on a cache miss
|
||||
@@ -169,7 +149,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
|
||||
// Verify that we do get a valid response and metric.
|
||||
t.Run("CacheHit", func(t *testing.T) {
|
||||
resetMetrics(t)
|
||||
resetMetrics()
|
||||
ips := getBootstrapDNS(t, "controlplane.tailscale.com")
|
||||
want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
|
||||
if !reflect.DeepEqual(ips, want) {
|
||||
@@ -186,10 +166,8 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLookupMetric(t *testing.T) {
|
||||
setDNSCache(t, &dnsEntryMap{})
|
||||
|
||||
d := []string{"a.io", "b.io", "c.io", "d.io", "e.io", "e.io", "e.io", "a.io"}
|
||||
resetMetrics(t)
|
||||
resetMetrics()
|
||||
for _, q := range d {
|
||||
_ = getBootstrapDNS(t, q)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
|
||||
💣 github.com/go4org/hashtriemap from tailscale.com/derp/derpserver
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
||||
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
|
||||
@@ -311,7 +310,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
hash from crypto+
|
||||
hash/crc32 from compress/gzip+
|
||||
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/template from tailscale.com/cmd/derper+
|
||||
internal/abi from crypto/x509/internal/macos+
|
||||
|
||||
+8
-27
@@ -87,7 +87,8 @@ var (
|
||||
acceptConnLimit = flag.Float64("accept-connection-limit", math.Inf(+1), "rate limit for accepting new connection")
|
||||
acceptConnBurst = flag.Int("accept-connection-burst", math.MaxInt, "burst limit for accepting new connection")
|
||||
|
||||
rateConfigPath = flag.String("rate-config", "", "if non-empty, path to JSON rate limit config file. Rate limiting is experimental and subject to change. Configuration is reloaded on SIGHUP.")
|
||||
perClientRateLimit = flag.Uint("per-client-rate-limit", 0, "per-client receive rate limit in bytes/sec; 0 means unlimited. Mesh peers are exempt.")
|
||||
perClientRateBurst = flag.Uint("per-client-rate-burst", 0, "per-client receive rate burst in bytes; 0 defaults to 2x the rate limit (only relevant when using nonzero --per-client-rate-limit)")
|
||||
|
||||
// tcpKeepAlive is intentionally long, to reduce battery cost. There is an L7 keepalive on a higher frequency schedule.
|
||||
tcpKeepAlive = flag.Duration("tcp-keepalive-time", 10*time.Minute, "TCP keepalive time")
|
||||
@@ -194,11 +195,12 @@ func main() {
|
||||
s.SetVerifyClientURL(*verifyClientURL)
|
||||
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
|
||||
s.SetTCPWriteTimeout(*tcpWriteTimeout)
|
||||
if *rateConfigPath != "" {
|
||||
if err := s.LoadAndApplyRateConfig(*rateConfigPath); err != nil {
|
||||
log.Fatalf("derper: loading rate config: %v", err)
|
||||
if *perClientRateLimit > 0 {
|
||||
burst := *perClientRateBurst
|
||||
if burst < 1 {
|
||||
burst = *perClientRateLimit * 2
|
||||
}
|
||||
go watchRateConfig(ctx, s, *rateConfigPath)
|
||||
s.SetPerClientRateLimit(*perClientRateLimit, burst)
|
||||
}
|
||||
|
||||
var meshKey string
|
||||
@@ -252,7 +254,7 @@ func main() {
|
||||
if err := startMesh(s); err != nil {
|
||||
log.Fatalf("startMesh: %v", err)
|
||||
}
|
||||
expvar.Publish("derp", s.ExpVar(*rateConfigPath != ""))
|
||||
expvar.Publish("derp", s.ExpVar())
|
||||
|
||||
handleHome, ok := getHomeHandler(*flagHome)
|
||||
if !ok {
|
||||
@@ -434,27 +436,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// watchRateConfig listens for SIGHUP signals and reloads the rate config
|
||||
// file on each signal, applying it to the server. It returns when ctx is done.
|
||||
func watchRateConfig(ctx context.Context, s *derpserver.Server, path string) {
|
||||
sighup := make(chan os.Signal, 1)
|
||||
signal.Notify(sighup, syscall.SIGHUP)
|
||||
defer signal.Stop(sighup)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-sighup:
|
||||
log.Printf("derper: received SIGHUP, reloading rate config from %s", path)
|
||||
if err := s.LoadAndApplyRateConfig(path); err != nil {
|
||||
log.Printf("derper: rate config reload failed: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("derper: rate config reloaded successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
|
||||
|
||||
func prodAutocertHostPolicy(_ context.Context, host string) error {
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
"github.com/tailscale/hujson"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
tsclient "tailscale.com/client/tailscale"
|
||||
_ "tailscale.com/feature/identityfederation"
|
||||
_ "tailscale.com/feature/condregister/identityfederation"
|
||||
"tailscale.com/internal/client/tailscale"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
+201
-5
@@ -5,16 +5,212 @@
|
||||
package main // import "tailscale.com/cmd/hello"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/cmd/hello/helloserver"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
var (
|
||||
httpAddr = flag.String("http", ":80", "address to run an HTTP server on, or empty for none")
|
||||
httpsAddr = flag.String("https", ":443", "address to run an HTTPS server on, or empty for none")
|
||||
testIP = flag.String("test-ip", "", "if non-empty, look up IP and exit before running a server")
|
||||
)
|
||||
|
||||
//go:embed hello.tmpl.html
|
||||
var embeddedTemplate string
|
||||
|
||||
var localClient local.Client
|
||||
|
||||
func main() {
|
||||
s := &helloserver.Server{
|
||||
HTTPAddr: ":80",
|
||||
HTTPSAddr: ":443",
|
||||
flag.Parse()
|
||||
if *testIP != "" {
|
||||
res, err := localClient.WhoIs(context.Background(), *testIP)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
e := json.NewEncoder(os.Stdout)
|
||||
e.SetIndent("", "\t")
|
||||
e.Encode(res)
|
||||
return
|
||||
}
|
||||
if devMode() {
|
||||
// Parse it optimistically
|
||||
var err error
|
||||
tmpl, err = template.New("home").Parse(embeddedTemplate)
|
||||
if err != nil {
|
||||
log.Printf("ignoring template error in dev mode: %v", err)
|
||||
}
|
||||
} else {
|
||||
if embeddedTemplate == "" {
|
||||
log.Fatalf("embeddedTemplate is empty; must be build with Go 1.16+")
|
||||
}
|
||||
tmpl = template.Must(template.New("home").Parse(embeddedTemplate))
|
||||
}
|
||||
|
||||
http.HandleFunc("/", root)
|
||||
log.Printf("Starting hello server.")
|
||||
log.Fatal(s.Run())
|
||||
|
||||
errc := make(chan error, 1)
|
||||
if *httpAddr != "" {
|
||||
log.Printf("running HTTP server on %s", *httpAddr)
|
||||
go func() {
|
||||
errc <- http.ListenAndServe(*httpAddr, nil)
|
||||
}()
|
||||
}
|
||||
if *httpsAddr != "" {
|
||||
log.Printf("running HTTPS server on %s", *httpsAddr)
|
||||
go func() {
|
||||
hs := &http.Server{
|
||||
Addr: *httpsAddr,
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
switch hi.ServerName {
|
||||
case "hello.ts.net":
|
||||
return localClient.GetCertificate(hi)
|
||||
case "hello.ipn.dev":
|
||||
c, err := tls.LoadX509KeyPair(
|
||||
"/etc/hello/hello.ipn.dev.crt",
|
||||
"/etc/hello/hello.ipn.dev.key",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
return nil, errors.New("invalid SNI name")
|
||||
},
|
||||
},
|
||||
IdleTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 20 * time.Second,
|
||||
MaxHeaderBytes: 10 << 10,
|
||||
}
|
||||
errc <- hs.ListenAndServeTLS("", "")
|
||||
}()
|
||||
}
|
||||
log.Fatal(<-errc)
|
||||
}
|
||||
|
||||
func devMode() bool { return *httpsAddr == "" && *httpAddr != "" }
|
||||
|
||||
func getTmpl() (*template.Template, error) {
|
||||
if devMode() {
|
||||
tmplData, err := os.ReadFile("hello.tmpl.html")
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("using baked-in template in dev mode; can't find hello.tmpl.html in current directory")
|
||||
return tmpl, nil
|
||||
}
|
||||
return template.New("home").Parse(string(tmplData))
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// tmpl is the template used in prod mode.
|
||||
// In dev mode it's only used if the template file doesn't exist on disk.
|
||||
// It's initialized by main after flag parsing.
|
||||
var tmpl *template.Template
|
||||
|
||||
type tmplData struct {
|
||||
DisplayName string // "Foo Barberson"
|
||||
LoginName string // "foo@bar.com"
|
||||
ProfilePicURL string // "https://..."
|
||||
MachineName string // "imac5k"
|
||||
MachineOS string // "Linux"
|
||||
IP string // "100.2.3.4"
|
||||
}
|
||||
|
||||
func tailscaleIP(who *apitype.WhoIsResponse) string {
|
||||
if who == nil {
|
||||
return ""
|
||||
}
|
||||
vals, err := tailcfg.UnmarshalNodeCapJSON[string](who.Node.CapMap, tailcfg.NodeAttrNativeIPV4)
|
||||
if err == nil && len(vals) > 0 {
|
||||
return vals[0]
|
||||
}
|
||||
for _, nodeIP := range who.Node.Addresses {
|
||||
if nodeIP.Addr().Is4() && nodeIP.IsSingleIP() {
|
||||
return nodeIP.Addr().String()
|
||||
}
|
||||
}
|
||||
for _, nodeIP := range who.Node.Addresses {
|
||||
if nodeIP.IsSingleIP() {
|
||||
return nodeIP.Addr().String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func root(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS == nil && *httpsAddr != "" {
|
||||
host := r.Host
|
||||
if strings.Contains(r.Host, "100.101.102.103") ||
|
||||
strings.Contains(r.Host, "hello.ipn.dev") {
|
||||
host = "hello.ts.net"
|
||||
}
|
||||
http.Redirect(w, r, "https://"+host, http.StatusFound)
|
||||
return
|
||||
}
|
||||
if r.RequestURI != "/" {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if r.TLS != nil && *httpsAddr != "" && strings.Contains(r.Host, "hello.ipn.dev") {
|
||||
http.Redirect(w, r, "https://hello.ts.net", http.StatusFound)
|
||||
return
|
||||
}
|
||||
tmpl, err := getTmpl()
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
http.Error(w, "template error: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
who, err := localClient.WhoIs(r.Context(), r.RemoteAddr)
|
||||
var data tmplData
|
||||
if err != nil {
|
||||
if devMode() {
|
||||
log.Printf("warning: using fake data in dev mode due to whois lookup error: %v", err)
|
||||
data = tmplData{
|
||||
DisplayName: "Taily Scalerson",
|
||||
LoginName: "taily@scaler.son",
|
||||
ProfilePicURL: "https://placekitten.com/200/200",
|
||||
MachineName: "scaled",
|
||||
MachineOS: "Linux",
|
||||
IP: "100.1.2.3",
|
||||
}
|
||||
} else {
|
||||
log.Printf("whois(%q) error: %v", r.RemoteAddr, err)
|
||||
http.Error(w, "Your Tailscale works, but we failed to look you up.", 500)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
data = tmplData{
|
||||
DisplayName: who.UserProfile.DisplayName,
|
||||
LoginName: who.UserProfile.LoginName,
|
||||
ProfilePicURL: who.UserProfile.ProfilePicURL,
|
||||
MachineName: firstLabel(who.Node.ComputedName),
|
||||
MachineOS: who.Node.Hostinfo.OS(),
|
||||
IP: tailscaleIP(who),
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
// firstLabel s up until the first period, if any.
|
||||
func firstLabel(s string) string {
|
||||
s, _, _ = strings.Cut(s, ".")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<title>Hello from Tailscale</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: #dad6d5;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 24rem;
|
||||
width: 95%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.px-2 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.pl-3 {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.pr-3 {
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.pt-4 {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 0.5rem;
|
||||
;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.width-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.min-width-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.border-t-1 {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.border-gray-100 {
|
||||
border-color: #f7f5f4;
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
border-color: #eeebea;
|
||||
}
|
||||
|
||||
.border-gray-300 {
|
||||
border-color: #dad6d5;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.bg-gray-0 {
|
||||
background-color: #faf9f8;
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
background-color: #f7f5f4;
|
||||
}
|
||||
|
||||
.text-green-600 {
|
||||
color: #0d4b3b;
|
||||
}
|
||||
|
||||
.text-blue-600 {
|
||||
color: #3f5db3;
|
||||
}
|
||||
|
||||
.hover\:text-blue-800:hover {
|
||||
color: #253570;
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
color: #444342;
|
||||
}
|
||||
|
||||
.text-gray-700 {
|
||||
color: #2e2d2d;
|
||||
}
|
||||
|
||||
.text-gray-800 {
|
||||
color: #232222;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.font-title {
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-regular {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-pic {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 9999px;
|
||||
background-size: cover;
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.animate .panel {
|
||||
transform: translateY(10%);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.0), 0 10px 10px -5px rgba(0, 0, 0, 0.0);
|
||||
transition: transform 1200ms ease, opacity 1200ms ease, box-shadow 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .panel-interior {
|
||||
opacity: 0.0;
|
||||
transition: opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .logo {
|
||||
transform: translateY(2rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .header-title {
|
||||
transform: translateY(1.6rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .header-text {
|
||||
transform: translateY(1.2rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .footer {
|
||||
transform: translateY(-0.5rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animating .panel {
|
||||
transform: translateY(0);
|
||||
opacity: 1.0;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.animating .panel-interior {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.animating .spinner {
|
||||
opacity: 0.0;
|
||||
}
|
||||
|
||||
.animating .logo,
|
||||
.animating .header-title,
|
||||
.animating .header-text,
|
||||
.animating .footer {
|
||||
transform: translateY(0);
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
align-items: center;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.spinner span {
|
||||
display: inline-block;
|
||||
background-color: currentColor;
|
||||
border-radius: 9999px;
|
||||
animation-name: loading-dots-blink;
|
||||
animation-duration: 1.4s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-fill-mode: both;
|
||||
width: 0.35em;
|
||||
height: 0.35em;
|
||||
margin: 0 0.15em;
|
||||
}
|
||||
|
||||
.spinner span:nth-child(2) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.spinner span:nth-child(3) {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.animate .spinner {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@keyframes loading-dots-blink {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
* {
|
||||
animation-duration: 0ms !important;
|
||||
transition-duration: 0ms !important;
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<script>
|
||||
(function() {
|
||||
var lastSeen = localStorage.getItem("lastSeen");
|
||||
if (!lastSeen) {
|
||||
document.body.classList.add("animate");
|
||||
window.addEventListener("load", function () {
|
||||
setTimeout(function () {
|
||||
document.body.classList.add("animating");
|
||||
localStorage.setItem("lastSeen", Date.now());
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<main class="text-gray-800">
|
||||
<svg class="logo mb-6" width="28" height="28" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle opacity="0.2" cx="3.4" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="3.4" cy="19.5" r="2.7" fill="currentColor" />
|
||||
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="11.5" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="19.5" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="19.5" cy="19.5" r="2.7" fill="currentColor" />
|
||||
</svg>
|
||||
<header class="mb-8 text-center">
|
||||
<h1 class="header-title font-title font-semibold mb-2">You're connected over Tailscale!</h1>
|
||||
<p class="header-text">This device is signed in as…</p>
|
||||
</header>
|
||||
<div class="panel relative bg-white rounded-lg width-full shadow-xl mb-8 p-4">
|
||||
<div class="spinner text-gray-600">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="panel-interior flex items-center width-full min-width-0 p-2 mb-4">
|
||||
<div class="profile-pic bg-gray-100" style="background-image: url({{.ProfilePicURL}});"></div>
|
||||
<div class="overflow-hidden">
|
||||
{{ with .DisplayName }}
|
||||
<h4 class="font-semibold truncate">{{.}}</h4>
|
||||
{{ end }}
|
||||
<h5 class="text-gray-600 truncate">{{.LoginName}}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="panel-interior border border-gray-200 bg-gray-0 rounded-lg p-2 pl-3 pr-3 mb-2 width-full flex justify-between items-center">
|
||||
<div class="flex items-center min-width-0">
|
||||
<svg class="text-gray-600 mr-2" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
</svg>
|
||||
<h4 class="font-semibold truncate mr-2">{{.MachineName}}</h4>
|
||||
</div>
|
||||
<h5>{{.IP}}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer text-gray-600 text-center mb-12">
|
||||
<p>Read about <a href="https://tailscale.com/kb/1017/install#advanced-features" class="text-blue-600 hover:text-blue-800"
|
||||
target="_blank">what you can do next →</a></p>
|
||||
<p>Read about <a href="https://tailscale.com/kb/1073/hello" class="text-blue-600 hover:text-blue-800"
|
||||
target="_blank">the Hello service →</a></p>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,71 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<title>Hello from Tailscale</title>
|
||||
<link type="text/css" rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/script.js" defer></script>
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<main class="text-gray-800">
|
||||
<svg class="logo mb-6" width="28" height="28" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle opacity="0.2" cx="3.4" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="3.4" cy="19.5" r="2.7" fill="currentColor" />
|
||||
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="11.5" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="19.5" cy="3.25" r="2.7" fill="currentColor" />
|
||||
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor" />
|
||||
<circle opacity="0.2" cx="19.5" cy="19.5" r="2.7" fill="currentColor" />
|
||||
</svg>
|
||||
<header class="mb-8 text-center">
|
||||
<h1 class="header-title font-title font-semibold mb-2">You're connected over Tailscale!</h1>
|
||||
<p class="header-text">This device is signed in as…</p>
|
||||
</header>
|
||||
<div class="panel relative bg-white rounded-lg width-full shadow-xl mb-8 p-4">
|
||||
<div class="spinner text-gray-600">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="panel-interior flex items-center width-full min-width-0 p-2 mb-4">
|
||||
<div class="profile-pic bg-gray-100">
|
||||
<img
|
||||
src="{{.ProfilePicURL}}"
|
||||
alt="Profile picture"
|
||||
class="profile-pic-img"
|
||||
>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
{{ with .DisplayName }}
|
||||
<h4 class="font-semibold truncate">{{.}}</h4>
|
||||
{{ end }}
|
||||
<h5 class="text-gray-600 truncate">{{.LoginName}}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="panel-interior border border-gray-200 bg-gray-0 rounded-lg p-2 pl-3 pr-3 mb-2 width-full flex justify-between items-center">
|
||||
<div class="flex items-center min-width-0">
|
||||
<svg class="text-gray-600 mr-2" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
</svg>
|
||||
<h4 class="font-semibold truncate mr-2">{{.MachineName}}</h4>
|
||||
</div>
|
||||
<h5>{{.IP}}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer text-gray-600 text-center mb-12">
|
||||
<p>Read about <a href="https://tailscale.com/kb/1017/install#advanced-features" class="text-blue-600 hover:text-blue-800"
|
||||
target="_blank">what you can do next →</a></p>
|
||||
<p>Read about <a href="https://tailscale.com/kb/1073/hello" class="text-blue-600 hover:text-blue-800"
|
||||
target="_blank">the Hello service →</a></p>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,157 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package helloserver implements the HTTP server behind hello.ts.net.
|
||||
package helloserver
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
//go:embed hello.tmpl.html
|
||||
var embeddedTemplate string
|
||||
|
||||
//go:embed static/*
|
||||
var staticFiles embed.FS
|
||||
|
||||
var staticHandler = http.FileServerFS(staticFiles)
|
||||
|
||||
var tmpl = template.Must(template.New("home").Parse(embeddedTemplate))
|
||||
|
||||
// Server is an HTTP server for hello.ts.net.
|
||||
//
|
||||
// The zero value is not valid; populate at least one of HTTPAddr or HTTPSAddr
|
||||
// before calling Run.
|
||||
type Server struct {
|
||||
// HTTPAddr is the address to run an HTTP server on, or empty for none.
|
||||
HTTPAddr string
|
||||
|
||||
// HTTPSAddr is the address to run an HTTPS server on, or empty for none.
|
||||
HTTPSAddr string
|
||||
|
||||
// LocalClient is used to look up the identity of incoming requests and
|
||||
// to obtain TLS certificates. If nil, the zero value of local.Client is
|
||||
// used.
|
||||
LocalClient *local.Client
|
||||
}
|
||||
|
||||
func (s *Server) localClient() *local.Client {
|
||||
if s.LocalClient != nil {
|
||||
return s.LocalClient
|
||||
}
|
||||
return &local.Client{}
|
||||
}
|
||||
|
||||
// Run starts the configured HTTP and HTTPS servers and blocks until one of
|
||||
// them returns an error.
|
||||
func (s *Server) Run() error {
|
||||
errc := make(chan error, 1)
|
||||
if s.HTTPAddr != "" {
|
||||
log.Printf("running HTTP server on %s", s.HTTPAddr)
|
||||
go func() {
|
||||
errc <- http.ListenAndServe(s.HTTPAddr, s)
|
||||
}()
|
||||
}
|
||||
if s.HTTPSAddr != "" {
|
||||
log.Printf("running HTTPS server on %s", s.HTTPSAddr)
|
||||
go func() {
|
||||
hs := &http.Server{
|
||||
Addr: s.HTTPSAddr,
|
||||
Handler: s,
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: s.localClient().GetCertificate,
|
||||
},
|
||||
IdleTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 20 * time.Second,
|
||||
MaxHeaderBytes: 10 << 10,
|
||||
}
|
||||
errc <- hs.ListenAndServeTLS("", "")
|
||||
}()
|
||||
}
|
||||
return <-errc
|
||||
}
|
||||
|
||||
type tmplData struct {
|
||||
DisplayName string // "Foo Barberson"
|
||||
LoginName string // "foo@bar.com"
|
||||
ProfilePicURL string // "https://..."
|
||||
MachineName string // "imac5k"
|
||||
MachineOS string // "Linux"
|
||||
IP string // "100.2.3.4"
|
||||
}
|
||||
|
||||
func tailscaleIP(who *apitype.WhoIsResponse) string {
|
||||
if who == nil {
|
||||
return ""
|
||||
}
|
||||
vals, err := tailcfg.UnmarshalNodeCapJSON[string](who.Node.CapMap, tailcfg.NodeAttrNativeIPV4)
|
||||
if err == nil && len(vals) > 0 {
|
||||
return vals[0]
|
||||
}
|
||||
for _, nodeIP := range who.Node.Addresses {
|
||||
if nodeIP.Addr().Is4() && nodeIP.IsSingleIP() {
|
||||
return nodeIP.Addr().String()
|
||||
}
|
||||
}
|
||||
for _, nodeIP := range who.Node.Addresses {
|
||||
if nodeIP.IsSingleIP() {
|
||||
return nodeIP.Addr().String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS == nil && s.HTTPSAddr != "" {
|
||||
host := r.Host
|
||||
if strings.Contains(r.Host, "100.101.102.103") {
|
||||
host = "hello.ts.net"
|
||||
}
|
||||
http.Redirect(w, r, "https://"+host, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.RequestURI, "/static/") {
|
||||
staticHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.RequestURI != "/" {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
who, err := s.localClient().WhoIs(r.Context(), r.RemoteAddr)
|
||||
if err != nil {
|
||||
log.Printf("whois(%q) error: %v", r.RemoteAddr, err)
|
||||
http.Error(w, "Your Tailscale works, but we failed to look you up.", 500)
|
||||
return
|
||||
}
|
||||
data := tmplData{
|
||||
DisplayName: who.UserProfile.DisplayName,
|
||||
LoginName: who.UserProfile.LoginName,
|
||||
ProfilePicURL: who.UserProfile.ProfilePicURL,
|
||||
MachineName: firstLabel(who.Node.ComputedName),
|
||||
MachineOS: who.Node.Hostinfo.OS(),
|
||||
IP: tailscaleIP(who),
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
// firstLabel returns s up until the first period, if any.
|
||||
func firstLabel(s string) string {
|
||||
s, _, _ = strings.Cut(s, ".")
|
||||
return s
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
(function () {
|
||||
var lastSeen = localStorage.getItem("lastSeen");
|
||||
if (!lastSeen) {
|
||||
document.body.classList.add("animate");
|
||||
window.addEventListener("load", function () {
|
||||
setTimeout(function () {
|
||||
document.body.classList.add("animating");
|
||||
localStorage.setItem("lastSeen", Date.now());
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1,366 +0,0 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: #dad6d5;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 24rem;
|
||||
width: 95%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.px-2 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.pl-3 {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.pr-3 {
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.pt-4 {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 0.5rem;
|
||||
;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.width-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.min-width-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.border-t-1 {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.border-gray-100 {
|
||||
border-color: #f7f5f4;
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
border-color: #eeebea;
|
||||
}
|
||||
|
||||
.border-gray-300 {
|
||||
border-color: #dad6d5;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.bg-gray-0 {
|
||||
background-color: #faf9f8;
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
background-color: #f7f5f4;
|
||||
}
|
||||
|
||||
.text-green-600 {
|
||||
color: #0d4b3b;
|
||||
}
|
||||
|
||||
.text-blue-600 {
|
||||
color: #3f5db3;
|
||||
}
|
||||
|
||||
.hover\:text-blue-800:hover {
|
||||
color: #253570;
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
color: #444342;
|
||||
}
|
||||
|
||||
.text-gray-700 {
|
||||
color: #2e2d2d;
|
||||
}
|
||||
|
||||
.text-gray-800 {
|
||||
color: #232222;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.font-title {
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-regular {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-pic {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background-size: cover;
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-pic-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.animate .panel {
|
||||
transform: translateY(10%);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.0), 0 10px 10px -5px rgba(0, 0, 0, 0.0);
|
||||
transition: transform 1200ms ease, opacity 1200ms ease, box-shadow 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .panel-interior {
|
||||
opacity: 0.0;
|
||||
transition: opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .logo {
|
||||
transform: translateY(2rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .header-title {
|
||||
transform: translateY(1.6rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .header-text {
|
||||
transform: translateY(1.2rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animate .footer {
|
||||
transform: translateY(-0.5rem);
|
||||
opacity: 0.0;
|
||||
transition: transform 1200ms ease, opacity 1200ms ease;
|
||||
}
|
||||
|
||||
.animating .panel {
|
||||
transform: translateY(0);
|
||||
opacity: 1.0;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.animating .panel-interior {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.animating .spinner {
|
||||
opacity: 0.0;
|
||||
}
|
||||
|
||||
.animating .logo,
|
||||
.animating .header-title,
|
||||
.animating .header-text,
|
||||
.animating .footer {
|
||||
transform: translateY(0);
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-flex;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
align-items: center;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.spinner span {
|
||||
display: inline-block;
|
||||
background-color: currentColor;
|
||||
border-radius: 9999px;
|
||||
animation-name: loading-dots-blink;
|
||||
animation-duration: 1.4s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-fill-mode: both;
|
||||
width: 0.35em;
|
||||
height: 0.35em;
|
||||
margin: 0 0.15em;
|
||||
}
|
||||
|
||||
.spinner span:nth-child(2) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.spinner span:nth-child(3) {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.animate .spinner {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@keyframes loading-dots-blink {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
* {
|
||||
animation-duration: 0ms !important;
|
||||
transition-duration: 0ms !important;
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,77 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
|
||||
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
|
||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
||||
github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/defaults+
|
||||
github.com/aws/aws-sdk-go-v2/aws/defaults from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/aws/middleware from github.com/aws/aws-sdk-go-v2/aws/retry+
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/query from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/restjson from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/xml from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/aws/ratelimit from github.com/aws/aws-sdk-go-v2/aws/retry
|
||||
github.com/aws/aws-sdk-go-v2/aws/retry from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client+
|
||||
github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 from github.com/aws/aws-sdk-go-v2/aws/signer/v4
|
||||
github.com/aws/aws-sdk-go-v2/aws/signer/v4 from github.com/aws/aws-sdk-go-v2/internal/auth/smithy+
|
||||
github.com/aws/aws-sdk-go-v2/aws/transport/http from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/config from tailscale.com/wif
|
||||
github.com/aws/aws-sdk-go-v2/credentials from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds
|
||||
github.com/aws/aws-sdk-go-v2/credentials/processcreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/ssocreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/stscreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds/internal/config from github.com/aws/aws-sdk-go-v2/feature/ec2/imds
|
||||
github.com/aws/aws-sdk-go-v2/internal/auth from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
|
||||
github.com/aws/aws-sdk-go-v2/internal/auth/smithy from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/context from github.com/aws/aws-sdk-go-v2/aws/retry+
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 from github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints+
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/internal/middleware from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/rand from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/aws-sdk-go-v2/internal/sdk from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/aws-sdk-go-v2/internal/sdkio from github.com/aws/aws-sdk-go-v2/credentials/processcreds
|
||||
github.com/aws/aws-sdk-go-v2/internal/shareddefaults from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/internal/strings from github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4
|
||||
github.com/aws/aws-sdk-go-v2/internal/sync/singleflight from github.com/aws/aws-sdk-go-v2/aws
|
||||
github.com/aws/aws-sdk-go-v2/internal/timeconv from github.com/aws/aws-sdk-go-v2/aws/retry
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/service/sso from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso
|
||||
github.com/aws/aws-sdk-go-v2/service/sso/types from github.com/aws/aws-sdk-go-v2/service/sso
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssooidc
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc/types from github.com/aws/aws-sdk-go-v2/service/ssooidc
|
||||
github.com/aws/aws-sdk-go-v2/service/sts from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/service/sts/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/service/sts/types from github.com/aws/aws-sdk-go-v2/credentials/stscreds+
|
||||
github.com/aws/smithy-go from github.com/aws/aws-sdk-go-v2/aws/protocol/restjson+
|
||||
github.com/aws/smithy-go/auth from github.com/aws/aws-sdk-go-v2/internal/auth+
|
||||
github.com/aws/smithy-go/auth/bearer from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/context from github.com/aws/smithy-go/auth/bearer
|
||||
github.com/aws/smithy-go/document from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/smithy-go/encoding from github.com/aws/smithy-go/encoding/json+
|
||||
github.com/aws/smithy-go/encoding/httpbinding from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
|
||||
github.com/aws/smithy-go/encoding/json from github.com/aws/aws-sdk-go-v2/service/ssooidc
|
||||
github.com/aws/smithy-go/encoding/xml from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/smithy-go/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/smithy-go/endpoints/private/rulesfn from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/smithy-go/internal/sync/singleflight from github.com/aws/smithy-go/auth/bearer
|
||||
github.com/aws/smithy-go/io from github.com/aws/aws-sdk-go-v2/feature/ec2/imds+
|
||||
github.com/aws/smithy-go/logging from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/metrics from github.com/aws/aws-sdk-go-v2/aws/retry+
|
||||
github.com/aws/smithy-go/middleware from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/private/requestcompression from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/smithy-go/ptr from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/rand from github.com/aws/aws-sdk-go-v2/aws/middleware
|
||||
github.com/aws/smithy-go/time from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/smithy-go/tracing from github.com/aws/aws-sdk-go-v2/aws/middleware+
|
||||
github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
|
||||
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
|
||||
github.com/blang/semver/v4 from k8s.io/component-base/metrics
|
||||
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus+
|
||||
@@ -59,7 +130,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
github.com/google/gnostic-models/jsonschema from github.com/google/gnostic-models/compiler
|
||||
github.com/google/gnostic-models/openapiv2 from k8s.io/client-go/discovery+
|
||||
github.com/google/gnostic-models/openapiv3 from k8s.io/kube-openapi/pkg/handler3+
|
||||
github.com/google/uuid from k8s.io/apimachinery/pkg/util/uuid+
|
||||
github.com/google/uuid from github.com/prometheus-community/pro-bing+
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
||||
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
|
||||
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
||||
@@ -93,6 +164,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal+
|
||||
github.com/pkg/errors from github.com/evanphx/json-patch/v5+
|
||||
github.com/pmezard/go-difflib/difflib from k8s.io/apimachinery/pkg/util/diff
|
||||
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
|
||||
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil from github.com/prometheus/client_golang/prometheus/promhttp
|
||||
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil/header from github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil
|
||||
💣 github.com/prometheus/client_golang/prometheus from github.com/prometheus/client_golang/prometheus/collectors+
|
||||
@@ -108,7 +180,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
|
||||
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
|
||||
github.com/spf13/pflag from k8s.io/client-go/tools/clientcmd+
|
||||
DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
|
||||
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
|
||||
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
|
||||
@@ -733,9 +805,11 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/feature/c2n from tailscale.com/tsnet
|
||||
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/feature/condregister/identityfederation from tailscale.com/tsnet
|
||||
tailscale.com/feature/condregister/oauthkey from tailscale.com/tsnet
|
||||
tailscale.com/feature/condregister/portmapper from tailscale.com/tsnet
|
||||
tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet
|
||||
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
|
||||
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
|
||||
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
|
||||
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
|
||||
@@ -743,7 +817,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/health from tailscale.com/control/controlclient+
|
||||
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/hostinfo from tailscale.com/client/web+
|
||||
tailscale.com/internal/client/tailscale from tailscale.com/feature/oauthkey+
|
||||
tailscale.com/internal/client/tailscale from tailscale.com/feature/identityfederation+
|
||||
tailscale.com/ipn from tailscale.com/client/local+
|
||||
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
|
||||
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
|
||||
@@ -836,7 +910,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/tstime from tailscale.com/cmd/k8s-operator+
|
||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
||||
tailscale.com/tsweb from tailscale.com/util/eventbus+
|
||||
tailscale.com/tsweb from tailscale.com/util/eventbus
|
||||
tailscale.com/tsweb/varz from tailscale.com/util/usermetric+
|
||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/types/bools from tailscale.com/tsnet+
|
||||
@@ -926,6 +1000,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
|
||||
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine+
|
||||
tailscale.com/wgengine/wglog from tailscale.com/wgengine
|
||||
tailscale.com/wif from tailscale.com/feature/identityfederation
|
||||
golang.org/x/crypto/argon2 from tailscale.com/tka
|
||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
||||
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
||||
@@ -948,15 +1023,14 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
|
||||
golang.org/x/net/http2 from k8s.io/apimachinery/pkg/util/net+
|
||||
golang.org/x/net/http2/hpack from golang.org/x/net/http2+
|
||||
golang.org/x/net/icmp from tailscale.com/net/ping
|
||||
golang.org/x/net/icmp from github.com/prometheus-community/pro-bing+
|
||||
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
|
||||
golang.org/x/net/internal/httpcommon from golang.org/x/net/http2
|
||||
golang.org/x/net/internal/httpsfv from golang.org/x/net/http2
|
||||
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
|
||||
golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
|
||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
||||
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
||||
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
|
||||
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
|
||||
golang.org/x/net/ipv4 from github.com/prometheus-community/pro-bing+
|
||||
golang.org/x/net/ipv6 from github.com/prometheus-community/pro-bing+
|
||||
golang.org/x/net/proxy from tailscale.com/net/netns
|
||||
D golang.org/x/net/route from tailscale.com/net/netmon+
|
||||
golang.org/x/net/websocket from tailscale.com/k8s-operator/sessionrecording/ws
|
||||
@@ -1063,7 +1137,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
crypto/sha3 from crypto/internal/fips140hash+
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/cipher+
|
||||
crypto/tls from github.com/prometheus/client_golang/prometheus/promhttp+
|
||||
crypto/tls from github.com/prometheus-community/pro-bing+
|
||||
crypto/tls/internal/fips140tls from crypto/tls
|
||||
crypto/x509 from crypto/tls+
|
||||
D crypto/x509/internal/macos from crypto/x509
|
||||
@@ -1172,7 +1246,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
mime/quotedprintable from mime/multipart
|
||||
net from crypto/tls+
|
||||
net/http from expvar+
|
||||
net/http/httptrace from github.com/prometheus/client_golang/prometheus/promhttp+
|
||||
net/http/httptrace from github.com/prometheus-community/pro-bing+
|
||||
net/http/httputil from tailscale.com/client/web+
|
||||
net/http/internal from net/http+
|
||||
net/http/internal/ascii from net/http+
|
||||
|
||||
@@ -146,6 +146,3 @@ spec:
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.operatorConfig.priorityClassName }}
|
||||
priorityClassName: {{ . }}
|
||||
{{- end }}
|
||||
|
||||
@@ -72,8 +72,6 @@ operatorConfig:
|
||||
|
||||
affinity: {}
|
||||
|
||||
priorityClassName: ""
|
||||
|
||||
podSecurityContext: {}
|
||||
|
||||
securityContext: {}
|
||||
|
||||
@@ -104,884 +104,6 @@ spec:
|
||||
description: Pod configuration.
|
||||
type: object
|
||||
properties:
|
||||
affinity:
|
||||
description: If specified, applies affinity rules to the pods deployed by the DNSConfig resource.
|
||||
type: object
|
||||
properties:
|
||||
nodeAffinity:
|
||||
description: Describes node affinity scheduling rules for the pod.
|
||||
type: object
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
The scheduler will prefer to schedule pods to nodes that satisfy
|
||||
the affinity expressions specified by this field, but it may choose
|
||||
a node that violates one or more of the expressions. The node that is
|
||||
most preferred is the one with the greatest sum of weights, i.e.
|
||||
for each node that meets all of the scheduling requirements (resource
|
||||
request, requiredDuringScheduling affinity expressions, etc.),
|
||||
compute a sum by iterating through the elements of this field and adding
|
||||
"weight" to the sum if the node matches the corresponding matchExpressions; the
|
||||
node(s) with the highest sum are the most preferred.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
An empty preferred scheduling term matches all objects with implicit weight 0
|
||||
(i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).
|
||||
type: object
|
||||
required:
|
||||
- preference
|
||||
- weight
|
||||
properties:
|
||||
preference:
|
||||
description: A node selector term, associated with the corresponding weight.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: A list of node selector requirements by node's labels.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A node selector requirement is a selector that contains values, a key, and an operator
|
||||
that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
Represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
An array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. If the operator is Gt or Lt, the values
|
||||
array must have a single element, which will be interpreted as an integer.
|
||||
This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchFields:
|
||||
description: A list of node selector requirements by node's fields.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A node selector requirement is a selector that contains values, a key, and an operator
|
||||
that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
Represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
An array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. If the operator is Gt or Lt, the values
|
||||
array must have a single element, which will be interpreted as an integer.
|
||||
This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-map-type: atomic
|
||||
weight:
|
||||
description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.
|
||||
type: integer
|
||||
format: int32
|
||||
x-kubernetes-list-type: atomic
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
If the affinity requirements specified by this field are not met at
|
||||
scheduling time, the pod will not be scheduled onto the node.
|
||||
If the affinity requirements specified by this field cease to be met
|
||||
at some point during pod execution (e.g. due to an update), the system
|
||||
may or may not try to eventually evict the pod from its node.
|
||||
type: object
|
||||
required:
|
||||
- nodeSelectorTerms
|
||||
properties:
|
||||
nodeSelectorTerms:
|
||||
description: Required. A list of node selector terms. The terms are ORed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A null or empty node selector term matches no objects. The requirements of
|
||||
them are ANDed.
|
||||
The TopologySelectorTerm type implements a subset of the NodeSelectorTerm.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: A list of node selector requirements by node's labels.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A node selector requirement is a selector that contains values, a key, and an operator
|
||||
that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
Represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
An array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. If the operator is Gt or Lt, the values
|
||||
array must have a single element, which will be interpreted as an integer.
|
||||
This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchFields:
|
||||
description: A list of node selector requirements by node's fields.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A node selector requirement is a selector that contains values, a key, and an operator
|
||||
that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
Represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
An array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. If the operator is Gt or Lt, the values
|
||||
array must have a single element, which will be interpreted as an integer.
|
||||
This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-map-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-map-type: atomic
|
||||
podAffinity:
|
||||
description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).
|
||||
type: object
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
The scheduler will prefer to schedule pods to nodes that satisfy
|
||||
the affinity expressions specified by this field, but it may choose
|
||||
a node that violates one or more of the expressions. The node that is
|
||||
most preferred is the one with the greatest sum of weights, i.e.
|
||||
for each node that meets all of the scheduling requirements (resource
|
||||
request, requiredDuringScheduling affinity expressions, etc.),
|
||||
compute a sum by iterating through the elements of this field and adding
|
||||
"weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the
|
||||
node(s) with the highest sum are the most preferred.
|
||||
type: array
|
||||
items:
|
||||
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
|
||||
type: object
|
||||
required:
|
||||
- podAffinityTerm
|
||||
- weight
|
||||
properties:
|
||||
podAffinityTerm:
|
||||
description: Required. A pod affinity term, associated with the corresponding weight.
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: |-
|
||||
A label query over a set of resources, in this case pods.
|
||||
If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: |-
|
||||
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: |-
|
||||
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: |-
|
||||
A label query over the set of namespaces that the term applies to.
|
||||
The term is applied to the union of the namespaces selected by this field
|
||||
and the ones listed in the namespaces field.
|
||||
null selector and null or empty namespaces list means "this pod's namespace".
|
||||
An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: |-
|
||||
namespaces specifies a static list of namespace names that the term applies to.
|
||||
The term is applied to the union of the namespaces listed in this field
|
||||
and the ones selected by namespaceSelector.
|
||||
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
topologyKey:
|
||||
description: |-
|
||||
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||
selected pods is running.
|
||||
Empty topologyKey is not allowed.
|
||||
type: string
|
||||
weight:
|
||||
description: |-
|
||||
weight associated with matching the corresponding podAffinityTerm,
|
||||
in the range 1-100.
|
||||
type: integer
|
||||
format: int32
|
||||
x-kubernetes-list-type: atomic
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
If the affinity requirements specified by this field are not met at
|
||||
scheduling time, the pod will not be scheduled onto the node.
|
||||
If the affinity requirements specified by this field cease to be met
|
||||
at some point during pod execution (e.g. due to a pod label update), the
|
||||
system may or may not try to eventually evict the pod from its node.
|
||||
When there are multiple elements, the lists of nodes corresponding to each
|
||||
podAffinityTerm are intersected, i.e. all terms must be satisfied.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
Defines a set of pods (namely those matching the labelSelector
|
||||
relative to the given namespace(s)) that this pod should be
|
||||
co-located (affinity) or not co-located (anti-affinity) with,
|
||||
where co-located is defined as running on a node whose value of
|
||||
the label with key <topologyKey> matches that of any node on which
|
||||
a pod of the set of pods is running
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: |-
|
||||
A label query over a set of resources, in this case pods.
|
||||
If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: |-
|
||||
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: |-
|
||||
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: |-
|
||||
A label query over the set of namespaces that the term applies to.
|
||||
The term is applied to the union of the namespaces selected by this field
|
||||
and the ones listed in the namespaces field.
|
||||
null selector and null or empty namespaces list means "this pod's namespace".
|
||||
An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: |-
|
||||
namespaces specifies a static list of namespace names that the term applies to.
|
||||
The term is applied to the union of the namespaces listed in this field
|
||||
and the ones selected by namespaceSelector.
|
||||
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
topologyKey:
|
||||
description: |-
|
||||
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||
selected pods is running.
|
||||
Empty topologyKey is not allowed.
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
podAntiAffinity:
|
||||
description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).
|
||||
type: object
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
The scheduler will prefer to schedule pods to nodes that satisfy
|
||||
the anti-affinity expressions specified by this field, but it may choose
|
||||
a node that violates one or more of the expressions. The node that is
|
||||
most preferred is the one with the greatest sum of weights, i.e.
|
||||
for each node that meets all of the scheduling requirements (resource
|
||||
request, requiredDuringScheduling anti-affinity expressions, etc.),
|
||||
compute a sum by iterating through the elements of this field and subtracting
|
||||
"weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the
|
||||
node(s) with the highest sum are the most preferred.
|
||||
type: array
|
||||
items:
|
||||
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
|
||||
type: object
|
||||
required:
|
||||
- podAffinityTerm
|
||||
- weight
|
||||
properties:
|
||||
podAffinityTerm:
|
||||
description: Required. A pod affinity term, associated with the corresponding weight.
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: |-
|
||||
A label query over a set of resources, in this case pods.
|
||||
If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: |-
|
||||
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: |-
|
||||
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: |-
|
||||
A label query over the set of namespaces that the term applies to.
|
||||
The term is applied to the union of the namespaces selected by this field
|
||||
and the ones listed in the namespaces field.
|
||||
null selector and null or empty namespaces list means "this pod's namespace".
|
||||
An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: |-
|
||||
namespaces specifies a static list of namespace names that the term applies to.
|
||||
The term is applied to the union of the namespaces listed in this field
|
||||
and the ones selected by namespaceSelector.
|
||||
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
topologyKey:
|
||||
description: |-
|
||||
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||
selected pods is running.
|
||||
Empty topologyKey is not allowed.
|
||||
type: string
|
||||
weight:
|
||||
description: |-
|
||||
weight associated with matching the corresponding podAffinityTerm,
|
||||
in the range 1-100.
|
||||
type: integer
|
||||
format: int32
|
||||
x-kubernetes-list-type: atomic
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: |-
|
||||
If the anti-affinity requirements specified by this field are not met at
|
||||
scheduling time, the pod will not be scheduled onto the node.
|
||||
If the anti-affinity requirements specified by this field cease to be met
|
||||
at some point during pod execution (e.g. due to a pod label update), the
|
||||
system may or may not try to eventually evict the pod from its node.
|
||||
When there are multiple elements, the lists of nodes corresponding to each
|
||||
podAffinityTerm are intersected, i.e. all terms must be satisfied.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
Defines a set of pods (namely those matching the labelSelector
|
||||
relative to the given namespace(s)) that this pod should be
|
||||
co-located (affinity) or not co-located (anti-affinity) with,
|
||||
where co-located is defined as running on a node whose value of
|
||||
the label with key <topologyKey> matches that of any node on which
|
||||
a pod of the set of pods is running
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: |-
|
||||
A label query over a set of resources, in this case pods.
|
||||
If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: |-
|
||||
MatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
|
||||
Also, matchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: |-
|
||||
MismatchLabelKeys is a set of pod label keys to select which pods will
|
||||
be taken into consideration. The keys are used to lookup values from the
|
||||
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
|
||||
to select the group of existing pods which pods will be taken into consideration
|
||||
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
|
||||
pod labels will be ignored. The default value is empty.
|
||||
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
|
||||
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: |-
|
||||
A label query over the set of namespaces that the term applies to.
|
||||
The term is applied to the union of the namespaces selected by this field
|
||||
and the ones listed in the namespaces field.
|
||||
null selector and null or empty namespaces list means "this pod's namespace".
|
||||
An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: |-
|
||||
A label selector requirement is a selector that contains values, a key, and an operator that
|
||||
relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: |-
|
||||
operator represents a key's relationship to a set of values.
|
||||
Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: |-
|
||||
values is an array of string values. If the operator is In or NotIn,
|
||||
the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||
the values array must be empty. This array is replaced during a strategic
|
||||
merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
x-kubernetes-list-type: atomic
|
||||
matchLabels:
|
||||
description: |-
|
||||
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||
map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||
operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: |-
|
||||
namespaces specifies a static list of namespace names that the term applies to.
|
||||
The term is applied to the union of the namespaces listed in this field
|
||||
and the ones selected by namespaceSelector.
|
||||
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
topologyKey:
|
||||
description: |-
|
||||
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
|
||||
the labelSelector in the specified namespaces, where co-located is defined as running on a node
|
||||
whose value of the label with key topologyKey matches that of any node on which any of the
|
||||
selected pods is running.
|
||||
Empty topologyKey is not allowed.
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
nodeSelector:
|
||||
description: If specified, applies node selector rules to the pods deployed by the DNSConfig resource.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
tolerations:
|
||||
description: If specified, applies tolerations to the pods deployed by the DNSConfig resource.
|
||||
type: array
|
||||
|
||||
@@ -442,884 +442,6 @@ spec:
|
||||
pod:
|
||||
description: Pod configuration.
|
||||
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:
|
||||
description: If specified, applies tolerations to the pods deployed by the DNSConfig resource.
|
||||
items:
|
||||
|
||||
@@ -17,11 +17,9 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"tailscale.com/client/tailscale/v2"
|
||||
kube "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
@@ -33,12 +31,12 @@ func TestL3Ingress(t *testing.T) {
|
||||
}
|
||||
|
||||
// Apply nginx
|
||||
nginx := nginxDeployment(ns)
|
||||
createAndCleanup(t, kubeClient, nginx)
|
||||
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx"))
|
||||
// Apply service to expose it as ingress
|
||||
name := generateName("test-ingress")
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: generateName("test-ingress"),
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/expose": "true",
|
||||
@@ -46,7 +44,7 @@ func TestL3Ingress(t *testing.T) {
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{
|
||||
"app.kubernetes.io/name": nginx.Name,
|
||||
"app.kubernetes.io/name": "nginx",
|
||||
},
|
||||
Ports: []corev1.ServicePort{
|
||||
{
|
||||
@@ -60,7 +58,7 @@ func TestL3Ingress(t *testing.T) {
|
||||
createAndCleanup(t, kubeClient, svc)
|
||||
|
||||
if err := tstest.WaitFor(time.Minute, func() error {
|
||||
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, svc.Name)}
|
||||
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, name)}
|
||||
if err := get(t.Context(), kubeClient, maybeReadySvc); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -81,7 +79,7 @@ func TestL3Ingress(t *testing.T) {
|
||||
if err := kubeClient.List(t.Context(), &secrets,
|
||||
client.InNamespace("tailscale"),
|
||||
client.MatchingLabels{
|
||||
"tailscale.com/parent-resource": svc.Name,
|
||||
"tailscale.com/parent-resource": name,
|
||||
"tailscale.com/parent-resource-ns": ns,
|
||||
},
|
||||
); err != nil {
|
||||
@@ -111,34 +109,33 @@ func TestL3HAIngress(t *testing.T) {
|
||||
}
|
||||
|
||||
// Apply nginx.
|
||||
nginx := nginxDeployment(ns)
|
||||
createAndCleanup(t, kubeClient, nginx)
|
||||
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx"))
|
||||
|
||||
// Create an ingress ProxyGroup.
|
||||
pg := &tsapi.ProxyGroup{
|
||||
createAndCleanup(t, kubeClient, &tsapi.ProxyGroup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: generateName("ingress"),
|
||||
Name: "ingress",
|
||||
},
|
||||
Spec: tsapi.ProxyGroupSpec{
|
||||
Type: tsapi.ProxyGroupTypeIngress,
|
||||
},
|
||||
}
|
||||
createAndCleanup(t, kubeClient, pg)
|
||||
})
|
||||
|
||||
// Apply a Service to expose nginx via the ProxyGroup.
|
||||
name := generateName("test-ingress")
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: generateName("test-ingress"),
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
Annotations: map[string]string{
|
||||
"tailscale.com/proxy-group": pg.Name,
|
||||
"tailscale.com/proxy-group": "ingress",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: new("tailscale"),
|
||||
Selector: map[string]string{
|
||||
"app.kubernetes.io/name": nginx.Name,
|
||||
"app.kubernetes.io/name": "nginx",
|
||||
},
|
||||
Ports: []corev1.ServicePort{
|
||||
{
|
||||
@@ -153,12 +150,12 @@ func TestL3HAIngress(t *testing.T) {
|
||||
|
||||
var svcIPv4 string
|
||||
forceReconcile := triggerReconcile(t,
|
||||
client.ObjectKey{Namespace: ns, Name: svc.Name},
|
||||
client.ObjectKey{Namespace: ns, Name: name},
|
||||
&corev1.Service{}, 30*time.Second)
|
||||
|
||||
// Wait for Service to be ready
|
||||
if err := tstest.WaitFor(5*time.Minute, func() error {
|
||||
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, svc.Name)}
|
||||
maybeReadySvc := &corev1.Service{ObjectMeta: objectMeta(ns, name)}
|
||||
forceReconcile()
|
||||
if err := get(t.Context(), kubeClient, maybeReadySvc); err != nil {
|
||||
return err
|
||||
@@ -189,16 +186,15 @@ func TestL7Ingress(t *testing.T) {
|
||||
}
|
||||
|
||||
// Apply nginx Deployment and Service.
|
||||
nginx := nginxDeployment(ns)
|
||||
createAndCleanup(t, kubeClient, nginx)
|
||||
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx"))
|
||||
createAndCleanup(t, kubeClient, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: nginx.Name,
|
||||
Name: "nginx",
|
||||
Namespace: ns,
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{
|
||||
"app.kubernetes.io/name": nginx.Name,
|
||||
"app.kubernetes.io/name": "nginx",
|
||||
},
|
||||
Ports: []corev1.ServicePort{
|
||||
{
|
||||
@@ -210,12 +206,13 @@ func TestL7Ingress(t *testing.T) {
|
||||
})
|
||||
|
||||
// Apply Ingress to expose nginx.
|
||||
ingress := l7Ingress(ns, nginx.Name, map[string]string{})
|
||||
name := generateName("test-ingress")
|
||||
ingress := l7Ingress(ns, name, map[string]string{})
|
||||
createAndCleanup(t, kubeClient, ingress)
|
||||
|
||||
t.Log("Waiting for the Ingress to be ready...")
|
||||
|
||||
hostname, err := waitForIngressHostname(t, ns, ingress.Name)
|
||||
hostname, err := waitForIngressHostname(t, ns, name)
|
||||
if err != nil {
|
||||
t.Fatalf("error waiting for Ingress hostname: %v", err)
|
||||
}
|
||||
@@ -231,16 +228,15 @@ func TestL7HAIngress(t *testing.T) {
|
||||
}
|
||||
|
||||
// Apply nginx Deployment and Service.
|
||||
nginx := nginxDeployment(ns)
|
||||
createAndCleanup(t, kubeClient, nginx)
|
||||
createAndCleanup(t, kubeClient, nginxDeployment(ns, "nginx"))
|
||||
createAndCleanup(t, kubeClient, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: nginx.Name,
|
||||
Name: "nginx",
|
||||
Namespace: ns,
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{
|
||||
"app.kubernetes.io/name": nginx.Name,
|
||||
"app.kubernetes.io/name": "nginx",
|
||||
},
|
||||
Ports: []corev1.ServicePort{
|
||||
{
|
||||
@@ -252,23 +248,23 @@ func TestL7HAIngress(t *testing.T) {
|
||||
})
|
||||
|
||||
// Create ProxyGroup that the Ingress will reference.
|
||||
pg := &tsapi.ProxyGroup{
|
||||
createAndCleanup(t, kubeClient, &tsapi.ProxyGroup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: generateName("ingress"),
|
||||
Name: "ingress",
|
||||
},
|
||||
Spec: tsapi.ProxyGroupSpec{
|
||||
Type: tsapi.ProxyGroupTypeIngress,
|
||||
},
|
||||
}
|
||||
createAndCleanup(t, kubeClient, pg)
|
||||
})
|
||||
|
||||
// Apply Ingress to expose nginx.
|
||||
ingress := l7Ingress(ns, nginx.Name, map[string]string{"tailscale.com/proxy-group": pg.Name})
|
||||
name := generateName("test-ingress")
|
||||
ingress := l7Ingress(ns, name, map[string]string{"tailscale.com/proxy-group": "ingress"})
|
||||
createAndCleanup(t, kubeClient, ingress)
|
||||
|
||||
t.Log("Waiting for the Ingress to be ready...")
|
||||
|
||||
hostname, err := waitForIngressHostname(t, ns, ingress.Name)
|
||||
hostname, err := waitForIngressHostname(t, ns, name)
|
||||
if err != nil {
|
||||
t.Fatalf("error waiting for Ingress hostname: %v", err)
|
||||
}
|
||||
@@ -278,88 +274,7 @@ func TestL7HAIngress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
func l7Ingress(namespace, name string, annotations map[string]string) *networkingv1.Ingress {
|
||||
ingress := &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
@@ -381,7 +296,7 @@ func l7Ingress(namespace, svc string, annotations map[string]string) *networking
|
||||
PathType: new(networkingv1.PathTypePrefix),
|
||||
Backend: networkingv1.IngressBackend{
|
||||
Service: &networkingv1.IngressServiceBackend{
|
||||
Name: svc,
|
||||
Name: "nginx",
|
||||
Port: networkingv1.ServiceBackendPort{
|
||||
Number: 80,
|
||||
},
|
||||
@@ -398,27 +313,26 @@ func l7Ingress(namespace, svc string, annotations map[string]string) *networking
|
||||
return ingress
|
||||
}
|
||||
|
||||
func nginxDeployment(namespace string) *appsv1.Deployment {
|
||||
name := generateName("nginx")
|
||||
func nginxDeployment(namespace, name string) *appsv1.Deployment {
|
||||
return &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Labels: map[string]string{
|
||||
"app.kubernetes.io/name": name,
|
||||
"app.kubernetes.io/name": "nginx",
|
||||
},
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Replicas: new(int32(1)),
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app.kubernetes.io/name": name,
|
||||
"app.kubernetes.io/name": "nginx",
|
||||
},
|
||||
},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app.kubernetes.io/name": name,
|
||||
"app.kubernetes.io/name": "nginx",
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
@@ -492,56 +406,6 @@ func testIngressIsReachable(t *testing.T, httpClient *http.Client, url string) e
|
||||
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) {
|
||||
t.Helper()
|
||||
var hostname string
|
||||
|
||||
@@ -54,7 +54,7 @@ func createAndCleanup(t *testing.T, cl client.Client, obj client.Object) {
|
||||
t.Cleanup(func() {
|
||||
// Use context.Background() for cleanup, as t.Context() is cancelled
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
@@ -69,7 +69,7 @@ func createAndCleanupErr(t *testing.T, cl client.Client, obj client.Object) erro
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
+23
-192
@@ -4,7 +4,6 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
@@ -40,7 +39,6 @@ import (
|
||||
"helm.sh/helm/v3/pkg/release"
|
||||
"helm.sh/helm/v3/pkg/storage/driver"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/rest"
|
||||
@@ -72,12 +70,9 @@ const (
|
||||
|
||||
var (
|
||||
tsClient *tailscale.Client // For API calls to control.
|
||||
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.
|
||||
tnClient *tsnet.Server // For testing real tailnet traffic.
|
||||
restCfg *rest.Config // For constructing a client-go client if necessary.
|
||||
kubeClient client.WithWatch // For k8s API calls.
|
||||
clusterLoginServer string
|
||||
|
||||
//go:embed certs/pebble.minica.crt
|
||||
pebbleMiniCACert []byte
|
||||
@@ -162,11 +157,11 @@ func runTests(m *testing.M) (int, error) {
|
||||
}
|
||||
|
||||
var (
|
||||
clientID, clientSecret string // OAuth client for the first tailnet (for the operator to use).
|
||||
clusterLoginServer string // Login server from cluster Pod point of view.
|
||||
clientID, clientSecret string // OAuth client for the operator to use.
|
||||
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.
|
||||
secondClientID, secondClientSecret string // OAuth client for the second tailnet (for the operator to use).
|
||||
)
|
||||
if *fDevcontrol {
|
||||
// Deploy pebble and get its certs.
|
||||
@@ -284,7 +279,7 @@ func runTests(m *testing.M) (int, error) {
|
||||
return 0, fmt.Errorf("failed to set policy file: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("ACLs configured for first tailnet")
|
||||
logger.Infof("ACLs configured")
|
||||
|
||||
key, err := tsClient.Keys().CreateOAuthClient(ctx, tailscale.CreateOAuthClientRequest{
|
||||
Scopes: []string{"auth_keys", "devices:core", "services"},
|
||||
@@ -292,77 +287,36 @@ func runTests(m *testing.M) (int, error) {
|
||||
Description: "k8s-operator client for e2e tests",
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create OAuth client for first tailnet: %w", err)
|
||||
return 0, fmt.Errorf("failed to marshal OAuth client creation request: %w", err)
|
||||
}
|
||||
|
||||
clientID = key.ID
|
||||
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 {
|
||||
clientSecret = os.Getenv("TS_API_CLIENT_SECRET")
|
||||
if clientSecret == "" {
|
||||
return 0, fmt.Errorf("must use --devcontrol or set TS_API_CLIENT_SECRET to an OAuth client suitable for the operator")
|
||||
}
|
||||
clientID, err = clientIDFromSecret(clientSecret)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get client id from secret: %w", err)
|
||||
// Format is "tskey-client-<id>-<random>".
|
||||
parts := strings.Split(clientSecret, "-")
|
||||
if len(parts) != 4 {
|
||||
return 0, fmt.Errorf("TS_API_CLIENT_SECRET is not valid")
|
||||
}
|
||||
tsClient, err = tailscaleClientFromSecret(ctx, ipn.DefaultControlURL, clientID, clientSecret)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to set up first tailnet client: %w", err)
|
||||
clientID = parts[2]
|
||||
credentials := clientcredentials.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
TokenURL: fmt.Sprintf("%s/api/v2/oauth/token", ipn.DefaultControlURL),
|
||||
Scopes: []string{"auth_keys"},
|
||||
}
|
||||
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)
|
||||
tk, err := credentials.Token(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get client id from secret: %w", err)
|
||||
return 0, fmt.Errorf("failed to get OAuth token: %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)
|
||||
// An access token will last for an hour which is plenty of time for
|
||||
// the tests to run. No need for token refresh logic.
|
||||
tsClient = &tailscale.Client{
|
||||
APIKey: tk.AccessToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,16 +446,10 @@ func runTests(m *testing.M) (int, error) {
|
||||
|
||||
authKey, err := tsClient.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create auth key for first tailnet: %w", err)
|
||||
return 0, err
|
||||
}
|
||||
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{
|
||||
ControlURL: tsClient.BaseURL.String(),
|
||||
Hostname: "test-proxy",
|
||||
@@ -515,64 +463,9 @@ func runTests(m *testing.M) (int, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
hist := action.NewHistory(cfg)
|
||||
hist.Max = 1
|
||||
@@ -831,65 +724,3 @@ func buildImage(ctx context.Context, dir, repo, target, tag string, extraCACerts
|
||||
|
||||
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,7 +20,6 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
"tailscale.com/kube/egressservices"
|
||||
)
|
||||
|
||||
@@ -91,7 +90,7 @@ func (er *egressEpsReconciler) Reconcile(ctx context.Context, req reconcile.Requ
|
||||
lg.Debugf("No egress config found, likely because ProxyGroup has not been created")
|
||||
return res, nil
|
||||
}
|
||||
cfg, ok := cfgs[tailnetSvc]
|
||||
cfg, ok := (*cfgs)[tailnetSvc]
|
||||
if !ok {
|
||||
lg.Infof("[unexpected] configuration for tailnet service %s not found", tailnetSvc)
|
||||
return res, nil
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/egressservices"
|
||||
@@ -348,11 +347,11 @@ func (esr *egressSvcsReconciler) provision(ctx context.Context, proxyGroupName s
|
||||
return nil, false, nil
|
||||
}
|
||||
tailnetSvc := tailnetSvcName(svc)
|
||||
gotCfg := cfgs[tailnetSvc]
|
||||
gotCfg := (*cfgs)[tailnetSvc]
|
||||
wantsCfg := egressSvcCfg(svc, clusterIPSvc, esr.tsNamespace, lg)
|
||||
if !reflect.DeepEqual(gotCfg, wantsCfg) {
|
||||
lg.Debugf("updating egress services ConfigMap %s", cm.Name)
|
||||
mak.Set(&cfgs, tailnetSvc, wantsCfg)
|
||||
mak.Set(cfgs, tailnetSvc, wantsCfg)
|
||||
bs, err := json.Marshal(cfgs)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("error marshalling egress services configs: %w", err)
|
||||
@@ -486,19 +485,19 @@ func (esr *egressSvcsReconciler) ensureEgressSvcCfgDeleted(ctx context.Context,
|
||||
lggr.Debugf("ConfigMap does not contain egress service configs")
|
||||
return nil
|
||||
}
|
||||
cfgs := egressservices.Configs{}
|
||||
if err := json.Unmarshal(bs, &cfgs); err != nil {
|
||||
cfgs := &egressservices.Configs{}
|
||||
if err := json.Unmarshal(bs, cfgs); err != nil {
|
||||
return fmt.Errorf("error unmarshalling egress services configs")
|
||||
}
|
||||
tailnetSvc := tailnetSvcName(svc)
|
||||
_, ok := cfgs[tailnetSvc]
|
||||
_, ok := (*cfgs)[tailnetSvc]
|
||||
if !ok {
|
||||
lggr.Debugf("ConfigMap does not contain egress service config, likely because it was already deleted")
|
||||
return nil
|
||||
}
|
||||
lggr.Infof("before deleting config %+#v", cfgs)
|
||||
delete(cfgs, tailnetSvc)
|
||||
lggr.Infof("after deleting config %+#v", cfgs)
|
||||
lggr.Infof("before deleting config %+#v", *cfgs)
|
||||
delete(*cfgs, tailnetSvc)
|
||||
lggr.Infof("after deleting config %+#v", *cfgs)
|
||||
bs, err := json.Marshal(cfgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling egress services configs: %w", err)
|
||||
@@ -650,7 +649,7 @@ func isEgressSvcForProxyGroup(obj client.Object) bool {
|
||||
|
||||
// egressSvcConfig returns a ConfigMap that contains egress services configuration for the provided ProxyGroup as well
|
||||
// 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)
|
||||
cm = &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
@@ -665,9 +664,9 @@ func egressSvcsConfigs(ctx context.Context, cl client.Client, proxyGroupName, ts
|
||||
if err != nil {
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/tstest"
|
||||
@@ -285,11 +284,11 @@ func configFromCM(t *testing.T, cm *corev1.ConfigMap, svcName string) *egressser
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cfgs := egressservices.Configs{}
|
||||
if err := json.Unmarshal(cfgBs, &cfgs); err != nil {
|
||||
cfgs := &egressservices.Configs{}
|
||||
if err := json.Unmarshal(cfgBs, cfgs); err != nil {
|
||||
t.Fatalf("error unmarshalling config: %v", err)
|
||||
}
|
||||
cfg, ok := cfgs[svcName]
|
||||
cfg, ok := (*cfgs)[svcName]
|
||||
if ok {
|
||||
return &cfg
|
||||
}
|
||||
|
||||
@@ -1081,7 +1081,7 @@ func certResourceLabels(pgName, domain string) map[string]string {
|
||||
return map[string]string{
|
||||
kubetypes.LabelManaged: "true",
|
||||
labelProxyGroup: pgName,
|
||||
labelDomain: tsoperator.TruncateLabelValue(domain),
|
||||
labelDomain: domain,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
kube "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
)
|
||||
@@ -228,13 +227,13 @@ func metricsResourceLabels(opts *metricsOpts) map[string]string {
|
||||
kubetypes.LabelManaged: "true",
|
||||
labelMetricsTarget: opts.proxyStsName,
|
||||
labelPromProxyType: opts.proxyType,
|
||||
labelPromProxyParentName: kube.TruncateLabelValue(opts.proxyLabels[LabelParentName]),
|
||||
labelPromProxyParentName: opts.proxyLabels[LabelParentName],
|
||||
}
|
||||
// Include namespace label for proxies created for a namespaced type.
|
||||
if isNamespacedProxyType(opts.proxyType) {
|
||||
lbls[labelPromProxyParentNamespace] = kube.TruncateLabelValue(opts.proxyLabels[LabelParentNamespace])
|
||||
lbls[labelPromProxyParentNamespace] = opts.proxyLabels[LabelParentNamespace]
|
||||
}
|
||||
lbls[labelPromJob] = kube.TruncateLabelValue(promJobName(opts))
|
||||
lbls[labelPromJob] = promJobName(opts)
|
||||
return lbls
|
||||
}
|
||||
|
||||
@@ -251,11 +250,11 @@ func promJobName(opts *metricsOpts) string {
|
||||
func metricsSvcSelector(proxyLabels map[string]string, proxyType string) map[string]string {
|
||||
sel := map[string]string{
|
||||
labelPromProxyType: proxyType,
|
||||
labelPromProxyParentName: kube.TruncateLabelValue(proxyLabels[LabelParentName]),
|
||||
labelPromProxyParentName: proxyLabels[LabelParentName],
|
||||
}
|
||||
// Include namespace label for proxies created for a namespaced type.
|
||||
if isNamespacedProxyType(proxyType) {
|
||||
sel[labelPromProxyParentNamespace] = kube.TruncateLabelValue(proxyLabels[LabelParentNamespace])
|
||||
sel[labelPromProxyParentNamespace] = proxyLabels[LabelParentNamespace]
|
||||
}
|
||||
return sel
|
||||
}
|
||||
|
||||
@@ -190,8 +190,6 @@ func (a *NameserverReconciler) maybeProvision(ctx context.Context, tsDNSCfg *tsa
|
||||
}
|
||||
if tsDNSCfg.Spec.Nameserver.Pod != nil {
|
||||
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} {
|
||||
@@ -227,8 +225,6 @@ type deployConfig struct {
|
||||
namespace string
|
||||
clusterIP string
|
||||
tolerations []corev1.Toleration
|
||||
affinity *corev1.Affinity
|
||||
nodeSelector map[string]string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -254,8 +250,6 @@ var (
|
||||
d.ObjectMeta.Labels = cfg.labels
|
||||
d.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||
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) {
|
||||
oldD.Spec = d.Spec
|
||||
}
|
||||
|
||||
@@ -43,9 +43,6 @@ func TestNameserverReconciler(t *testing.T) {
|
||||
ClusterIP: "5.4.3.2",
|
||||
},
|
||||
Pod: &tsapi.NameserverPod{
|
||||
NodeSelector: map[string]string{
|
||||
"foo": "bar",
|
||||
},
|
||||
Tolerations: []corev1.Toleration{
|
||||
{
|
||||
Key: "some-key",
|
||||
@@ -54,23 +51,6 @@ func TestNameserverReconciler(t *testing.T) {
|
||||
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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -117,26 +97,6 @@ func TestNameserverReconciler(t *testing.T) {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -698,8 +698,6 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
log: opts.log.Named("recorder-reconciler"),
|
||||
clock: tstime.DefaultClock{},
|
||||
clients: clients,
|
||||
authKeyRateLimits: make(map[string]*rate.Limiter),
|
||||
authKeyReissuing: make(map[string]bool),
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create Recorder reconciler: %v", err)
|
||||
|
||||
@@ -1160,9 +1160,6 @@ func (r *ProxyGroupReconciler) ensureStateRemovedForProxyGroup(pg *tsapi.ProxyGr
|
||||
gaugeIngressProxyGroupResources.Set(int64(r.ingressProxyGroups.Len()))
|
||||
gaugeAPIServerProxyGroupResources.Set(int64(r.apiServerProxyGroups.Len()))
|
||||
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) {
|
||||
|
||||
+15
-125
@@ -14,11 +14,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
xslices "golang.org/x/exp/slices"
|
||||
"golang.org/x/time/rate"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
@@ -64,8 +62,7 @@ type RecorderReconciler struct {
|
||||
clock tstime.Clock
|
||||
clients ClientProvider
|
||||
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
|
||||
recorders set.Slice[types.UID] // for recorders gauge
|
||||
}
|
||||
@@ -167,23 +164,9 @@ func (r *RecorderReconciler) Reconcile(ctx context.Context, req reconcile.Reques
|
||||
func (r *RecorderReconciler) maybeProvision(ctx context.Context, tsClient tsclient.Client, tsr *tsapi.Recorder) error {
|
||||
logger := r.logger(tsr.Name)
|
||||
|
||||
var replicas int32 = 1
|
||||
if tsr.Spec.Replicas != nil {
|
||||
replicas = *tsr.Spec.Replicas
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.recorders.Add(tsr.UID)
|
||||
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()
|
||||
|
||||
if err := r.ensureAuthSecretsCreated(ctx, tsClient, tsr); err != nil {
|
||||
@@ -191,6 +174,11 @@ 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.
|
||||
var replicas int32 = 1
|
||||
if tsr.Spec.Replicas != nil {
|
||||
replicas = *tsr.Spec.Replicas
|
||||
}
|
||||
|
||||
for replica := range replicas {
|
||||
sec := tsrStateSecret(tsr, r.tsNamespace, replica)
|
||||
_, err := createOrUpdate(ctx, r.Client, r.tsNamespace, sec, func(s *corev1.Secret) {
|
||||
@@ -435,10 +423,6 @@ func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Record
|
||||
r.mu.Lock()
|
||||
r.recorders.Remove(tsr.UID)
|
||||
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()
|
||||
|
||||
return true, nil
|
||||
@@ -463,119 +447,25 @@ func (r *RecorderReconciler) ensureAuthSecretsCreated(ctx context.Context, tsCli
|
||||
Name: fmt.Sprintf("%s-auth-%d", tsr.Name, replica),
|
||||
}
|
||||
|
||||
existingSecret := &corev1.Secret{}
|
||||
err := r.Get(ctx, key, existingSecret)
|
||||
err := r.Get(ctx, key, &corev1.Secret{})
|
||||
switch {
|
||||
case err == nil:
|
||||
reissue, err := r.shouldReissueAuthKey(ctx, tsClient, tsr, replica, existingSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking auth key reissue for replica %d: %w", replica, err)
|
||||
}
|
||||
if !reissue {
|
||||
logger.Debugf("auth Secret %q already exists, no reissue needed", key.Name)
|
||||
logger.Debugf("auth Secret %q already exists", key.Name)
|
||||
continue
|
||||
}
|
||||
authKey, err := newAuthKey(ctx, tsClient, tags.Stringify())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existingSecret.Data["authkey"] = []byte(authKey)
|
||||
if err = r.Update(ctx, existingSecret); err != nil {
|
||||
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:
|
||||
case !apierrors.IsNotFound(err):
|
||||
return fmt.Errorf("failed to get Secret %q: %w", key.Name, err)
|
||||
}
|
||||
|
||||
authKey, err := newAuthKey(ctx, tsClient, tags.Stringify())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
if err = r.Create(ctx, tsrAuthSecret(tsr, r.tsNamespace, authKey, replica)); err != nil {
|
||||
return 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
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/time/rate"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
@@ -62,8 +61,6 @@ func TestRecorder(t *testing.T) {
|
||||
recorder: fr,
|
||||
log: zl.Sugar(),
|
||||
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) {
|
||||
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"k8s.io/utils/strings/slices"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/cmd/k8s-proxy/internal/config"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store"
|
||||
@@ -42,7 +41,6 @@ import (
|
||||
"tailscale.com/kube/certs"
|
||||
healthz "tailscale.com/kube/health"
|
||||
"tailscale.com/kube/k8s-proxy/conf"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
klc "tailscale.com/kube/localclient"
|
||||
"tailscale.com/kube/metrics"
|
||||
@@ -173,31 +171,10 @@ func run(logger *zap.SugaredLogger) error {
|
||||
|
||||
// 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.
|
||||
var kc kubeclient.Client
|
||||
var stateSecretName string
|
||||
if podUID != "" {
|
||||
if err := state.SetInitialKeys(st, podUID); err != nil {
|
||||
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
|
||||
@@ -220,69 +197,23 @@ func run(logger *zap.SugaredLogger) error {
|
||||
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()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting local client: %w", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Setup for updating state keys.
|
||||
if podUID != "" {
|
||||
group.Go(func() error {
|
||||
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) {
|
||||
@@ -431,8 +362,6 @@ func run(logger *zap.SugaredLogger) error {
|
||||
}
|
||||
|
||||
cfgLogger.Infof("Config reloaded")
|
||||
case <-reissueCh:
|
||||
return handleAuthKeyReissue(ctx, lc, kc, stateSecretName, authKey, cfgChan, logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+168
-8
@@ -9,13 +9,22 @@
|
||||
// git-pull-oss.sh having Nix available.
|
||||
package main
|
||||
|
||||
// For the format, see:
|
||||
// See https://gist.github.com/jbeda/5c79d2b1434f0018d693
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"tailscale.com/cmd/nardump/nardump"
|
||||
"path"
|
||||
"sort"
|
||||
)
|
||||
|
||||
var sri = flag.Bool("sri", false, "print SRI")
|
||||
@@ -25,16 +34,167 @@ func main() {
|
||||
if flag.NArg() != 1 {
|
||||
log.Fatal("usage: nardump <dir>")
|
||||
}
|
||||
fsys := os.DirFS(flag.Arg(0))
|
||||
if *sri {
|
||||
s, err := nardump.SRI(fsys)
|
||||
if err != nil {
|
||||
arg := flag.Arg(0)
|
||||
if err := os.Chdir(arg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(s)
|
||||
if *sri {
|
||||
hash := sha256.New()
|
||||
if err := writeNAR(hash, os.DirFS(".")); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("sha256-%s\n", base64.StdEncoding.EncodeToString(hash.Sum(nil)))
|
||||
return
|
||||
}
|
||||
if err := nardump.WriteNAR(os.Stdout, fsys); err != nil {
|
||||
bw := bufio.NewWriter(os.Stdout)
|
||||
if err := writeNAR(bw, os.DirFS(".")); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// 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,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
if err = s.HandshakeContext(ctx); err != nil {
|
||||
if err = uptc.HandshakeContext(ctx); err != nil {
|
||||
p.errors.Add("client-tls", 1)
|
||||
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
|
||||
// in the self node's CapMap. We set NotifyInitialNetMap so the first
|
||||
// Notify carries the current self node (now via Notify.SelfChange);
|
||||
// subsequent self changes wake us up too.
|
||||
// in the netmap.
|
||||
// We set the NotifyInitialNetMap flag so we will always get woken with the
|
||||
// current netmap, before only being woken on changes.
|
||||
bus, err := lc.WatchIPNBus(ctx, ipn.NotifyWatchEngineUpdates|ipn.NotifyInitialNetMap)
|
||||
if err != nil {
|
||||
log.Fatalf("watching IPN bus: %v", err)
|
||||
@@ -155,13 +155,10 @@ func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, pro
|
||||
log.Fatalf("reading IPN bus: %v", err)
|
||||
}
|
||||
|
||||
self := msg.SelfChange
|
||||
if self == nil {
|
||||
continue
|
||||
}
|
||||
// NetMap contains app-connector configuration
|
||||
if nm := msg.NetMap; nm != nil && nm.SelfNode.Valid() {
|
||||
var c appctype.AppConnectorConfig
|
||||
// View() lets us reuse the existing CapView decoder.
|
||||
nmConf, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorConfig](self.View().CapMap(), configCapKey)
|
||||
nmConf, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorConfig](nm.SelfNode.CapMap(), configCapKey)
|
||||
if err != nil {
|
||||
log.Printf("failed to read app connector configuration from coordination server: %v", err)
|
||||
} else if len(nmConf) > 0 {
|
||||
@@ -181,6 +178,7 @@ func run(ctx context.Context, ts *tsnet.Server, wgPort int, hostname string, pro
|
||||
s.srv.Configure(&c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type sniproxy struct {
|
||||
srv Server
|
||||
|
||||
@@ -15,11 +15,9 @@ import (
|
||||
)
|
||||
|
||||
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() {
|
||||
flag.Parse()
|
||||
lc := &local.Client{Socket: *socket}
|
||||
systray.SetTheme(*theme)
|
||||
new(systray.Menu).Run(lc)
|
||||
}
|
||||
|
||||
+10
-42
@@ -28,7 +28,6 @@ import (
|
||||
"tailscale.com/feature"
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/util/slicesx"
|
||||
"tailscale.com/util/testenv"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
@@ -93,8 +92,8 @@ var localClient = local.Client{
|
||||
Socket: paths.DefaultTailscaledSocket(),
|
||||
}
|
||||
|
||||
// RunWithContext runs the CLI. The args do not include the binary name.
|
||||
func RunWithContext(ctx context.Context, args []string) (err error) {
|
||||
// Run runs the CLI. The args do not include the binary name.
|
||||
func Run(args []string) (err error) {
|
||||
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'.
|
||||
// Don't run the tailscale CLI and spam logs with usage; just exit.
|
||||
@@ -164,7 +163,7 @@ func RunWithContext(ctx context.Context, args []string) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
err = rootCmd.Run(ctx)
|
||||
err = rootCmd.Run(context.Background())
|
||||
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, " "))
|
||||
}
|
||||
@@ -174,11 +173,6 @@ func RunWithContext(ctx context.Context, args []string) (err error) {
|
||||
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 {
|
||||
flag.Value
|
||||
set bool
|
||||
@@ -200,39 +194,17 @@ func (v *onceFlagValue) IsBoolFlag() bool {
|
||||
return ok && bf.IsBoolFlag()
|
||||
}
|
||||
|
||||
// noDupFlagify modifies c recursively to make all the flag values be
|
||||
// wrappers that permit setting the value at most once. If tb is
|
||||
// non-nil, the original values are restored when the test completes.
|
||||
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) {
|
||||
// noDupFlagify modifies c recursively to make all the
|
||||
// flag values be wrappers that permit setting the value
|
||||
// at most once.
|
||||
func noDupFlagify(c *ffcli.Command) {
|
||||
if c.FlagSet != nil {
|
||||
c.FlagSet.VisitAll(func(f *flag.Flag) {
|
||||
if tb != nil {
|
||||
restores = append(restores, restore{f, f.Value})
|
||||
}
|
||||
f.Value = &onceFlagValue{Value: f.Value}
|
||||
})
|
||||
}
|
||||
for _, sub := range c.Subcommands {
|
||||
walk(sub)
|
||||
}
|
||||
}
|
||||
walk(c)
|
||||
if tb != nil {
|
||||
tb.Cleanup(func() {
|
||||
for _, r := range restores {
|
||||
r.f.Value = r.v
|
||||
}
|
||||
})
|
||||
noDupFlagify(sub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +221,7 @@ var (
|
||||
_ func() *ffcli.Command
|
||||
)
|
||||
|
||||
func newRootCmd(tb ...testenv.TB) *ffcli.Command {
|
||||
func newRootCmd() *ffcli.Command {
|
||||
rootfs := newFlagSet("tailscale")
|
||||
rootfs.Func("socket", "path to tailscaled socket", func(s string) error {
|
||||
localClient.Socket = s
|
||||
@@ -331,11 +303,7 @@ change in the future.
|
||||
})
|
||||
|
||||
ffcomplete.Inject(rootCmd, func(c *ffcli.Command) { c.LongHelp = hidden + c.LongHelp }, usageFunc)
|
||||
var t testenv.TB
|
||||
if len(tb) > 0 {
|
||||
t = tb[0]
|
||||
}
|
||||
noDupFlagify(rootCmd, t)
|
||||
noDupFlagify(rootCmd)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
|
||||
@@ -779,43 +779,11 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
||||
wantErr: `--exit-node-allow-lan-access can only be used with --exit-node`,
|
||||
},
|
||||
{
|
||||
name: "error_tag_bad_prefix",
|
||||
name: "error_tag_prefix",
|
||||
args: upArgsT{
|
||||
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,
|
||||
},
|
||||
advertiseTags: "foo",
|
||||
},
|
||||
wantErr: `tag: "foo": tags must start with 'tag:'`,
|
||||
},
|
||||
{
|
||||
name: "error_long_hostname",
|
||||
@@ -1650,7 +1618,7 @@ func TestNoDups(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newRootCmd(t)
|
||||
cmd := newRootCmd()
|
||||
makeQuietContinueOnError(cmd)
|
||||
err := cmd.Parse(tt.args)
|
||||
if got := fmt.Sprint(err); got != tt.want {
|
||||
|
||||
@@ -20,8 +20,10 @@ import (
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"k8s.io/client-go/util/homedir"
|
||||
"sigs.k8s.io/yaml"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
@@ -96,12 +98,12 @@ func runConfigureKubeconfig(ctx context.Context, args []string) error {
|
||||
if st.BackendState != "Running" {
|
||||
return errors.New("Tailscale is not running")
|
||||
}
|
||||
dnsCfg, err := getDNSConfig(ctx)
|
||||
nm, err := getNetMap(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetFQDN, err := nodeOrServiceDNSNameFromArg(st, dnsCfg, hostOrFQDNOrIP)
|
||||
targetFQDN, err := nodeOrServiceDNSNameFromArg(st, nm, hostOrFQDNOrIP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -238,14 +240,14 @@ func setKubeconfigForPeer(scheme, fqdn, filePath string) error {
|
||||
// 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
|
||||
// an IP. If none is found, it looks for a Tailscale Service
|
||||
func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, dns *tailcfg.DNSConfig, arg string) (string, error) {
|
||||
func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, nm *netmap.NetworkMap, arg string) (string, error) {
|
||||
// First check for a node DNS name.
|
||||
if dnsName, ok := nodeDNSNameFromArg(st, arg); ok {
|
||||
return dnsName, nil
|
||||
}
|
||||
|
||||
// If not found, check for a Tailscale Service DNS name.
|
||||
rec, ok := serviceDNSRecordFromDNSConfig(dns, arg)
|
||||
rec, ok := serviceDNSRecordFromNetMap(nm, arg)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no peer found for %q", arg)
|
||||
}
|
||||
@@ -267,13 +269,25 @@ func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, dns *tailcfg.DNSConfig, ar
|
||||
return "", fmt.Errorf("%q is in MagicDNS, but is not currently reachable on any known peer", arg)
|
||||
}
|
||||
|
||||
func getDNSConfig(ctx context.Context) (*tailcfg.DNSConfig, error) {
|
||||
func getNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
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
|
||||
}
|
||||
|
||||
func serviceDNSRecordFromDNSConfig(dns *tailcfg.DNSConfig, arg string) (rec tailcfg.DNSRecord, ok bool) {
|
||||
return n.NetMap, nil
|
||||
}
|
||||
|
||||
func serviceDNSRecordFromNetMap(nm *netmap.NetworkMap, arg string) (rec tailcfg.DNSRecord, ok bool) {
|
||||
argIP, _ := netip.ParseAddr(arg)
|
||||
argFQDN, err := dnsname.ToFQDN(arg)
|
||||
argFQDNValid := err == nil
|
||||
@@ -281,7 +295,7 @@ func serviceDNSRecordFromDNSConfig(dns *tailcfg.DNSConfig, arg string) (rec tail
|
||||
return rec, false
|
||||
}
|
||||
|
||||
for _, rec := range dns.ExtraRecords {
|
||||
for _, rec := range nm.DNS.ExtraRecords {
|
||||
if argIP.IsValid() {
|
||||
recIP, _ := netip.ParseAddr(rec.Value)
|
||||
if recIP == argIP {
|
||||
|
||||
@@ -18,7 +18,7 @@ func init() {
|
||||
maybeSystrayCmd = systrayConfigCmd
|
||||
}
|
||||
|
||||
var configSystrayArgs struct {
|
||||
var systrayArgs struct {
|
||||
initSystem string
|
||||
installStartup bool
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func systrayConfigCmd() *ffcli.Command {
|
||||
Exec: configureSystray,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("systray")
|
||||
fs.StringVar(&configSystrayArgs.initSystem, "enable-startup", "",
|
||||
fs.StringVar(&systrayArgs.initSystem, "enable-startup", "",
|
||||
"Install startup script for init system. Currently supported systems are [systemd, freedesktop].")
|
||||
return fs
|
||||
})(),
|
||||
@@ -40,8 +40,8 @@ func systrayConfigCmd() *ffcli.Command {
|
||||
}
|
||||
|
||||
func configureSystray(_ context.Context, _ []string) error {
|
||||
if configSystrayArgs.initSystem != "" {
|
||||
if err := systray.InstallStartupScript(configSystrayArgs.initSystem); err != nil {
|
||||
if systrayArgs.initSystem != "" {
|
||||
if err := systray.InstallStartupScript(systrayArgs.initSystem); err != nil {
|
||||
fmt.Printf("%s\n\n", err.Error())
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
@@ -670,11 +670,18 @@ func runNetmap(ctx context.Context, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
raw, err := localClient.DebugResultJSON(ctx, "current-netmap")
|
||||
var mask ipn.NotifyWatchOpt = ipn.NotifyInitialNetMap
|
||||
watcher, err := localClient.WatchIPNBus(ctx, mask)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
j, _ := json.MarshalIndent(raw, "", "\t")
|
||||
defer watcher.Close()
|
||||
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
j, _ := json.MarshalIndent(n.NetMap, "", "\t")
|
||||
fmt.Printf("%s\n", j)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/cmd/tailscale/cli/jsonoutput"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
var dnsStatusCmd = &ffcli.Command{
|
||||
@@ -118,10 +120,11 @@ func runDNSStatus(ctx context.Context, args []string) error {
|
||||
SelfDNSName: s.Self.DNSName,
|
||||
}
|
||||
|
||||
dnsConfig, err := localClient.DNSConfig(ctx)
|
||||
netMap, err := fetchNetMap()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch DNS config: %w", err)
|
||||
return fmt.Errorf("failed to fetch network map: %w", err)
|
||||
}
|
||||
dnsConfig := netMap.DNS
|
||||
|
||||
for _, r := range dnsConfig.Resolvers {
|
||||
data.Resolvers = append(data.Resolvers, makeDNSResolverInfo(r))
|
||||
@@ -354,3 +357,19 @@ 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")
|
||||
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
|
||||
}
|
||||
|
||||
+15
-184
@@ -32,7 +32,6 @@ import (
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/cmd/tailscale/cli/ffcomplete"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -79,7 +78,6 @@ var fileCpCmd = &ffcli.Command{
|
||||
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.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
|
||||
})(),
|
||||
}
|
||||
@@ -88,7 +86,6 @@ var cpArgs struct {
|
||||
name string
|
||||
verbose bool
|
||||
targets bool
|
||||
updateInterval time.Duration
|
||||
}
|
||||
|
||||
func runCp(ctx context.Context, args []string) error {
|
||||
@@ -122,6 +119,9 @@ func runCp(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
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 cpArgs.name != "" {
|
||||
@@ -132,51 +132,7 @@ func runCp(ctx context.Context, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
for _, fileArg := range files {
|
||||
var fileContents *countingReader
|
||||
var name = cpArgs.name
|
||||
var contentLength int64 = -1
|
||||
@@ -219,57 +175,16 @@ func runCp(ctx context.Context, args []string) error {
|
||||
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
|
||||
ctxProgress, cancelProgress := context.WithCancel(ctx)
|
||||
defer cancelProgress()
|
||||
if cpArgs.updateInterval > 0 && isatty.IsTerminal(os.Stderr.Fd()) {
|
||||
group.Go(func() {
|
||||
progressPrinter(ctxProgress, name, ps.sent.Load, contentLength, cpArgs.updateInterval)
|
||||
})
|
||||
if isatty.IsTerminal(os.Stderr.Fd()) {
|
||||
group.Go(func() { progressPrinter(ctxProgress, name, fileContents.n.Load, contentLength) })
|
||||
}
|
||||
|
||||
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()
|
||||
group.Wait() // wait for progress printer to stop before reporting the error
|
||||
if ps.warnTimer != nil {
|
||||
ps.warnTimer.Stop()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -280,71 +195,15 @@ func runCp(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func progressPrinter(ctx context.Context, name string, contentCount func() int64, contentLength int64) {
|
||||
var rateValueFast, rateValueSlow tsrate.Value
|
||||
// tailscaled emits OutgoingFile.Sent updates at ~1 Hz, so most printer
|
||||
// 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
|
||||
rateValueFast.HalfLife = 1 * time.Second // fast response for rate measurement
|
||||
rateValueSlow.HalfLife = 10 * time.Second // slow response for ETA measurement
|
||||
var prevContentCount int64
|
||||
print := func() {
|
||||
currContentCount := contentCount()
|
||||
// Clamp so a regression (which shouldn't happen, but tsrate.Value.Add
|
||||
// panics on a negative count) can't take down the CLI.
|
||||
delta := max(currContentCount-prevContentCount, 0)
|
||||
rateValueFast.Add(float64(delta))
|
||||
rateValueSlow.Add(float64(delta))
|
||||
rateValueFast.Add(float64(currContentCount - prevContentCount))
|
||||
rateValueSlow.Add(float64(currContentCount - prevContentCount))
|
||||
prevContentCount = currContentCount
|
||||
|
||||
const vtRestartLine = "\r\x1b[K"
|
||||
@@ -356,23 +215,16 @@ func progressPrinter(ctx context.Context, name string, contentCount func() int64
|
||||
if contentLength >= 0 {
|
||||
currContentCount = min(currContentCount, contentLength) // cap at 100%
|
||||
ratioRemain := float64(currContentCount) / float64(contentLength)
|
||||
etaStr := "ETA -"
|
||||
if rate := rateValueSlow.Rate(); rate > 0 {
|
||||
bytesRemain := float64(contentLength - currContentCount)
|
||||
secsRemain := bytesRemain / rate
|
||||
secsRemain := bytesRemain / rateValueSlow.Rate()
|
||||
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",
|
||||
leftPad(fmt.Sprintf("%0.2f%%", 100.0*ratioRemain), len("100.00%")),
|
||||
etaStr)
|
||||
fmt.Sprintf("ETA %02d:%02d:%02d", secs/60/60, (secs/60)%60, secs%60))
|
||||
}
|
||||
}
|
||||
|
||||
const stuckAfter = 2 * time.Second
|
||||
var fullStartedAt time.Time // when we first observed currCount==contentLength with ~zero rate
|
||||
|
||||
tc := time.NewTicker(interval)
|
||||
tc := time.NewTicker(250 * time.Millisecond)
|
||||
defer tc.Stop()
|
||||
print()
|
||||
for {
|
||||
@@ -383,24 +235,6 @@ func progressPrinter(ctx context.Context, name string, contentCount func() int64
|
||||
return
|
||||
case <-tc.C:
|
||||
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{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,10 +328,7 @@ peerLoop:
|
||||
return "", isOffline, errors.New("cannot send files: missing required Taildrop capability")
|
||||
|
||||
case ipnstate.TaildropTargetOffline:
|
||||
// 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
|
||||
return "", isOffline, errors.New("cannot send files: peer is offline")
|
||||
|
||||
case ipnstate.TaildropTargetNoPeerInfo:
|
||||
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
|
||||
// the public components of a key known to tailnet-lock.
|
||||
// the public components of a key known to network-lock.
|
||||
type tkaKeyV1 struct {
|
||||
Kind string `json:"Kind,omitzero"`
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ type tailnetLockStatusV1Base struct {
|
||||
// Enabled is true if Tailnet Lock is enabled.
|
||||
Enabled bool
|
||||
|
||||
// PublicKey describes the node's tailnet-lock public key.
|
||||
// PublicKey describes the node's network-lock public key.
|
||||
PublicKey string `json:"PublicKey,omitzero"`
|
||||
|
||||
// NodeKey describes the node's current node-key. This field is not
|
||||
@@ -144,7 +144,7 @@ type tailnetLockEnabledStatusV1 struct {
|
||||
NodeKeySignature *tkaNodeKeySignatureV1
|
||||
|
||||
// TrustedKeys describes the keys currently trusted to make changes
|
||||
// to tailnet-lock.
|
||||
// to network-lock.
|
||||
TrustedKeys []tkaKeyV1
|
||||
|
||||
// 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)
|
||||
return err
|
||||
}
|
||||
if self := n.SelfChange; self != nil {
|
||||
if nm := n.NetMap; nm != nil && nm.SelfNode.Valid() {
|
||||
gotAll := true
|
||||
for _, c := range caps {
|
||||
if _, has := self.CapMap[c]; !has {
|
||||
if !nm.SelfNode.HasCap(c) {
|
||||
// The feature is not yet enabled.
|
||||
// Continue blocking until it is.
|
||||
gotAll = false
|
||||
|
||||
@@ -7,7 +7,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/systray"
|
||||
@@ -18,20 +17,10 @@ var systrayCmd = &ffcli.Command{
|
||||
ShortUsage: "tailscale systray",
|
||||
ShortHelp: "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,
|
||||
}
|
||||
|
||||
var systrayArgs struct {
|
||||
theme string
|
||||
}
|
||||
|
||||
func runSystray(ctx context.Context, _ []string) error {
|
||||
systray.SetTheme(systrayArgs.theme)
|
||||
new(systray.Menu).Run(&localClient)
|
||||
return nil
|
||||
}
|
||||
|
||||
+6
-12
@@ -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.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.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.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.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.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.postureChecking, "report-posture", false, "allow management plane to gather device posture information")
|
||||
upf.BoolVar(&upArgs.postureChecking, "report-posture", false, hidden+"allow management plane to gather device posture information")
|
||||
|
||||
if safesocket.GOOSUsesPeerCreds(goos) {
|
||||
upf.StringVar(&upArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
|
||||
@@ -309,15 +309,9 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
|
||||
var tags []string
|
||||
if upArgs.advertiseTags != "" {
|
||||
tags = strings.Split(upArgs.advertiseTags, ",")
|
||||
for i, tag := range tags {
|
||||
// Allow users to omit the "tag:" prefix; if the tag has no
|
||||
// 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 {
|
||||
for _, tag := range tags {
|
||||
err := tailcfg.CheckTag(tag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tag: %q: %s", tag, err)
|
||||
}
|
||||
}
|
||||
@@ -732,7 +726,7 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE
|
||||
if s := n.State; s != nil {
|
||||
ipnIsRunning = *s == ipn.Running
|
||||
}
|
||||
if n.SelfChange != nil && n.SelfChange.Key != origNodeKey {
|
||||
if n.NetMap != nil && n.NetMap.NodeKey != origNodeKey {
|
||||
waitingForKeyChange = false
|
||||
}
|
||||
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/mono from tailscale.com/tstime/rate
|
||||
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/types/appctype from tailscale.com/client/local+
|
||||
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/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/socket from golang.org/x/net/ipv4+
|
||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
||||
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
||||
golang.org/x/net/ipv4 from 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/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/socket from golang.org/x/net/ipv4+
|
||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
||||
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/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/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/socket from golang.org/x/net/ipv4+
|
||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
||||
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/sync/errgroup from github.com/mdlayher/socket
|
||||
|
||||
+10
-12
@@ -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/internal/parseexprfunc from github.com/google/nftables+
|
||||
L github.com/google/nftables/xt from github.com/google/nftables/expr+
|
||||
W github.com/google/uuid from tailscale.com/clientupdate
|
||||
DW github.com/google/uuid from tailscale.com/clientupdate+
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
|
||||
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
|
||||
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
||||
@@ -173,8 +173,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
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/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+
|
||||
DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
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/internal/fs from github.com/tailscale/go-winio
|
||||
@@ -258,7 +259,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/client/web from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/clientupdate from tailscale.com/feature/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/tailscaledhooks from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
|
||||
@@ -303,12 +303,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
|
||||
tailscale.com/feature/posture 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
|
||||
LD tailscale.com/feature/ssh from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/feature/syspolicy 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
|
||||
tailscale.com/feature/tpm from tailscale.com/feature/condregister
|
||||
L 💣 tailscale.com/feature/tundevstats from tailscale.com/feature/condregister
|
||||
@@ -404,7 +402,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/tstime from tailscale.com/control/controlclient+
|
||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
||||
tailscale.com/tsweb from tailscale.com/util/eventbus+
|
||||
tailscale.com/tsweb from tailscale.com/util/eventbus
|
||||
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/types/bools from tailscale.com/wgengine/netlog
|
||||
@@ -527,13 +525,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/http/httpguts from tailscale.com/ipn/ipnlocal
|
||||
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/internal/iana from golang.org/x/net/icmp+
|
||||
golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
|
||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
||||
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
||||
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
|
||||
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
|
||||
golang.org/x/net/ipv4 from github.com/prometheus-community/pro-bing+
|
||||
golang.org/x/net/ipv6 from github.com/prometheus-community/pro-bing+
|
||||
golang.org/x/net/proxy from tailscale.com/net/netns
|
||||
D golang.org/x/net/route from tailscale.com/net/netmon+
|
||||
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
|
||||
@@ -644,7 +642,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
crypto/x509 from crypto/tls+
|
||||
D crypto/x509/internal/macos from crypto/x509
|
||||
crypto/x509/pkix from crypto/x509+
|
||||
W database/sql/driver from github.com/google/uuid
|
||||
DW database/sql/driver from github.com/google/uuid
|
||||
W debug/dwarf from debug/pe
|
||||
W debug/pe from github.com/dblohm7/wingoes/pe
|
||||
embed from github.com/tailscale/web-client-prebuilt+
|
||||
@@ -734,7 +732,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
mime/quotedprintable from mime/multipart
|
||||
net from crypto/tls+
|
||||
net/http from expvar+
|
||||
net/http/httptrace from github.com/aws/smithy-go/transport/http+
|
||||
net/http/httptrace from github.com/prometheus-community/pro-bing+
|
||||
net/http/httputil from github.com/aws/smithy-go/transport/http+
|
||||
net/http/internal from net/http+
|
||||
net/http/internal/ascii from net/http+
|
||||
|
||||
@@ -202,19 +202,6 @@ func TestOmitPortlist(t *testing.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) {
|
||||
deptest.DepChecker{
|
||||
GOOS: "linux",
|
||||
|
||||
@@ -828,6 +828,7 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
|
||||
if err != nil {
|
||||
return onlyNetstack, err
|
||||
}
|
||||
e = wgengine.NewWatchdog(e)
|
||||
sys.Set(e)
|
||||
sys.NetstackRouter.Set(netstackSubnetRouter)
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ func main() {
|
||||
if cached {
|
||||
lastCol = "(cached)"
|
||||
} else {
|
||||
lastCol = fmt.Sprintf("%.3fs", testDur.Seconds())
|
||||
lastCol = fmt.Sprintf("%.3f", testDur.Seconds())
|
||||
}
|
||||
fmt.Printf("%s\t%s\t%v\n", outcome, pkg, lastCol)
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ func buildWasm(dev bool) ([]byte, error) {
|
||||
// to fail for unclosed files.
|
||||
defer outputFile.Close()
|
||||
|
||||
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,nethttpomithttp2,omitidna,omitpemdecrypt"}
|
||||
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,omitidna,omitpemdecrypt"}
|
||||
if !dev {
|
||||
if *devControl != "" {
|
||||
return nil, fmt.Errorf("Development control URL can only be used in dev mode.")
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"sourceMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
"jsxImportSource": "preact",
|
||||
"types": ["golang-wasm-exec", "qrcode"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// This file bridges the Taildrop FileOps interface to JS callbacks,
|
||||
// using the same channel+FuncOf pattern as the Go stdlib's WASM HTTP
|
||||
// transport (src/net/http/roundtrip_js.go): Go passes a js.FuncOf to JS,
|
||||
// then blocks on a channel until JS calls it back — which may be async.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/feature/taildrop"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/progresstracking"
|
||||
"tailscale.com/util/rands"
|
||||
)
|
||||
|
||||
// Compile-time check that jsFileOps implements taildrop.FileOps.
|
||||
var _ taildrop.FileOps = (*jsFileOps)(nil)
|
||||
|
||||
// taildropExt returns the taildrop extension, or an error if unavailable.
|
||||
func (i *jsIPN) taildropExt() (*taildrop.Extension, error) {
|
||||
ext, ok := ipnlocal.GetExt[*taildrop.Extension](i.lb)
|
||||
if !ok {
|
||||
return nil, errors.New("taildrop extension not available")
|
||||
}
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// listFileTargets returns the peers that can receive Taildrop files as a JSON
|
||||
// array of {stableNodeID, name, addresses, os} objects.
|
||||
func (i *jsIPN) listFileTargets() js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
ext, err := i.taildropExt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fts, err := ext.FileTargets()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type jsTarget struct {
|
||||
StableNodeID string `json:"stableNodeID"`
|
||||
Name string `json:"name"`
|
||||
Addresses []string `json:"addresses"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
out := make([]jsTarget, 0, len(fts))
|
||||
for _, ft := range fts {
|
||||
addrs := make([]string, 0, len(ft.Node.Addresses))
|
||||
for _, a := range ft.Node.Addresses {
|
||||
addrs = append(addrs, a.Addr().String())
|
||||
}
|
||||
out = append(out, jsTarget{
|
||||
StableNodeID: string(ft.Node.StableID),
|
||||
Name: ft.Node.Name,
|
||||
Addresses: addrs,
|
||||
OS: ft.Node.Hostinfo.OS(),
|
||||
})
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(b), nil
|
||||
})
|
||||
}
|
||||
|
||||
// sendFile sends data as filename to the peer identified by stableNodeID,
|
||||
// reporting progress via notifyOutgoingFiles callbacks roughly once per second.
|
||||
func (i *jsIPN) sendFile(stableNodeID, filename string, data js.Value) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
ext, err := i.taildropExt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fts, err := ext.FileTargets()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ft *apitype.FileTarget
|
||||
for _, x := range fts {
|
||||
if x.Node.StableID == tailcfg.StableNodeID(stableNodeID) {
|
||||
ft = x
|
||||
break
|
||||
}
|
||||
}
|
||||
if ft == nil {
|
||||
return nil, fmt.Errorf("node %q not found or not a file target", stableNodeID)
|
||||
}
|
||||
dstURL, err := url.Parse(ft.PeerAPIURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bogus peer URL: %w", err)
|
||||
}
|
||||
b := make([]byte, data.Get("byteLength").Int())
|
||||
js.CopyBytesToGo(b, data)
|
||||
|
||||
outgoing := &ipn.OutgoingFile{
|
||||
ID: rands.HexString(30),
|
||||
PeerID: tailcfg.StableNodeID(stableNodeID),
|
||||
Name: filename,
|
||||
DeclaredSize: int64(len(b)),
|
||||
Started: time.Now(),
|
||||
}
|
||||
updates := map[string]*ipn.OutgoingFile{outgoing.ID: outgoing}
|
||||
|
||||
// Report final state (success or failure) when the function returns.
|
||||
var sendErr error
|
||||
defer func() {
|
||||
outgoing.Finished = true
|
||||
outgoing.Succeeded = sendErr == nil
|
||||
ext.UpdateOutgoingFiles(updates)
|
||||
}()
|
||||
|
||||
body := progresstracking.NewReader(bytes.NewReader(b), time.Second, func(n int, _ error) {
|
||||
outgoing.Sent = int64(n)
|
||||
ext.UpdateOutgoingFiles(updates)
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("PUT", dstURL.String()+"/v0/put/"+url.PathEscape(filename), body)
|
||||
if err != nil {
|
||||
sendErr = err
|
||||
return nil, err
|
||||
}
|
||||
req.ContentLength = int64(len(b))
|
||||
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
sendErr = err
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
sendErr = fmt.Errorf("send file: %s: %s", resp.Status, bytes.TrimSpace(respBody))
|
||||
return nil, sendErr
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// waitingFiles returns received files waiting for pickup as a JSON array of
|
||||
// {name, size} objects. Always returns an array (never null).
|
||||
func (i *jsIPN) waitingFiles() js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
ext, err := i.taildropExt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wfs, err := ext.WaitingFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type jsWaitingFile struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
out := make([]jsWaitingFile, len(wfs))
|
||||
for i, wf := range wfs {
|
||||
out[i] = jsWaitingFile{Name: wf.Name, Size: wf.Size}
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(b), nil
|
||||
})
|
||||
}
|
||||
|
||||
// openWaitingFile returns the contents of a received file as a Uint8Array.
|
||||
func (i *jsIPN) openWaitingFile(name string) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
ext, err := i.taildropExt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rc, _, err := ext.OpenFile(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf := js.Global().Get("Uint8Array").New(len(data))
|
||||
js.CopyBytesToJS(buf, data)
|
||||
return buf, nil
|
||||
})
|
||||
}
|
||||
|
||||
// deleteWaitingFile deletes a received file by name.
|
||||
func (i *jsIPN) deleteWaitingFile(name string) js.Value {
|
||||
return makePromise(func() (any, error) {
|
||||
ext, err := i.taildropExt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ext.DeleteFile(name)
|
||||
})
|
||||
}
|
||||
|
||||
// wireTaildropFileOps installs a JS-backed FileOps on the taildrop extension
|
||||
// if jsObj is a non-null JS object. It must be called after NewLocalBackend
|
||||
// and before lb.Start (i.e. before run() is called by the user), so that the
|
||||
// FileOps is in place when the extension's onChangeProfile hook fires on init.
|
||||
//
|
||||
// SetStagedFileOps is used instead of SetFileOps so that files are staged for
|
||||
// explicit retrieval via WaitingFiles/OpenFile rather than delivered directly
|
||||
// (DirectFileMode=false). The JS caller fetches them via waitingFiles() et al.
|
||||
func wireTaildropFileOps(lb *ipnlocal.LocalBackend, jsObj js.Value) {
|
||||
if jsObj.IsUndefined() || jsObj.IsNull() {
|
||||
return
|
||||
}
|
||||
ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ext.SetStagedFileOps(&jsFileOps{v: jsObj})
|
||||
}
|
||||
|
||||
// jsFileOps implements [taildrop.FileOps] by delegating to JS callbacks.
|
||||
// JS methods use one of two callback conventions:
|
||||
//
|
||||
// Void ops (openWriter, write, closeWriter, remove): cb(err?: string)
|
||||
//
|
||||
// on success: cb() or cb("")
|
||||
// on error: cb("error message")
|
||||
// not found: cb("ENOENT")
|
||||
//
|
||||
// Result ops (rename, listFiles, stat, openReader): cb(result: T | null, err?: string)
|
||||
//
|
||||
// on success: cb(result)
|
||||
// on error: cb(null, "error message")
|
||||
// not found: cb(null, "ENOENT")
|
||||
type jsFileOps struct {
|
||||
v js.Value
|
||||
}
|
||||
|
||||
// jsCallResult invokes method on j.v, appending a Go-owned js.FuncOf as the
|
||||
// final argument. It blocks until JS calls back with (result, errStr?), then
|
||||
// returns (result, error). An absent or empty errStr means success.
|
||||
//
|
||||
// JS convention for result ops: cb(result: T | null, err?: string)
|
||||
func (j jsFileOps) jsCallResult(method string, args ...any) (js.Value, error) {
|
||||
type result struct {
|
||||
val js.Value
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
cb := js.FuncOf(func(this js.Value, cbArgs []js.Value) any {
|
||||
var r result
|
||||
if len(cbArgs) > 0 {
|
||||
if t := cbArgs[0].Type(); t != js.TypeNull && t != js.TypeUndefined {
|
||||
r.val = cbArgs[0]
|
||||
}
|
||||
}
|
||||
if len(cbArgs) > 1 && cbArgs[1].Type() == js.TypeString {
|
||||
if s := cbArgs[1].String(); s != "" {
|
||||
r.err = errors.New(s)
|
||||
}
|
||||
}
|
||||
ch <- r
|
||||
return nil
|
||||
})
|
||||
defer cb.Release()
|
||||
j.v.Call(method, append(args, cb)...)
|
||||
r := <-ch
|
||||
return r.val, r.err
|
||||
}
|
||||
|
||||
// jsCallVoid invokes method on j.v for operations that return no result,
|
||||
// appending a Go-owned js.FuncOf as the final argument. It blocks until JS
|
||||
// calls back with an optional error string, then returns the error or nil.
|
||||
//
|
||||
// JS convention for void ops: cb(err?: string)
|
||||
func (j jsFileOps) jsCallVoid(method string, args ...any) error {
|
||||
ch := make(chan error, 1)
|
||||
cb := js.FuncOf(func(this js.Value, cbArgs []js.Value) any {
|
||||
var err error
|
||||
if len(cbArgs) > 0 && cbArgs[0].Type() == js.TypeString {
|
||||
if s := cbArgs[0].String(); s != "" {
|
||||
err = errors.New(s)
|
||||
}
|
||||
}
|
||||
ch <- err
|
||||
return nil
|
||||
})
|
||||
defer cb.Release()
|
||||
j.v.Call(method, append(args, cb)...)
|
||||
return <-ch
|
||||
}
|
||||
|
||||
// isJSNotExist reports whether err is the sentinel "ENOENT" from JS.
|
||||
func isJSNotExist(err error) bool {
|
||||
return err != nil && err.Error() == "ENOENT"
|
||||
}
|
||||
|
||||
func (j jsFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) {
|
||||
if err := j.jsCallVoid("openWriter", name, offset); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &jsWriteCloser{ops: j, name: name}, name, nil
|
||||
}
|
||||
|
||||
type jsWriteCloser struct {
|
||||
ops jsFileOps
|
||||
name string
|
||||
}
|
||||
|
||||
func (w *jsWriteCloser) Write(p []byte) (int, error) {
|
||||
buf := js.Global().Get("Uint8Array").New(len(p))
|
||||
js.CopyBytesToJS(buf, p)
|
||||
if err := w.ops.jsCallVoid("write", w.name, buf); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (w *jsWriteCloser) Close() error {
|
||||
return w.ops.jsCallVoid("closeWriter", w.name)
|
||||
}
|
||||
|
||||
func (j jsFileOps) Remove(name string) error {
|
||||
err := j.jsCallVoid("remove", name)
|
||||
if isJSNotExist(err) {
|
||||
return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (j jsFileOps) Rename(oldPath, newName string) (string, error) {
|
||||
val, err := j.jsCallResult("rename", oldPath, newName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return val.String(), nil
|
||||
}
|
||||
|
||||
func (j jsFileOps) ListFiles() ([]string, error) {
|
||||
val, err := j.jsCallResult("listFiles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := val.Length()
|
||||
names := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
names[i] = val.Index(i).String()
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (j jsFileOps) Stat(name string) (fs.FileInfo, error) {
|
||||
val, err := j.jsCallResult("stat", name)
|
||||
if isJSNotExist(err) {
|
||||
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Use Float to correctly handle files larger than 2 GiB (int is 32-bit on wasm).
|
||||
return &jsFileInfo{name: name, size: int64(val.Float())}, nil
|
||||
}
|
||||
|
||||
func (j jsFileOps) OpenReader(name string) (io.ReadCloser, error) {
|
||||
val, err := j.jsCallResult("openReader", name)
|
||||
if isJSNotExist(err) {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := make([]byte, val.Get("byteLength").Int())
|
||||
js.CopyBytesToGo(b, val)
|
||||
return io.NopCloser(bytes.NewReader(b)), nil
|
||||
}
|
||||
|
||||
// jsFileInfo is a minimal [fs.FileInfo] backed by a name and a size.
|
||||
// Only Size() is used by the taildrop manager; the other fields are stubs.
|
||||
type jsFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
|
||||
func (i *jsFileInfo) Name() string { return i.name }
|
||||
func (i *jsFileInfo) Size() int64 { return i.size }
|
||||
func (i *jsFileInfo) Mode() fs.FileMode { return 0o444 }
|
||||
func (i *jsFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (i *jsFileInfo) IsDir() bool { return false }
|
||||
func (i *jsFileInfo) Sys() any { return nil }
|
||||
+1022
-8
File diff suppressed because it is too large
Load Diff
+87
-10
@@ -6,6 +6,77 @@ 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/internal/common from github.com/alexbrainman/sspi/negotiate
|
||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
||||
github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/defaults+
|
||||
github.com/aws/aws-sdk-go-v2/aws/defaults from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/aws/middleware from github.com/aws/aws-sdk-go-v2/aws/retry+
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/query from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/restjson from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/xml from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/aws/ratelimit from github.com/aws/aws-sdk-go-v2/aws/retry
|
||||
github.com/aws/aws-sdk-go-v2/aws/retry from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client+
|
||||
github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 from github.com/aws/aws-sdk-go-v2/aws/signer/v4
|
||||
github.com/aws/aws-sdk-go-v2/aws/signer/v4 from github.com/aws/aws-sdk-go-v2/internal/auth/smithy+
|
||||
github.com/aws/aws-sdk-go-v2/aws/transport/http from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/config from tailscale.com/wif
|
||||
github.com/aws/aws-sdk-go-v2/credentials from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds
|
||||
github.com/aws/aws-sdk-go-v2/credentials/processcreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/ssocreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/credentials/stscreds from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds/internal/config from github.com/aws/aws-sdk-go-v2/feature/ec2/imds
|
||||
github.com/aws/aws-sdk-go-v2/internal/auth from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
|
||||
github.com/aws/aws-sdk-go-v2/internal/auth/smithy from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/context from github.com/aws/aws-sdk-go-v2/aws/retry+
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 from github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints+
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/aws-sdk-go-v2/internal/middleware from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/aws-sdk-go-v2/internal/rand from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/aws-sdk-go-v2/internal/sdk from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/aws-sdk-go-v2/internal/sdkio from github.com/aws/aws-sdk-go-v2/credentials/processcreds
|
||||
github.com/aws/aws-sdk-go-v2/internal/shareddefaults from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/internal/strings from github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4
|
||||
github.com/aws/aws-sdk-go-v2/internal/sync/singleflight from github.com/aws/aws-sdk-go-v2/aws
|
||||
github.com/aws/aws-sdk-go-v2/internal/timeconv from github.com/aws/aws-sdk-go-v2/aws/retry
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/service/sso from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso
|
||||
github.com/aws/aws-sdk-go-v2/service/sso/types from github.com/aws/aws-sdk-go-v2/service/sso
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssooidc
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc/types from github.com/aws/aws-sdk-go-v2/service/ssooidc
|
||||
github.com/aws/aws-sdk-go-v2/service/sts from github.com/aws/aws-sdk-go-v2/config+
|
||||
github.com/aws/aws-sdk-go-v2/service/sts/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/aws-sdk-go-v2/service/sts/types from github.com/aws/aws-sdk-go-v2/credentials/stscreds+
|
||||
github.com/aws/smithy-go from github.com/aws/aws-sdk-go-v2/aws/protocol/restjson+
|
||||
github.com/aws/smithy-go/auth from github.com/aws/aws-sdk-go-v2/internal/auth+
|
||||
github.com/aws/smithy-go/auth/bearer from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/context from github.com/aws/smithy-go/auth/bearer
|
||||
github.com/aws/smithy-go/document from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/smithy-go/encoding from github.com/aws/smithy-go/encoding/json+
|
||||
github.com/aws/smithy-go/encoding/httpbinding from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
|
||||
github.com/aws/smithy-go/encoding/json from github.com/aws/aws-sdk-go-v2/service/ssooidc
|
||||
github.com/aws/smithy-go/encoding/xml from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/smithy-go/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/smithy-go/endpoints/private/rulesfn from github.com/aws/aws-sdk-go-v2/service/sts
|
||||
github.com/aws/smithy-go/internal/sync/singleflight from github.com/aws/smithy-go/auth/bearer
|
||||
github.com/aws/smithy-go/io from github.com/aws/aws-sdk-go-v2/feature/ec2/imds+
|
||||
github.com/aws/smithy-go/logging from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/metrics from github.com/aws/aws-sdk-go-v2/aws/retry+
|
||||
github.com/aws/smithy-go/middleware from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/private/requestcompression from github.com/aws/aws-sdk-go-v2/config
|
||||
github.com/aws/smithy-go/ptr from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/rand from github.com/aws/aws-sdk-go-v2/aws/middleware
|
||||
github.com/aws/smithy-go/time from github.com/aws/aws-sdk-go-v2/service/sso+
|
||||
github.com/aws/smithy-go/tracing from github.com/aws/aws-sdk-go-v2/aws/middleware+
|
||||
github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws+
|
||||
github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
|
||||
github.com/coder/websocket from tailscale.com/util/eventbus
|
||||
github.com/coder/websocket/internal/errd from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/util from github.com/coder/websocket
|
||||
@@ -34,6 +105,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
||||
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
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/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
|
||||
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
||||
@@ -56,8 +128,9 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
||||
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink+
|
||||
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
|
||||
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
|
||||
DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
|
||||
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
|
||||
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
|
||||
@@ -150,9 +223,11 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
||||
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/feature/c2n from tailscale.com/tsnet
|
||||
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/feature/condregister/identityfederation from tailscale.com/tsnet
|
||||
tailscale.com/feature/condregister/oauthkey from tailscale.com/tsnet
|
||||
tailscale.com/feature/condregister/portmapper from tailscale.com/tsnet
|
||||
tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet
|
||||
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
|
||||
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
|
||||
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
|
||||
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
|
||||
@@ -234,7 +309,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
||||
tailscale.com/tstime from tailscale.com/control/controlclient+
|
||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
||||
tailscale.com/tsweb from tailscale.com/util/eventbus+
|
||||
tailscale.com/tsweb from tailscale.com/util/eventbus
|
||||
tailscale.com/tsweb/varz from tailscale.com/tsweb+
|
||||
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/types/bools from tailscale.com/tsnet+
|
||||
@@ -324,6 +399,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
||||
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
|
||||
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine+
|
||||
tailscale.com/wgengine/wglog from tailscale.com/wgengine
|
||||
tailscale.com/wif from tailscale.com/feature/identityfederation
|
||||
golang.org/x/crypto/argon2 from tailscale.com/tka
|
||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
||||
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
|
||||
@@ -345,16 +421,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/http/httpguts from tailscale.com/ipn/ipnlocal
|
||||
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 github.com/prometheus-community/pro-bing+
|
||||
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/socket from golang.org/x/net/ipv4+
|
||||
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
|
||||
golang.org/x/net/internal/socks from golang.org/x/net/proxy
|
||||
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
|
||||
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
|
||||
golang.org/x/net/ipv4 from github.com/prometheus-community/pro-bing+
|
||||
golang.org/x/net/ipv6 from github.com/prometheus-community/pro-bing+
|
||||
golang.org/x/net/proxy from tailscale.com/net/netns
|
||||
D golang.org/x/net/route from tailscale.com/net/netmon+
|
||||
golang.org/x/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/internal from golang.org/x/oauth2+
|
||||
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
|
||||
@@ -457,11 +533,12 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
||||
crypto/sha3 from crypto/internal/fips140hash+
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/cipher+
|
||||
crypto/tls from net/http+
|
||||
crypto/tls from github.com/prometheus-community/pro-bing+
|
||||
crypto/tls/internal/fips140tls from crypto/tls
|
||||
crypto/x509 from crypto/tls+
|
||||
D crypto/x509/internal/macos from crypto/x509
|
||||
crypto/x509/pkix from crypto/x509+
|
||||
D database/sql/driver from github.com/google/uuid
|
||||
W debug/dwarf from debug/pe
|
||||
W debug/pe from github.com/dblohm7/wingoes/pe
|
||||
embed from github.com/tailscale/web-client-prebuilt+
|
||||
@@ -550,7 +627,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
||||
mime/quotedprintable from mime/multipart
|
||||
net from crypto/tls+
|
||||
net/http from expvar+
|
||||
net/http/httptrace from net/http+
|
||||
net/http/httptrace from github.com/prometheus-community/pro-bing+
|
||||
net/http/httputil from tailscale.com/client/web+
|
||||
net/http/internal from net/http+
|
||||
net/http/internal/ascii from net/http+
|
||||
@@ -565,7 +642,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
||||
os/user from github.com/godbus/dbus/v5+
|
||||
path from debug/dwarf+
|
||||
path/filepath from crypto/x509+
|
||||
reflect from encoding/asn1+
|
||||
reflect from database/sql/driver+
|
||||
regexp from github.com/huin/goupnp/httpu+
|
||||
regexp/syntax from regexp
|
||||
runtime from crypto/internal/fips140+
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
// 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
@@ -1,513 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user