18 Commits

Author SHA1 Message Date
codinget 52cae45f81 fix(wasm): correct ICMP case in ping type error message
The constant tailcfg.PingICMP is "ICMP" not "icmp"; the error message
was listing the wrong string, causing user confusion about valid values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:28:50 +00:00
codinget 7fd2507611 fix(wasm): validate ping type early; fallback DNS resolver for exit node
Add a switch guard before the 30-second context in ping() so that invalid
ping type strings (e.g. "disco" vs "Disco") reject immediately with a clear
error rather than silently timing out because userspaceEngine.Ping has no
default case.

For queryDNS(), detect SERVFAIL responses returned with an empty resolver
list (the typical state when an exit node is active but the DNS manager
forwarder has no configured upstreams) and fall back to querying 8.8.8.8
via the dialer — which honours exit-node routing — for A/AAAA record types.
Fall further back to the browser's native resolver if UserDial fails.

Also accept bare IP addresses in whoIs() (in addition to ip:port) so
callers don't need to fabricate a port when they only have a peer IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:20:40 +00:00
codinget 8514045909 feat(tsconnect): add peerAPIURL to netmap and localAPI in-process bridge
Include the PeerAPI base URL (http://ip:port) in every node entry of the
notifyNetMap payload — for self via LocalBackend.GetPeerAPIPort, for peers
by reading the PeerAPI4/PeerAPI6 Services entries in their Hostinfo. The URL
mirrors the address-family preference used by peerAPIBase (prefer IPv4).

Add a localAPI(method, path, body?) WASM binding that dispatches in-process
HTTP requests directly to a LocalAPI handler with full read/write/cert
permissions, returning {status, body}. Enables TypeScript callers to access
any LocalAPI endpoint (ACL policy, Taildrive shares, etc.) without network
setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 01:19:37 +00:00
codinget 7f5983eaab feat(tsconnect): add whoIs, queryDNS, ping, suggestExitNode WASM bindings
Expose four LocalBackend capabilities to JavaScript:
- whoIs(addrPort, proto?): resolves a connecting ip:port to a tailnet node
  and user profile; returns null for unknown peers
- queryDNS(name, type?): queries the tailnet DNS resolver (MagicDNS +
  upstream); parses A/AAAA/CNAME/TXT answers into strings
- ping(ip, type?, size?): pings a tailnet peer (TSMP, disco, ICMP, peerapi)
  with a 30 s context timeout; returns latency and path details
- suggestExitNode(): asks the coordination server for the best exit node

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 21:55:58 +00:00
codinget 143581c955 feat(tsconnect): add getCert, listenTLS, setFunnel + fix TLS cert for WASM
Enable ACME TLS certificates on js/wasm by dropping the !js build tag from
cert.go and routing storage through the state store. Add getCert, listenTLS,
and setFunnel WASM bindings with a combinedTLSListener that merges Funnel
ingress and direct tailnet connections. Notify the control plane immediately
after serve config changes to accelerate Funnel DNS provisioning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 11:19:25 +00:00
codinget d9efc3bae2 fix(tsconnect): pin types to avoid monorepo @types pollution
Replace skipLibCheck with an explicit types list so TypeScript and
dts-bundle-generator only auto-include @types/golang-wasm-exec and
@types/qrcode, preventing @types/eslint-scope and @types/ws from
leaking in from a parent node_modules when built inside a monorepo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 20:04:20 +00:00
codinget 9e36a7f27f fix(tsconnect): skipLibCheck to avoid monorepo @types conflicts
When tsconnect is built inside a JS monorepo, TypeScript walks up the
directory tree and auto-discovers @types/eslint-scope and @types/ws
from the root node_modules, causing spurious type errors unrelated to
tsconnect itself. skipLibCheck suppresses these.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 19:52:29 +00:00
codinget 8277fc0f1d fix(tsconnect): lowercase name/size in waitingFiles JSON
apitype.WaitingFile has no json tags so it serialised as {Name, Size}.
Introduce a local jsWaitingFile struct with json:"name" / json:"size"
so the JS side receives idiomatic camelCase property names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:39:52 +00:00
codinget e32520659d fix(taildrop): restore incoming file progress notifications
The io.Copy in PutFile was writing directly to wc, bypassing the
incomingFile wrapper whose Write method increments f.copied and fires
a throttled sendFileNotify on progress. As a result, notifyIncomingFiles
on the JS side only ever fired once (on completion) with received=0,
making progress UI impossible. The original inFile wrapping was lost
during the Android SAF refactor.

Also surface the PartialFile.Done flag through jsIncomingFile so JS can
distinguish the final "transfer complete" notification from in-progress
updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 19:04:02 +00:00
codinget e8eb9d71c2 fix(tsconnect): guard nil n.Prefs in notify callback
n.Prefs is *PrefsView (a pointer), so calling n.Prefs.Valid() on a
Notify where Prefs is nil auto-dereferenced nil and panicked. The
callback's defer recover() swallowed the panic, which meant every
Notify without Prefs (Health-only, FilesWaiting, IncomingFiles,
OutgoingFiles, etc.) never reached the file-related JS calls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 18:43:58 +00:00
codinget c4ff4c4835 feat(tsconnect): add outgoing file transfer progress notifications
- Export UpdateOutgoingFiles on taildrop.Extension so it can be called
  from outside the package (wasm bridge, package main).
- Wrap sendFile's PUT body with progresstracking.NewReader so bytes-sent
  is sampled roughly once per second during transfer.
- Create an OutgoingFile entry (with UUID, peer ID, name, declared size)
  before the PUT and call UpdateOutgoingFiles on each progress tick and
  on completion (setting Finished/Succeeded). This flows into the IPN
  notify stream as OutgoingFiles notifications.
- Add jsOutgoingFile struct and wire n.OutgoingFiles into a new
  notifyOutgoingFiles callback in run(), mirroring notifyIncomingFiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 23:01:30 +00:00
codinget 68ecc4b033 feat(tsconnect): add notifyFilesWaiting and notifyIncomingFiles callbacks
Wire two new callbacks into the IPN notify stream:

- notifyFilesWaiting: fires when a completed inbound transfer is staged
  and ready to retrieve via waitingFiles(). Triggered by n.FilesWaiting
  in the notify stream.
- notifyIncomingFiles: fires with a JSON snapshot of in-progress inbound
  transfers whenever progress changes (roughly once per second while
  active, plus once at completion). The jsIncomingFile struct carries
  name, started (Unix ms), declaredSize, and received bytes. An empty
  array indicates all active transfers have finished.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 22:58:13 +00:00
codinget 9f96b7434c feat(taildrop): fix DirectFileMode, void callbacks, and empty WaitingFiles
- Add SetStagedFileOps to Extension: sets fileOps without enabling
  DirectFileMode, so WASM clients use staged retrieval (WaitingFiles,
  OpenFile, DeleteFile) instead of direct-write mode.
- Add directFileOps bool field: SetFileOps (Android SAF) sets it true;
  SetStagedFileOps (WASM JS) leaves it false. onChangeProfile now uses
  `fops != nil && e.directFileOps` to determine DirectFileMode.
- Add jsCallVoid to jsFileOps: void ops (openWriter, write, closeWriter,
  remove) now use cb(err?: string) instead of cb(null, err: string).
- Fix waitingFiles() returning JSON null when no files are waiting:
  normalise nil slice to empty slice before marshalling.
- Update wireTaildropFileOps to call SetStagedFileOps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:48:11 +00:00
codinget b04b4f7751 feat(tsconnect): expose exit node selection to JS
Add exit node support to the wasm JS bridge:

- Include `exitNodeOption` and `stableNodeID` on each peer in the
  notifyNetMap payload so callers can identify which peers are exit
  nodes and reference them by stable ID.
- Call `notifyExitNode(stableNodeID)` whenever prefs change, so
  callers can track which exit node (if any) is currently active.
- Expose `setExitNode(stableNodeID)` — sets ExitNodeID via EditPrefs.
- Expose `setExitNodeEnabled(enabled)` — toggles the last-used exit
  node on/off via SetUseExitNodeEnabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:43:01 +00:00
codinget f961db8925 feat(tsconnect): add TCP listening to ipn.listen
Extend ipn.listen to also accept "tcp"/"tcp4"/"tcp6" and return a
TCPListener bound to a netstack gonet.TCPListener. The listener
exposes accept/close/addr like a Go net.Listener and additionally
implements Symbol.asyncIterator so JS callers can write:

  for await (const conn of listener) { ... }

The async iterator returns done when the listener is closed (via
errors.Is(net.ErrClosed)) and rejects on any other accept error.
Symbol-keyed properties are set via Reflect.set since syscall/js
only exposes string-keyed Set.
2026-04-10 21:08:59 +00:00
codinget fde5f11895 feat(tsconnect): expose dialTLS to JS
Add ipn.dialTLS(addr, opts?) which dials a TCP connection through
the Tailscale dialer and performs a TLS handshake on top, returning
a JS Conn just like ipn.dial.

WASM has no system root pool, so verification defaults to the
baked-in LetsEncrypt ISRG roots already linked via net/bakedroots.
That covers any tailnet HTTPS endpoint provisioned via
`tailscale cert`. Callers can override with opts.caCerts (PEM) or
bypass entirely with opts.insecureSkipVerify, and override SNI with
opts.serverName.

Marginal binary cost is ~10 KiB on top of the existing ~31.6 MiB
wasm: crypto/tls and the x509 verification path are already pulled
in by control/controlclient and net/tlsdial.
2026-04-10 20:43:22 +00:00
codinget 756ba1d5ec feat(tsconnect): expose dial, listen and listenICMP to JS
Wire up the userspace networking primitives to the JS bridge so
browser callers can initiate outbound and receive inbound traffic
over the Tailscale network:

- ipn.dial(network, addr) wraps a tsdial UserDial into a JS Conn
  with read/write/close/localAddr/remoteAddr.
- ipn.listen(network, addr) wraps a netstack ListenPacket into a
  JS PacketConn with readFrom/writeTo/close/localAddr.
- ipn.listenICMP("icmp4"|"icmp6"|"icmp") creates a raw ICMP
  endpoint on the underlying gVisor stack and wraps it as a
  PacketConn for sending/receiving ping traffic.

To support listenICMP, netstack.Impl gains a Stack() accessor that
returns the underlying *stack.Stack so jsIPN can call NewEndpoint
with icmp.ProtocolNumber4/6.

Binary I/O uses js.CopyBytesToGo / js.CopyBytesToJS to move bytes
across the syscall/js boundary without base64 round-trips.
2026-04-10 13:57:15 +00:00
codinget 68670f938b fix(tsconnect): drop nethttpomithttp2 build tag
After 1d93bdce2 ("control/controlclient: remove x/net/http2, use
net/http"), the noise control client uses net/http's Transport with
Protocols.SetUnencryptedHTTP2(true). The nethttpomithttp2 build tag
strips the bundled HTTP/2 implementation from net/http, so at runtime
the control client fails the first register request with "http:
Transport does not support unencrypted HTTP/2" and the wasm never
connects.

Drop the tag so the bundled HTTP/2 ships in the wasm binary.
2026-04-10 13:56:59 +00:00
425 changed files with 10934 additions and 27148 deletions
+2 -3
View File
@@ -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
-182
View File
@@ -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
+4 -20
View File
@@ -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
+1 -9
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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
+5 -7
View File
@@ -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
View File
@@ -1 +1 @@
1.99.0
1.97.0
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
-57
View File
@@ -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))
}
-108
View File
@@ -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)
}
-51
View File
@@ -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{
+4 -4
View File
@@ -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
View File
@@ -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
+1
View File
@@ -3,6 +3,7 @@
//go:build cgo || !darwin
// Package systray provides a minimal Tailscale systray application.
package systray
import (
+8 -28
View File
@@ -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 {
-120
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
})
}
}
+7 -25
View File
@@ -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
View File
@@ -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) {
-41
View File
@@ -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 -8
View File
@@ -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
+1 -43
View File
@@ -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
}
+37 -38
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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(&currentDeviceID, &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(&currentDeviceEndpoints, &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 ""
}
+6 -76
View File
@@ -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":
+6 -28
View File
@@ -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)
}
+1 -2
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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
}
+438
View File
@@ -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 &rarr;</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 &rarr;</a></p>
</footer>
</main>
</body>
</html>
-71
View File
@@ -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 &rarr;</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 &rarr;</a></p>
</footer>
</main>
</body>
</html>
-157
View File
@@ -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
}
-12
View File
@@ -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);
});
}
})();
-366
View File
@@ -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;
}
}
+85 -11
View File
@@ -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:
+37 -173
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
}
+1 -2
View File
@@ -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
+11 -12
View File
@@ -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)
}
}
+3 -4
View File
@@ -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
}
+1 -1
View File
@@ -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,
}
}
+5 -6
View File
@@ -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
}
-6
View File
@@ -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
}
-40
View File
@@ -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)
})
-2
View File
@@ -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)
-3
View File
@@ -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
View File
@@ -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
}
-3
View File
@@ -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) {
+8 -79
View File
@@ -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)
}
}
}
-161
View File
@@ -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)
}
-141
View File
@@ -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
View File
@@ -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
}
-193
View File
@@ -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
}
-55
View File
@@ -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)
}
}
+52
View File
@@ -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)
}
})
}
+1 -1
View File
@@ -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)
}
+7 -9
View File
@@ -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
-2
View File
@@ -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
View File
@@ -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
}
+4 -36
View File
@@ -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 {
+22 -8
View File
@@ -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 {
+4 -4
View File
@@ -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
}
+9 -2
View File
@@ -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
}
+21 -2
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
-11
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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+
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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+
-13
View File
@@ -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",
+1
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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.")
+2 -1
View File
@@ -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"]
+405
View File
@@ -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 }
File diff suppressed because it is too large Load Diff
+87 -10
View File
@@ -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+
-173
View File
@@ -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
View File
@@ -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(&registerArgs.nodeFile, "n", "", "node JSON file (required)")
fs.StringVar(&registerArgs.output, "o", "", "output file (default: stdout)")
fs.StringVar(&registerArgs.hostname, "hostname", "", "hostname to register")
fs.BoolVar(&registerArgs.ephemeral, "ephemeral", false, "register as ephemeral node")
fs.StringVar(&registerArgs.authKey, "auth-key", "", "pre-authorized auth key or file containing one")
fs.StringVar(&registerArgs.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
}
-39
View File
@@ -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
}
-14
View File
@@ -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