Author SHA1 Message Date
codingetandClaude 721194dfb5 fix(tsconnect/wasm): close conn on upgradeTLS configuration errors too
Previously only a handshake failure closed the underlying conn;
configuration errors (malformed caCerts PEM, bad cert/key pair)
returned with the conn still open, while the JS wrapper treats every
upgradeTLS rejection as fatal and marks the conn closed — leaking a
live Go conn that could no longer be closed from JS. Close on every
error path so the JS contract (any rejection after validation is
fatal) holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfRgMke8p7QDxD5QM5thxH
2026-07-12 11:28:33 +00:00
codingetandClaude 81e37f8812 feat(tsconnect/wasm): add upgradeTLS to wrapped conns
Adds an upgradeTLS method to the conn objects returned by dial/accept,
wrapping the existing net.Conn with crypto/tls in place (STARTTLS /
FTPS AUTH TLS style). Client mode reuses the dialTLS options
(serverName, insecureSkipVerify, caCerts), now factored into
tlsClientConfigFromJS; server mode takes isServer with certPem/keyPem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfRgMke8p7QDxD5QM5thxH
2026-07-06 09:00:19 +00:00
codingetandClaude cada6936b9 feat(wasm): expose taildrive WebDAV server and listDrivePeers via JS bridge
Add drive.go (build tag !ts_omit_drive): implements drive.FileSystemForRemote
with a JS-backed handler. Streams request bodies chunk-by-chunk via
readBodyChunk() and response bodies via write()/end() callbacks so no
full-body buffering occurs regardless of file size. The handler is nil-safe:
returns 404 until setDriveHandler() is called from JS.

Add drive_stub.go (build tag ts_omit_drive): no-op stubs for stripped builds.

Add peer.go: extract buildPeerAPIURL helper (previously inline in run()).

Modify wasm_js.go: call initDriveForRemote before NewLocalBackend (SubSystem
is set-once), expose setDriveHandler and listDrivePeers via wireDriveJS,
and refactor the inline peerAPI URL logic to use buildPeerAPIURL.

listDrivePeers mirrors native driveRemotesFromPeers: returns empty if
DriveAccessEnabled() is false, then filters peers by PeerCapabilityTaildriveSharer
using lb.PeerCaps(addr).HasCapability() (the live ACL-derived cap map).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:37:00 +00:00
codingetandClaude 78c4511a3d fix(tsconnect): avoid nil services slice in netmap JSON
userServicesFromView returned a nil slice when a node advertised no
services, which (combined with the omitempty tag) caused the
services field to be dropped or serialize as null instead of [].
TypeScript declares services as a non-optional array, so JS callers
calling .find()/.some() on it would throw intermittently depending on
which netmap snapshot they observed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:08:32 +00:00
codingetandClaude 3a9f6f463a fix(tsconnect): restart map poll after SetExplicitServices
The previous implementation only triggered a lite map update (non-streaming,
OmitPeers=true), whose response is discarded. This meant notifyNetMap was
never called after setServices, so self.services was never visible to the
local node and peers received the update only on their next periodic poll.

Add RestartMap() to controlclient.Auto and call it from SetExplicitServices
after the lite update. This cancels the current streaming poll and starts a
fresh one, causing the control server to send back a full netmap that
includes the updated SelfNode.Hostinfo.Services.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 01:09:37 +00:00
codingetandClaude 7bfc64c379 feat(tsconnect): expose service advertisement to JS
Add SetExplicitServices on LocalBackend so the browser WASM node can
declare TCP/UDP services that get uploaded to the control server and
distributed to all peers in the netmap — without the OS port-scanner
(portlist extension) that cannot run in a browser.

The ShouldUploadServices gate in hostInfoWithServicesLocked is bypassed
when services were set explicitly, leaving all other callers unaffected.

On the JS side, a new setServices(services) method accepts an array of
{proto, port, description?} objects.  The netmap JSON now includes a
services field on every node (self and peers), populated from
Hostinfo.Services with internal peerapi entries stripped (they are
already reflected in peerAPIURL).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 22:18:58 +00:00
codingetandClaude e7270026f7 fix(tsconnect/wasm): nil-check lb and ln in shutdown() before use
lb and ln are only initialised during run(); calling shutdown() before
run() panics on nil. Guard both fields before dereferencing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:54:55 +00:00
codingetandClaude 4618ee1496 fix(tsconnect/wasm): normalise ":port" listen addr to "0.0.0.0:port"
netstack.ListenTCP requires a full host:port address; callers passing
the standard net.Listen form (":0" for any-interface ephemeral port)
would get ParseAddrPort error. Prepend "0.0.0.0" when the address
starts with ":" so the API matches Go's net.Listen behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:35:17 +00:00
codingetandClaude 915dca44fe fix(safesocket/js): use unique memconn name per IPN instance
Each call to newIPN() starts an independent Go backend that calls
safesocket.Listen() to serve the ipnserver IPC channel. Because
memName was a global constant, the second instance would fail with
"addr unavailable" and log.Fatal the whole WASM process.

Use an atomic counter to give each listener a distinct name
(Tailscale-IPN-1, Tailscale-IPN-2, …). The connect() path is
unchanged: in the wasm/tsconnect build all LocalAPI calls go through
the in-process httptest handler, so connect() is never called.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:35:17 +00:00
codingetandClaude 6e83d5291b feat(tsconnect/wasm): add shutdown() to jsIPN
Expose a shutdown() method on the JS-side IPN object that stops the
LocalBackend, closes the safesocket listener (which unblocks srv.Run),
and signals main() to return so the Go runtime exits cleanly.

This allows the host environment (Node.js process or browser service
worker) to terminate normally once the Tailscale WASM module is no
longer needed, instead of being kept alive indefinitely by open handles,
goroutines, or the Go runtime's blocking main goroutine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 00:22:56 +00:00
codingetandClaude 21d0f11d85 feat(taildrop): stream files via ReadableStream on send and receive
Send: accept a ReadableStreamDefaultReader instead of a Uint8Array.
jsStreamReader (new io.ReadCloser) awaits reader.read() Promises via the
channel+FuncOf pattern, feeding chunks directly to the HTTP PUT body.
No js.CopyBytesToGo of the full file.

Receive: openWaitingFile now returns a pull-based ReadableStream backed by
the Go io.ReadCloser (jsReadableStream helper). Each pull call reads up
to 64 KiB and enqueues a Uint8Array chunk; no io.ReadAll.

jsFileOps.OpenReader: JS now returns a ReadableStream instead of a
Uint8Array; Go wraps it in jsStreamReader for streaming delivery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:13:04 +00:00
codingetandClaude 0df765eb60 chore(tsconnect): drop wasm pre-compression from build-pkg
Consumers are now responsible for compressing assets; the package ships
only the raw main.wasm binary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:01:45 +00:00
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
codingetandClaude 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
986 changed files with 19056 additions and 90193 deletions
+2 -60
View File
@@ -1,60 +1,2 @@
go.mod filter=go-mod eol=lf text
*.go diff=golang eol=lf text
*.adml eol=lf text
*.admx eol=lf text
*.bash eol=lf text
*.c eol=lf text
*.cgi eol=lf text
*.conf eol=lf text
*.css eol=lf text
*.csv eol=lf text
*.desktop eol=lf text
*.fish eol=lf text
*.gitattributes eol=lf text
*.gitignore eol=lf text
*.gitkeep eol=lf text
*.go eol=lf text
*.h eol=lf text
*.helmignore eol=lf text
*.htaccess eol=lf text
*.html eol=lf text
*.hujson eol=lf text
*.in eol=lf text
*.init eol=lf text
*.js eol=lf text
*.json eol=lf text
*.lock eol=lf text
*.lua eol=lf text
*.md eol=lf text
*.mod eol=lf text
*.nix eol=lf text
*.openrc eol=lf text
*.pbxproj eol=lf text
*.pem eol=lf text
*.plg eol=lf text
*.plist eol=lf text
*.rc eol=lf text
*.resolved eol=lf text
*.rev eol=lf text
*.rs eol=lf text
*.sc eol=lf text
*.service eol=lf text
*.sh eol=lf text
*.socket eol=lf text
*.stignore eol=lf text
*.sum eol=lf text
*.svg eol=lf text
*.swift eol=lf text
*.tmpl eol=lf text
*.toml eol=lf text
*.ts eol=lf text
*.tsx eol=lf text
*.txt eol=lf text
*.version eol=lf text
*.xcscheme eol=lf text
*.xcsettings eol=lf text
*.xib eol=lf text
*.xml eol=lf text
*.yaml eol=lf text
*.yml eol=lf text
*.zsh eol=lf text
go.mod filter=go-mod
*.go diff=golang
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
# Install a more recent Go that understands modern go.mod content.
- name: Install Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # zizmor: ignore[cache-poisoning] v6.3.0
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: go.mod
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install govulncheck
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@0782b76014f15f24e22a438f30f308df42899ba1 # 1.3.0
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Scan source code for known vulnerabilities
run: PATH=$PWD/tool/:$PATH "$(./tool/go env GOPATH)/bin/govulncheck" -test ./...
+3 -4
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" }
@@ -69,7 +68,7 @@ jobs:
- { image: "fedora:latest", deps: "curl", version: "1.80.0" }
runs-on: ubuntu-latest
container:
image: ${{ matrix.image }} # zizmor: ignore[unpinned-images]
image: ${{ matrix.image }}
options: --user root
steps:
- name: install dependencies (pacman)
@@ -1,7 +1,6 @@
# Run a single natlab smoke test on every PR. The full natlab suite
# is opt-in and lives in .github/workflows/natlab-test.yml.
# Run some natlab integration tests.
# See https://github.com/tailscale/tailscale/issues/13038
name: "natlab-basic"
name: "natlab-integrationtest"
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@@ -18,28 +17,17 @@ on:
branches:
- "main"
jobs:
EasyEasy:
natlab-integrationtest:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Install qemu
run: |
sudo rm -f /var/lib/man-db/auto-update
sudo apt-get -y update
sudo apt-get -y remove man-db
sudo apt-get install -y qemu-system-x86 qemu-utils
- name: Build VM image
# The test will build this if missing, but we do it explicitly
# to avoid cutting into the go test -timeout budget, and to
# fail earlier with a clearer error if the image build breaks.
run: |
make -C gokrazy natlab
- name: Run natlab integration tests
run: |
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/natlab/vmtest --run-vm-tests
./tool/go test -v -run=^TestEasyEasy$ -timeout=3m -count=1 ./tstest/integration/nat --run-vm-tests
-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|TestVnetPerf.*)$'
tmp=$(mktemp)
for pkg_dir in tstest/natlab/vmtest tstest/integration/nat; do
pkg="./${pkg_dir}/"
for f in "${pkg_dir}"/*_test.go; do
[ -e "$f" ] || continue
{ grep -hE '^func Test[A-Z][A-Za-z0-9_]*\(t \*testing\.T\)' "$f" || true; } \
| sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/' \
| { grep -vE "$exclude" || true; } \
| while read -r t; do
jq -nc --arg pkg "$pkg" --arg test "$t" \
'{pkg: $pkg, test: $test}' >> "$tmp"
done
done
done
matrix=$(jq -s -c . "$tmp")
echo "matrix=${matrix}" >> "$GITHUB_OUTPUT"
echo "Discovered tests:"
jq . "$tmp"
test:
needs: prepare
runs-on: ubuntu-latest
timeout-minutes: 20
name: "${{ matrix.test }}"
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.prepare.outputs.matrix) }}
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Install qemu
run: |
sudo rm -f /var/lib/man-db/auto-update
sudo apt-get -y update
sudo apt-get -y remove man-db
sudo apt-get install -y qemu-system-x86 qemu-utils
# restore-only: prepare is the single writer of these caches, so
# matrix jobs don't write back. fail-on-cache-miss would be too
# strict for the gokrazy cache (e.g. a non-fatal cache eviction
# between prepare and us); we just rebuild on miss instead.
- name: Restore cloud VM images
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.cache/tailscale/vmtest/images
key: natlab-vmimages-${{ hashFiles('tstest/natlab/vmtest/images.go') }}
- name: Restore gokrazy VM image
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: gokrazy/natlabapp.qcow2
key: natlab-gokrazy-${{ github.sha }}
# The gokrazy-based tests boot the kernel directly from
# vmlinuz that ships in the gokrazy/kernel.amd64 module.
# Tests look it up under GOMODCACHE via findKernelPath, so the
# module has to be present even though no Go source imports it
# in the test package itself.
- name: Download kernel.amd64 module
run: |
./tool/go mod download github.com/gokrazy/kernel.amd64
- name: Run ${{ matrix.test }}
# Per-test timeout is well above the few-minute typical runtime
# but small enough that a stuck test fails fast instead of holding
# the runner for the job's 20-minute budget.
run: |
./tool/go test -v -timeout=15m -count=1 ${{ matrix.pkg }} \
-run='^${{ matrix.test }}$' --run-vm-tests
-45
View File
@@ -1,45 +0,0 @@
name: policybot-test
env:
HOME: ${{ github.workspace }}
GOMODCACHE: ${{ github.workspace }}/gomodcache
CMD_GO_USE_GIT_HASH: "true"
on:
push:
branches:
- main
- "release-branch/*"
paths:
- .github/workflows/policybot-test.yml
- .policy.yml
- .policy-tests.yml
- go.mod
pull_request:
paths:
- .github/workflows/policybot-test.yml
- .policy.yml
- .policy-tests.yml
- go.mod
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
policybot-test:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: src
# The version of github.com/tailscale/policybottest used here is
# pinned by go.mod via internal/tooldeps/tooldeps.go; bump it with
# "go get github.com/tailscale/policybottest@<sha> && go mod tidy".
- name: Run policy tests
working-directory: src
run: ./tool/go run github.com/tailscale/policybottest -policy .policy.yml -tests .policy-tests.yml
@@ -2,7 +2,7 @@ name: request-dataplane-review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
types: [ opened, synchronize, reopened, ready_for_review ]
paths:
- ".github/workflows/request-dataplane-review.yml"
- "**/*derp*"
@@ -15,6 +15,8 @@ jobs:
name: Request Dataplane Review
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get access token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
id: generate-token
@@ -22,8 +24,6 @@ jobs:
# Get token for app: https://github.com/apps/change-visibility-bot
app-id: ${{ secrets.VISIBILITY_BOT_APP_ID }}
private-key: ${{ secrets.VISIBILITY_BOT_APP_PRIVATE_KEY }}
# Limit the token to only requesting reviewers on pull requests.
permission-pull-requests: write
- name: Add reviewers
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
-39
View File
@@ -1,39 +0,0 @@
name: request-k8s-review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- ".github/workflows/request-k8s-review.yml"
- "k8s-operator/**"
- "kube/**"
- "cmd/k8s-operator/**"
- "cmd/k8s-proxy/**"
- "cmd/k8s-nameserver/**"
- "cmd/containerboot/**"
- "cmd/sync-containers/**"
- "ipn/store/kubestore/**"
- "docs/k8s/**"
- "!**/depaware.txt"
jobs:
request-k8s-review:
if: github.event.pull_request.draft == false
name: Request K8s Review
runs-on: ubuntu-latest
steps:
- name: Get access token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
id: generate-token
with:
# Get token for app: https://github.com/apps/change-visibility-bot
app-id: ${{ secrets.VISIBILITY_BOT_APP_ID }}
private-key: ${{ secrets.VISIBILITY_BOT_APP_PRIVATE_KEY }}
# Limit the token to only requesting reviewers on pull requests.
permission-pull-requests: write
- name: Add reviewers
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
url: ${{ github.event.pull_request.html_url }}
run: |
gh pr edit "$url" --add-reviewer tailscale/k8s-devs
+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
+32 -22
View File
@@ -70,7 +70,7 @@ jobs:
run: go mod download
- name: Cache Go modules
if: steps.check-cache.outputs.cache-hit != 'true'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # zizmor: ignore[cache-poisoning] v5.0.4
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: gomodcache # relative to workspace; see env note at top of file
key: ${{ steps.hash.outputs.key }}
@@ -183,7 +183,7 @@ jobs:
TS_TEST_SHARD: ${{ matrix.shard }}
- name: bench all
working-directory: src
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run='^$' $(for x in $(git grep -l '^func Benchmark' | xargs dirname | sort | uniq); do echo "./$x"; done)
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
env:
GOARCH: ${{ matrix.goarch }}
- name: check that no tracked files changed
@@ -261,7 +261,6 @@ jobs:
cigocached-host: ${{ vars.CIGOCACHED_AZURE_HOST }}
- name: test
shell: bash
if: matrix.key != 'win-bench' # skip on bench builder
working-directory: src
run: ./tool/go run ./cmd/testwrapper sharded:${{ matrix.shard }}
@@ -269,10 +268,9 @@ jobs:
NOPWSHDEBUG: "true" # to quiet tool/gocross/gocross-wrapper.ps1 in CI
- name: bench all
shell: bash
if: matrix.key == 'win-bench'
working-directory: src
run: ./tool/go test -bench=. -benchtime=1x -run='^$' $(for x in $(git grep -l '^func Benchmark' | xargs dirname | sort | uniq); do echo "./$x"; done)
run: ./tool/go test ./... -bench=. -benchtime=1x -run="^$"
env:
NOPWSHDEBUG: "true" # to quiet tool/gocross/gocross-wrapper.ps1 in CI
@@ -345,7 +343,7 @@ jobs:
needs: gomod-cache
runs-on: ubuntu-24.04
container:
image: golang:latest # zizmor: ignore[unpinned-images]
image: golang:latest
options: --privileged
steps:
- name: checkout
@@ -363,7 +361,31 @@ jobs:
run: chown -R $(id -u):$(id -g) $PWD
- name: privileged tests
working-directory: src
run: ./tool/go test $(./tool/go run ./tool/listpkgs --has-root-tests)
run: ./tool/go test ./util/linuxfw ./derp/xdp
vm:
needs: gomod-cache
runs-on: ["self-hosted", "linux", "vm"]
# VM tests run with some privileges, don't let them run on 3p PRs.
if: github.repository == 'tailscale/tailscale'
steps:
- name: checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: src
- name: Restore Go module cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: gomodcache
key: ${{ needs.gomod-cache.outputs.cache-key }}
enableCrossOsArchive: true
- name: Run VM tests
working-directory: src
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2404
env:
HOME: "/var/lib/ghrunner/home"
TMPDIR: "/tmp"
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
cross: # cross-compile checks, build only.
needs: gomod-cache
@@ -620,13 +642,6 @@ jobs:
run: |
./tool/go run ./cmd/tsconnect --fast-compression build
./tool/go run ./cmd/tsconnect --fast-compression build-pkg
- name: verify Google Chrome is available
run: |
which google-chrome
google-chrome --version
- name: tsconnect js/wasm headless-browser tests
working-directory: src
run: ./tool/go test ./tstest/integration/jswasmtest/ -v -timeout 180s --run-headless-browser-tests
- name: Tidy cache
working-directory: src
shell: bash
@@ -772,14 +787,6 @@ jobs:
echo
echo
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
- name: check that 'genreadme' is clean
working-directory: src
run: |
./tool/go run ./misc/genreadme
git add -N . # ensure untracked files are noticed
echo
echo
git diff --name-only --exit-code || (echo "The files above need updating. Please run './tool/go run ./misc/genreadme'."; exit 1)
make_tidy:
runs-on: ubuntu-24.04
@@ -888,6 +895,7 @@ jobs:
- test
- windows
- macos
- vm
- cross
- ios
- wasm
@@ -933,6 +941,7 @@ jobs:
- test
- windows
- macos
- vm
- cross
- ios
- wasm
@@ -982,6 +991,7 @@ jobs:
- test
- windows
- macos
- vm
- wasm
- fuzz
- race-root-integration
+4 -7
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
@@ -33,9 +33,6 @@ jobs:
# Get token for app: https://github.com/apps/tailscale-code-updater
app-id: ${{ secrets.CODE_UPDATER_APP_ID }}
private-key: ${{ secrets.CODE_UPDATER_APP_PRIVATE_KEY }}
# Limit the token to only pushing a branch and opening a pull request.
permission-contents: write
permission-pull-requests: write
- name: Send pull request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 #v8.1.0
@@ -44,8 +41,8 @@ jobs:
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
committer: Flakes Updater <noreply+flakes-updater@tailscale.com>
branch: flakes
commit-message: "flakehashes.json: update SRI hash for go.mod changes"
title: "flakehashes.json: update SRI hash for go.mod changes"
commit-message: "go.mod.sri: update SRI hash for go.mod changes"
title: "go.mod.sri: update SRI hash for go.mod changes"
body: Triggered by ${{ github.repository }}@${{ github.sha }}
signoff: true
delete-branch: true
@@ -29,9 +29,6 @@ jobs:
# Get token for app: https://github.com/apps/tailscale-code-updater
app-id: ${{ secrets.CODE_UPDATER_APP_ID }}
private-key: ${{ secrets.CODE_UPDATER_APP_PRIVATE_KEY }}
# Limit the token to only pushing a branch and opening a pull request.
permission-contents: write
permission-pull-requests: write
- name: Send pull request
id: pull-request
+2 -4
View File
@@ -14,17 +14,15 @@ on:
- main
- "release-branch/*"
paths:
- .github/workflows/vet.yml
- "**.go"
pull_request:
paths:
- .github/workflows/vet.yml
- "**.go"
jobs:
vet:
runs-on: ubuntu-24.04
timeout-minutes: 10
runs-on: [ self-hosted, linux ]
timeout-minutes: 5
steps:
- name: Check out code
-32
View File
@@ -1,32 +0,0 @@
name: security lint GitHub Actions with zizmor
on:
push:
branches: ["main"]
paths:
- ".github/workflows/**"
pull_request:
branches: ["**"]
paths:
- ".github/workflows/**"
permissions: {}
jobs:
zizmor:
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
with:
min-severity: high
advanced-security: false
annotations: true
+1 -6
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
@@ -58,5 +55,3 @@ client/web/build/assets
# Ignore syncthing state directory.
/.stfolder
fbstatus
gafpush
-258
View File
@@ -1,258 +0,0 @@
# Tests for testdata/tailscale.com.policy.yml.
#
# Run from the parent directory with:
# go run . -policy testdata/tailscale.com.policy.yml \
# -tests testdata/tailscale.com.policy-test.yml
teams:
# Members are picked solely so tests below can refer to them.
# `alice` and `bob` are control-protocol-owners *and* dev. `carol`,
# `dave`, and `eve` are dev only. `outsider` is in nothing.
tailscale/control-protocol-owners:
- alice
- bob
tailscale/dev:
- alice
- bob
- carol
- dave
- eve
tests:
# ------------------------------------------------------------------
# Baseline: every PR (touching tailcfg/ or not) requires a +1 from a
# tailscale/dev member.
# ------------------------------------------------------------------
- name: non-tailcfg PR with no review is pending
pull_request:
author: carol
changed_files:
- README.md
- cmd/tailscale/main.go
expect:
status: pending
rules:
"tailcfg changes approved by control-protocol-owners": skipped
"tailcfg changes overridden by another tailscale/dev": skipped
"any tailscale/dev review": pending
- name: non-tailcfg PR with dev review is approved
pull_request:
author: carol
changed_files:
- README.md
reviews:
- user: dave
state: approved
expect:
status: approved
rules:
"any tailscale/dev review": approved
- name: non-tailcfg PR with thumbs-up comment from dev is approved
pull_request:
author: carol
changed_files:
- README.md
comments:
- user: dave
body: ":+1:"
expect:
status: approved
rules:
"any tailscale/dev review": approved
- name: non-tailcfg PR with non-dev review is pending
pull_request:
author: carol
changed_files:
- README.md
reviews:
- user: outsider
state: approved
expect:
status: pending
rules:
"any tailscale/dev review": pending
- name: PR with no files at all is pending without a dev review
pull_request:
author: carol
changed_files: []
expect:
status: pending
rules:
"any tailscale/dev review": pending
# ------------------------------------------------------------------
# tailcfg/ requires BOTH a control-protocol-owners review (or an
# override comment) AND a dev +1. Owners are in tailscale/dev too,
# so their single review satisfies both gates.
# ------------------------------------------------------------------
- name: tailcfg change without anything is pending
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
expect:
status: pending
rules:
"tailcfg changes approved by control-protocol-owners": pending
"tailcfg changes overridden by another tailscale/dev": pending
"any tailscale/dev review": pending
- name: a single owner review approves both the owner rule and the dev rule
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
reviews:
- user: alice
state: approved
expect:
status: approved
rules:
"tailcfg changes approved by control-protocol-owners": approved
"any tailscale/dev review": approved
- name: owner cannot self-approve their own tailcfg PR
pull_request:
author: alice
changed_files:
- tailcfg/tailcfg.go
reviews:
- user: alice
state: approved
expect:
status: pending
- name: non-owner dev review alone leaves the tailcfg gate pending
pull_request:
author: alice
changed_files:
- tailcfg/tailcfg.go
reviews:
- user: carol
state: approved
expect:
status: pending
rules:
"tailcfg changes approved by control-protocol-owners": pending
"tailcfg changes overridden by another tailscale/dev": pending
"any tailscale/dev review": approved
# ------------------------------------------------------------------
# policybot-override flow on a tailcfg/ PR.
# ------------------------------------------------------------------
# An override comment by itself only satisfies the override rule.
# The baseline dev-review rule still needs a separate +1.
- name: override comment alone leaves baseline dev review pending
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: dave
body: "policybot-override: emergency rollback"
expect:
status: pending
rules:
"tailcfg changes overridden by another tailscale/dev": approved
"any tailscale/dev review": pending
- name: override comment plus a separate dev review approves
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: dave
body: "policybot-override: emergency rollback"
reviews:
- user: eve
state: approved
expect:
status: approved
# One dev doing BOTH (post the override AND a regular +1) works too.
- name: same dev posts both the override and a +1 review
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: dave
body: "policybot-override: shipping a typo fix"
reviews:
- user: dave
state: approved
expect:
status: approved
- name: override comment from the author does not approve the override
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: carol
body: "policybot-override: please let me ship this"
expect:
status: pending
rules:
"tailcfg changes overridden by another tailscale/dev": pending
- name: override comment from a non-dev does not approve the override
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: outsider
body: "policybot-override: I am not on the dev team"
expect:
rules:
"tailcfg changes overridden by another tailscale/dev": pending
- name: empty-reason policybot-override does not approve
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: dave
body: "policybot-override:"
expect:
rules:
"tailcfg changes overridden by another tailscale/dev": pending
# Defaults regression guards: a normal review approval from a dev
# member must NOT silently approve the override rule. Same for a
# plain :+1: comment. Without the explicit `methods: github_review:
# false` / `comments: []` on the override rule, both would.
- name: a regular dev review does not silently satisfy the override rule
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
reviews:
- user: dave
state: approved
expect:
rules:
"tailcfg changes overridden by another tailscale/dev": pending
- name: a thumbs-up comment from a dev does not silently satisfy the override rule
pull_request:
author: carol
changed_files:
- tailcfg/tailcfg.go
comments:
- user: dave
body: ":+1:"
expect:
rules:
"tailcfg changes overridden by another tailscale/dev": pending
-84
View File
@@ -1,84 +0,0 @@
# Approval policy for this repository, enforced by policy-bot
# (https://github.com/palantir/policy-bot) running at
# https://policybot.corp.ts.net.
#
# This file replaces the role GitHub's CODEOWNERS played: when a pull
# request touches a path covered by a rule below, policy-bot posts a
# status check that blocks merging until the required reviewers approve.
#
# Policy and rule syntax reference:
# https://github.com/palantir/policy-bot/blob/develop/README.md
# Example policy files (team-approval, disapproval, remote, etc.):
# https://github.com/palantir/policy-bot/tree/develop/config/policy-examples
#
# Do not add to this policy without wide discussion.
# See https://github.com/tailscale/corp/issues/13972.
policy:
approval:
# tailcfg/ has an extra gate: either a control-protocol-owners
# review or an explicit policybot-override: comment from a dev.
# The block is skipped on PRs that don't touch tailcfg/.
- or:
- tailcfg changes approved by control-protocol-owners
- tailcfg changes overridden by another tailscale/dev
# And every PR, regardless of files, needs a +1 from a dev.
# This rule also keeps the policy from collapsing into "all rules
# skipped" (which policy-bot treats as a failure) on non-tailcfg
# PRs.
- any tailscale/dev review
approval_rules:
- name: tailcfg changes approved by control-protocol-owners
if:
changed_files:
paths:
- "^tailcfg/"
requires:
count: 1
teams:
- "tailscale/control-protocol-owners"
- name: tailcfg changes overridden by another tailscale/dev
description: |
Any member of @tailscale/dev (other than the PR author) can
override the control-protocol-owners requirement by leaving a
comment of the form
policybot-override: <reason>
on the pull request. The reason can be anything but should
explain why the override is appropriate; it stays in the PR
conversation as a record. The override comment also counts as
that developer's approval.
if:
changed_files:
paths:
- "^tailcfg/"
requires:
count: 1
teams:
- "tailscale/dev"
options:
methods:
# Explicitly turn off the defaults (github_review: true,
# comments: [":+1:", "👍"]) so the ONLY way to satisfy this
# rule is a "policybot-override:" comment. Otherwise a normal
# review approval or thumbs-up from any tailscale/dev member
# would silently pass the rule.
github_review: false
comments: []
comment_patterns:
- '^policybot-override: \S.*'
- name: any tailscale/dev review
description: |
Every PR needs at least one approval from a member of
@tailscale/dev. policy-bot's default approval methods count
a GitHub review approval, a ":+1:" comment, or a "👍"
comment as approval. The PR author cannot approve their
own PR.
requires:
count: 1
teams:
- "tailscale/dev"
+1 -7
View File
@@ -1,7 +1 @@
# This repository does NOT use GitHub's CODEOWNERS for review enforcement.
# Approval policies live in .policy.yml at the repository root and are
# enforced by policy-bot (https://github.com/palantir/policy-bot).
#
# To change required reviewers for a path, edit .policy.yml.
#
# See https://github.com/tailscale/corp/issues/13972.
/tailcfg/ @tailscale/control-protocol-owners
+5 -56
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,66 +137,15 @@ publishdevproxy: check-image-repo ## Build and publish k8s-proxy image to locati
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
@GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
echo "Testing on ubuntu:focal, ubuntu:jammy, ubuntu:noble, alpine:latest (in parallel)" && \
docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers & \
docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers & \
docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers & \
docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers & \
wait
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers
.PHONY: generate
generate: ## Generate code
./tool/go generate ./...
.PHONY: tsapp-build-and-flash-pi
tsapp-build-and-flash-pi: ## Build a tsapp-pi.arm64 GAF from HEAD and flash a local SD card (macOS auto-detects the disk; pass DISK=/dev/sdX on Linux)
cd gokrazy && ../tool/go run build.go --gaf --app=tsapp-pi.arm64
./tool/go run --exec=sudo ./cmd/tailscale configure flash-appliance \
--variant=pi-arm64 \
--gaf=gokrazy/tsapp-pi.arm64.gaf \
$(if $(DISK),--disk=$(DISK)) \
$(if $(wildcard $(HOME)/.ssh/id_ed25519.pub),--add-ssh-authorized-keys=$(HOME)/.ssh/id_ed25519.pub)
.PHONY: tsapp-qemu-pi
tsapp-qemu-pi: ## Build tsapp-pi.arm64 and boot it under qemu-system-aarch64 with a framebuffer GUI window and working network (requires mtools, dtc, qemu-efi-aarch64)
cd gokrazy && ../tool/go run build.go --build --app=tsapp-pi.arm64
# Extract the kernel from the FAT boot partition for direct -kernel boot.
rm -f gokrazy/tsapp-pi.arm64.vmlinuz
mcopy -i gokrazy/tsapp-pi.arm64.img@@4194304 ::vmlinuz gokrazy/tsapp-pi.arm64.vmlinuz
# Use the "virt" machine (not raspi3b) because it provides working
# PCI e1000 networking and, with UEFI firmware, an EFI framebuffer
# via the ramfb device. The raspi3b machine's USB NIC emulation is
# too broken for DHCP and its SoC watchdog reboots the guest.
#
# Find the UEFI firmware. Common paths:
# Debian/Ubuntu: /usr/share/qemu-efi-aarch64/QEMU_EFI.fd
# Homebrew: /opt/homebrew/share/qemu/edk2-aarch64-code.fd
# Fedora: /usr/share/edk2/aarch64/QEMU_EFI.fd
QEMU_EFI=$$(for f in \
/usr/share/qemu-efi-aarch64/QEMU_EFI.fd \
/opt/homebrew/share/qemu/edk2-aarch64-code.fd \
/usr/share/edk2/aarch64/QEMU_EFI.fd \
$$(dirname $$(which qemu-system-aarch64))/../share/qemu/edk2-aarch64-code.fd; do \
[ -f "$$f" ] && echo "$$f" && break; \
done) && \
[ -n "$$QEMU_EFI" ] || { echo "error: cannot find QEMU EFI firmware (install qemu-efi-aarch64)"; exit 1; } && \
qemu-system-aarch64 \
-M virt -cpu cortex-a53 -m 1G \
-bios "$$QEMU_EFI" \
-device ramfb \
-device e1000,netdev=net0 -netdev user,id=net0 \
-kernel gokrazy/tsapp-pi.arm64.vmlinuz \
-append "console=ttyAMA0,115200 nowatchdog gokrazy.log_to_serial=1 root=PARTUUID=60c24cc1-f3f9-427a-8199-dd02023b0001/PARTNROFF=1 ro init=/gokrazy/init rootwait" \
-drive file=gokrazy/tsapp-pi.arm64.img,format=raw,if=none,id=disk0 \
-device virtio-blk-device,drive=disk0 \
-serial mon:stdio
.PHONY: tsapp-push-pi
tsapp-push-pi: ## Build a tsapp-pi.arm64 GAF from HEAD and push it to a running Pi over the network (pass PI=<ip>)
@[ -n "$(PI)" ] || { echo "usage: make tsapp-push-pi PI=<ip-address>"; exit 1; }
cd gokrazy && ../tool/go run build.go --gaf --app=tsapp-pi.arm64
./tool/go run ./gokrazy/gafpush --gaf=gokrazy/tsapp-pi.arm64.gaf --pi=$(PI)
.PHONY: pin-github-actions
pin-github-actions:
./tool/go tool github.com/stacklok/frizbee actions .github/workflows
+1 -1
View File
@@ -1 +1 @@
1.103.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
+39 -23
View File
@@ -5,20 +5,18 @@ package appc
import (
"cmp"
"fmt"
"slices"
"strings"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/types/appctype"
"tailscale.com/types/dnstype"
"tailscale.com/util/mak"
"tailscale.com/util/set"
)
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
func isPeerEligibleConnector(peer tailcfg.NodeView) bool {
func isEligibleConnector(peer tailcfg.NodeView) bool {
if !peer.Valid() || !peer.Hostinfo().Valid() {
return false
}
@@ -41,7 +39,7 @@ func sortByPreference(ns []tailcfg.NodeView) {
func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.NodeView {
appTagsSet := set.SetOf(app.Connectors)
matches := nb.AppendMatchingPeers(nil, func(n tailcfg.NodeView) bool {
if !isPeerEligibleConnector(n) {
if !isEligibleConnector(n) {
return false
}
for _, t := range n.Tags().All() {
@@ -55,32 +53,50 @@ func PickConnector(nb ipnext.NodeBackend, app appctype.Conn25Attr) []tailcfg.Nod
return matches
}
// DNSAddrScheme is the custom URI scheme used for conn25-managed split DNS
// entries to determine the destination at query time rather than configuration
// time.
const DNSAddrScheme = "tailscale-app"
func AppDNSRoutes(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView) map[string][]*dnstype.Resolver {
// PickSplitDNSPeers looks at the netmap peers capabilities and finds which peers
// want to be connectors for which domains.
func PickSplitDNSPeers(hasCap func(c tailcfg.NodeCapability) bool, self tailcfg.NodeView, peers map[tailcfg.NodeID]tailcfg.NodeView) map[string][]tailcfg.NodeView {
var m map[string][]tailcfg.NodeView
if !hasCap(AppConnectorsExperimentalAttrName) {
return nil
return m
}
apps, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorAttr](self.CapMap(), AppConnectorsExperimentalAttrName)
if err != nil {
return nil
return m
}
appNamesByDomain := map[string]string{}
tagToDomain := make(map[string][]string)
for _, app := range apps {
for _, domain := range app.Domains {
domain, _ = strings.CutPrefix(domain, "*.")
domain = strings.ToLower(domain)
// in the case of multiple apps specifying the same domain (which is misconfiguration
// that should be validated at point of input) last write wins.
appNamesByDomain[domain] = app.Name
for _, tag := range app.Connectors {
tagToDomain[tag] = append(tagToDomain[tag], app.Domains...)
}
}
m := make(map[string][]*dnstype.Resolver, len(appNamesByDomain))
for domain, appName := range appNamesByDomain {
m[domain] = []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName), UseWithExitNode: true}}
// NodeIDs are Comparable, and we have a map of NodeID to NodeView anyway, so
// use a Set of NodeIDs to deduplicate, and populate into a []NodeView later.
var work map[string]set.Set[tailcfg.NodeID]
for _, peer := range peers {
if !isEligibleConnector(peer) {
continue
}
for _, t := range peer.Tags().All() {
domains := tagToDomain[t]
for _, domain := range domains {
if work[domain] == nil {
mak.Set(&work, domain, set.Set[tailcfg.NodeID]{})
}
work[domain].Add(peer.ID())
}
}
}
// Populate m. Make a []tailcfg.NodeView from []tailcfg.NodeID using the peers map.
// And sort it to our preference.
for domain, ids := range work {
nodes := make([]tailcfg.NodeView, 0, ids.Len())
for id := range ids {
nodes = append(nodes, peers[id])
}
sortByPreference(nodes)
mak.Set(&m, domain, nodes)
}
return m
}
+63 -82
View File
@@ -5,18 +5,17 @@ package appc
import (
"encoding/json"
"fmt"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/ipn/ipnext"
"tailscale.com/tailcfg"
"tailscale.com/types/appctype"
"tailscale.com/types/dnstype"
"tailscale.com/types/opt"
)
func TestAppDNSRoutes(t *testing.T) {
func TestPickSplitDNSPeers(t *testing.T) {
getBytesForAttr := func(name string, domains []string, tags []string) []byte {
attr := appctype.AppConnectorAttr{
Name: name,
@@ -33,105 +32,83 @@ func TestAppDNSRoutes(t *testing.T) {
appTwoBytes := getBytesForAttr("app2", []string{"a.example.com"}, []string{"tag:two"})
appThreeBytes := getBytesForAttr("app3", []string{"woo.b.example.com", "hoo.b.example.com"}, []string{"tag:three1", "tag:three2"})
appFourBytes := getBytesForAttr("app4", []string{"woo.b.example.com", "c.example.com"}, []string{"tag:four1", "tag:four2"})
appFiveBytes := getBytesForAttr("app5", []string{"*.example.com", "example.com"}, []string{"tag:one"})
appSixBytes := getBytesForAttr("app6", []string{"*.Example.com", "EXAMPLE.com", "EXAMPLE.COM"}, []string{"tag:one"})
resolver := func(appName string) []*dnstype.Resolver {
return []*dnstype.Resolver{{Addr: fmt.Sprintf("%s:%s", DNSAddrScheme, appName), UseWithExitNode: true}}
makeNodeView := func(id tailcfg.NodeID, name string, tags []string) tailcfg.NodeView {
return (&tailcfg.Node{
ID: id,
Name: name,
Tags: tags,
Hostinfo: (&tailcfg.Hostinfo{AppConnector: opt.NewBool(true)}).View(),
}).View()
}
nvp1 := makeNodeView(1, "p1", []string{"tag:one"})
nvp2 := makeNodeView(2, "p2", []string{"tag:four1", "tag:four2"})
nvp3 := makeNodeView(3, "p3", []string{"tag:two", "tag:three1"})
nvp4 := makeNodeView(4, "p4", []string{"tag:two", "tag:three2", "tag:four2"})
for _, tt := range []struct {
name string
hasCap bool
want map[string][]tailcfg.NodeView
peers []tailcfg.NodeView
config []tailcfg.RawMessage
want map[string][]*dnstype.Resolver
}{
{
name: "no-capability", // hasCap false should return nil regardless of config.
hasCap: false,
name: "empty",
},
{
name: "no-apps", // hasCap true but no configured apps returns an empty map.
hasCap: true,
want: map[string][]*dnstype.Resolver{},
},
{
name: "bad-config", // bad config should return nil rather than error.
hasCap: true,
name: "bad-config", // bad config should return a nil map rather than error.
config: []tailcfg.RawMessage{tailcfg.RawMessage(`hey`)},
},
{
name: "single-app",
hasCap: true,
name: "no-peers",
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
want: map[string][]*dnstype.Resolver{
"example.com": resolver("app1"),
},
{
name: "peers-that-are-not-connectors",
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
peers: []tailcfg.NodeView{
(&tailcfg.Node{
ID: 5,
Name: "p5",
Tags: []string{"tag:one"},
}).View(),
(&tailcfg.Node{
ID: 6,
Name: "p6",
Tags: []string{"tag:one"},
}).View(),
},
},
{
name: "single-app-multi-domain",
hasCap: true,
config: []tailcfg.RawMessage{tailcfg.RawMessage(appThreeBytes)},
want: map[string][]*dnstype.Resolver{
"woo.b.example.com": resolver("app3"),
"hoo.b.example.com": resolver("app3"),
name: "peers-that-dont-match-tags",
config: []tailcfg.RawMessage{tailcfg.RawMessage(appOneBytes)},
peers: []tailcfg.NodeView{
makeNodeView(5, "p5", []string{"tag:seven"}),
makeNodeView(6, "p6", nil),
},
},
{
name: "multi-app-no-overlap",
hasCap: true,
name: "matching-tagged-connector-peers",
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appOneBytes),
tailcfg.RawMessage(appTwoBytes),
tailcfg.RawMessage(appThreeBytes),
tailcfg.RawMessage(appFourBytes),
},
want: map[string][]*dnstype.Resolver{
"example.com": resolver("app1"),
"a.example.com": resolver("app2"),
peers: []tailcfg.NodeView{
nvp1,
nvp2,
nvp3,
nvp4,
makeNodeView(5, "p5", nil),
},
},
{
name: "domain-collision-last-write-wins",
hasCap: true,
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appThreeBytes), // app3: woo.b.example.com, hoo.b.example.com
tailcfg.RawMessage(appFourBytes), // app4: woo.b.example.com, c.example.com
},
want: map[string][]*dnstype.Resolver{
// app4 overwrites app3 for the shared domain
"woo.b.example.com": resolver("app4"),
"hoo.b.example.com": resolver("app3"),
"c.example.com": resolver("app4"),
},
},
{
name: "wildcards-are-stripped-and-deduped",
hasCap: true,
config: []tailcfg.RawMessage{tailcfg.RawMessage(appFiveBytes)},
want: map[string][]*dnstype.Resolver{
// *.example.com and example.com should both normalize to example.com.
"example.com": resolver("app5"),
},
},
{
name: "domains-are-normalized-and-deduped",
hasCap: true,
config: []tailcfg.RawMessage{tailcfg.RawMessage(appSixBytes)},
want: map[string][]*dnstype.Resolver{
// *.Example.com, EXAMPLE.com, EXAMPLE.COM should all normalize to example.com.
"example.com": resolver("app6"),
},
},
{
name: "sub-domains-and-top-domains-do-not-collide",
hasCap: true,
config: []tailcfg.RawMessage{
tailcfg.RawMessage(appTwoBytes),
tailcfg.RawMessage(appFiveBytes),
},
want: map[string][]*dnstype.Resolver{
// *.example.com normalizes to example.com; a.example.com remains distinct.
"a.example.com": resolver("app2"),
"example.com": resolver("app5"),
want: map[string][]tailcfg.NodeView{
// p5 has no matching tags and so doesn't appear
"example.com": {nvp1},
"a.example.com": {nvp3, nvp4},
"woo.b.example.com": {nvp2, nvp3, nvp4},
"hoo.b.example.com": {nvp3, nvp4},
"c.example.com": {nvp2, nvp4},
},
},
} {
@@ -143,11 +120,15 @@ func TestAppDNSRoutes(t *testing.T) {
}
}
selfView := selfNode.View()
got := AppDNSRoutes(func(_ tailcfg.NodeCapability) bool {
return tt.hasCap
}, selfView)
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Fatalf("AppDNSRoutes (-want, +got):\n%s", diff)
peers := map[tailcfg.NodeID]tailcfg.NodeView{}
for _, p := range tt.peers {
peers[p.ID()] = p
}
got := PickSplitDNSPeers(func(_ tailcfg.NodeCapability) bool {
return true
}, selfView, peers)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
})
}
-7
View File
@@ -51,13 +51,6 @@ while [ "$#" -gt 1 ]; do
ldflags="$ldflags -w -s"
tags="${tags:+$tags,},$(GOOS= GOARCH= $go run ./cmd/featuretags --min)"
;;
--strip)
# --min overrides your flags, when you're using custom tags and want to
# additionally strip symbols to help reduce the size, this is the easiest
# way to do it.
shift
ldflags="$ldflags -w -s"
;;
--box)
if [ ! -z "${TAGS:-}" ]; then
echo "set either --box or \$TAGS, but not both"
-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))
}
-54
View File
@@ -10,55 +10,13 @@ import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go4.org/mem"
)
// rateLimitedError is returned from cert-fetching methods when the
// upstream ACME CA reported a rate limit. Callers should unpack it via
// [RateLimitRetryAfter].
type rateLimitedError struct {
retryAfter time.Duration
underlying error
}
func (e rateLimitedError) Error() string { return e.underlying.Error() }
func (e rateLimitedError) Unwrap() error { return e.underlying }
// RateLimitRetryAfter reports whether err was a rate-limit failure from
// the upstream ACME CA and, if so, returns the CA's suggested wait
// (zero if none was provided).
func RateLimitRetryAfter(err error) (retryAfter time.Duration, ok bool) {
var rl rateLimitedError
if errors.As(err, &rl) {
return rl.retryAfter, true
}
return 0, false
}
// retryAfterFromHeader parses a Retry-After header, matching the
// delta-seconds + HTTP-date pattern in tempfork/acme/http.go.
func retryAfterFromHeader(h http.Header) time.Duration {
v := h.Get("Retry-After")
if i, err := strconv.Atoi(v); err == nil {
return time.Duration(i) * time.Second
}
t, err := http.ParseTime(v)
if err != nil {
return 0
}
d := time.Until(t)
if d < 0 {
return 0
}
return d
}
// SetDNS adds a DNS TXT record for the given domain name, containing
// the provided TXT value. The intended use case is answering
// LetsEncrypt/ACME dns-01 challenges.
@@ -85,8 +43,6 @@ func (lc *Client) SetDNS(ctx context.Context, name, value string) error {
//
// It returns a cached certificate from disk if it's still valid.
//
// Rate-limit failures can be identified via [RateLimitRetryAfter].
//
// Deprecated: use [Client.CertPair].
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return defaultClient.CertPair(ctx, domain)
@@ -96,8 +52,6 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e
//
// It returns a cached certificate from disk if it's still valid.
//
// Rate-limit failures can be identified via [RateLimitRetryAfter].
//
// API maturity: this is considered a stable API.
func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return lc.CertPairWithValidity(ctx, domain, 0)
@@ -111,18 +65,10 @@ func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM
// least the given duration, if permitted by the CA. If the certificate is
// valid, but for less than minValidity, it will be synchronously renewed.
//
// Rate-limit failures can be identified via [RateLimitRetryAfter].
//
// API maturity: this is considered a stable API.
func (lc *Client) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
if err != nil {
if hse, ok := errors.AsType[httpStatusError](err); ok && hse.HTTPStatus == http.StatusTooManyRequests {
return nil, nil, rateLimitedError{
retryAfter: retryAfterFromHeader(hse.Header),
underlying: err,
}
}
return nil, nil, err
}
// with ?type=pair, the response PEM is first the one private
-3
View File
@@ -50,9 +50,6 @@ type DebugPortmapOpts struct {
// process.
//
// opts can be nil; if so, default values will be used.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts) (io.ReadCloser, error) {
vals := make(url.Values)
if opts == nil {
+8 -309
View File
@@ -2,12 +2,6 @@
// SPDX-License-Identifier: BSD-3-Clause
// Package local contains a Go client for the Tailscale LocalAPI.
//
// The APIs in this package vary in maturity: some methods are considered
// stable APIs and are documented as such, while others are not necessarily
// stable and are subject to change between releases. Methods without an
// explicit "API maturity" note in their documentation should be assumed
// to be unstable.
package local
import (
@@ -141,9 +135,6 @@ func (lc *Client) defaultDialer(ctx context.Context, network, addr string) (net.
// authenticating to the local Tailscale daemon vary by platform.
//
// DoLocalRequest may mutate the request to add Authorization headers.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DoLocalRequest(req *http.Request) (*http.Response, error) {
req.Header.Set("Tailscale-Cap", strconv.Itoa(int(tailcfg.CurrentCapabilityVersion)))
lc.tsClientOnce.Do(func() {
@@ -289,7 +280,7 @@ func (lc *Client) sendWithHeaders(
}
if res.StatusCode != wantStatus {
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode, res.Header}
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
}
return slurp, res.Header, nil
}
@@ -297,7 +288,6 @@ func (lc *Client) sendWithHeaders(
type httpStatusError struct {
error
HTTPStatus int
Header http.Header
}
func (lc *Client) get200(ctx context.Context, path string) ([]byte, error) {
@@ -326,8 +316,6 @@ func decodeJSON[T any](b []byte) (ret T, err error) {
// For connections proxied by tailscaled, this looks up the owner of the given
// address as TCP first, falling back to UDP; if you want to only check a
// specific address family, use WhoIsProto.
//
// API maturity: this is considered a stable API.
func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
if err != nil {
@@ -339,39 +327,6 @@ func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsR
return decodeJSON[*apitype.WhoIsResponse](body)
}
// WhoIsForService is like [Client.WhoIs] but scopes the returned CapMap to
// capabilities that apply to the named VIP service. This enables per-service
// capability resolution on hosts that advertise multiple VIP services.
//
// API maturity: this is considered a stable API.
func (lc *Client) WhoIsForService(ctx context.Context, remoteAddr string, svcName tailcfg.ServiceName) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr)+"&svc_name="+url.QueryEscape(string(svcName)))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// WhoIsForIP is like [Client.WhoIs] but scopes the returned CapMap to
// capabilities that apply to the given destination IP. The IP may be a
// VIP service address, the node's own tailnet address, or any other
// routable IP the node handles.
//
// API maturity: this is considered a stable API.
func (lc *Client) WhoIsForIP(ctx context.Context, remoteAddr string, dst netip.Addr) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr)+"&dst_ip="+url.QueryEscape(dst.String()))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// ErrPeerNotFound is returned by [Client.WhoIs], [Client.WhoIsNodeKey] and
// [Client.WhoIsProto] when a peer is not found.
var ErrPeerNotFound = errors.New("peer not found")
@@ -379,8 +334,6 @@ var ErrPeerNotFound = errors.New("peer not found")
// WhoIsNodeKey returns the owner of the given wireguard public key.
//
// If not found, the error is ErrPeerNotFound.
//
// API maturity: this is considered a stable API.
func (lc *Client) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(key.String()))
if err != nil {
@@ -396,8 +349,6 @@ func (lc *Client) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apityp
// IP:port, for the given protocol (tcp or udp).
//
// If not found, the error is [ErrPeerNotFound].
//
// API maturity: this is considered a stable API.
func (lc *Client) WhoIsProto(ctx context.Context, proto, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?proto="+url.QueryEscape(proto)+"&addr="+url.QueryEscape(remoteAddr))
if err != nil {
@@ -474,9 +425,6 @@ func (lc *Client) SetGauge(ctx context.Context, name string, value int) error {
// TailDaemonLogs returns a stream the Tailscale daemon's logs as they arrive.
// Close the context to stop the stream.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/logtap", nil)
if err != nil {
@@ -493,18 +441,12 @@ func (lc *Client) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
}
// EventBusGraph returns a graph of active publishers and subscribers in the eventbus
// as a [eventbus.DebugTopics].
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
// as a [eventbus.DebugTopics]
func (lc *Client) EventBusGraph(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/debug-bus-graph")
}
// EventBusQueues returns a JSON snapshot of event bus queue depths per client.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) EventBusQueues(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/debug-bus-queues")
}
@@ -513,9 +455,6 @@ func (lc *Client) EventBusQueues(ctx context.Context) ([]byte, error) {
// Each pair is a valid event and a nil error, or a zero event a non-nil error.
// In case of error, the iterator ends after the pair reporting the error.
// Iteration stops if ctx ends.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) StreamBusEvents(ctx context.Context) iter.Seq2[eventbus.DebugEvent, error] {
return func(yield func(eventbus.DebugEvent, error) bool) {
req, err := http.NewRequestWithContext(ctx, "GET",
@@ -584,8 +523,6 @@ type BugReportOpts struct {
//
// The opts type specifies options to pass to the Tailscale daemon when
// generating this bug report.
//
// API maturity: this is considered a stable API.
func (lc *Client) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (string, error) {
qparams := make(url.Values)
if opts.Note != "" {
@@ -631,17 +568,12 @@ func (lc *Client) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (st
//
// This is the same as calling [Client.BugReportWithOpts] and only specifying the Note
// field.
//
// API maturity: this is considered a stable API.
func (lc *Client) BugReport(ctx context.Context, note string) (string, error) {
return lc.BugReportWithOpts(ctx, BugReportOpts{Note: note})
}
// DebugAction invokes a debug action, such as "rebind" or "restun".
// These are development tools.
//
// API maturity: this method is not considered a stable API and is
// subject to change or removal between releases.
// These are development tools and subject to change or removal over time.
func (lc *Client) DebugAction(ctx context.Context, action string) error {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
if err != nil {
@@ -652,10 +584,7 @@ func (lc *Client) DebugAction(ctx context.Context, action string) error {
// DebugActionBody invokes a debug action with a body parameter, such as
// "debug-force-prefer-derp".
// These are development tools.
//
// API maturity: this method is not considered a stable API and is
// subject to change or removal between releases.
// These are development tools and subject to change or removal over time.
func (lc *Client) DebugActionBody(ctx context.Context, action string, rbody io.Reader) error {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, rbody)
if err != nil {
@@ -665,10 +594,7 @@ func (lc *Client) DebugActionBody(ctx context.Context, action string, rbody io.R
}
// DebugResultJSON invokes a debug action and returns its result as something JSON-able.
// These are development tools.
//
// API maturity: this method is not considered a stable API and is
// subject to change or removal between releases.
// These are development tools and subject to change or removal over time.
func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
if err != nil {
@@ -681,27 +607,6 @@ func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, erro
return x, nil
}
// GetDebugResultJSON invokes a debug action and decodes the JSON response
// into a value of type T. It avoids the marshal/unmarshal roundtrip that
// callers of [Client.DebugResultJSON] otherwise need to do to get a typed
// value.
//
// These are development tools.
//
// API maturity: this function is not considered a stable API and is
// subject to change or removal between releases.
func GetDebugResultJSON[T any](ctx context.Context, lc *Client, action string) (T, error) {
var v T
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
if err != nil {
return v, fmt.Errorf("error %w: %s", err, body)
}
if err := json.Unmarshal(body, &v); err != nil {
return v, err
}
return v, nil
}
// QueryOptionalFeatures queries the optional features supported by the Tailscale daemon.
func (lc *Client) QueryOptionalFeatures(ctx context.Context) (*apitype.OptionalFeatures, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-optional-features", 200, nil)
@@ -731,9 +636,6 @@ func (lc *Client) SetDevStoreKeyValue(ctx context.Context, key, value string) er
// SetComponentDebugLogging sets component's debug logging enabled for
// the provided duration. If the duration is in the past, the debug logging
// is disabled.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) SetComponentDebugLogging(ctx context.Context, component string, d time.Duration) error {
if !buildfeatures.HasDebug {
return feature.ErrUnavailable
@@ -762,8 +664,6 @@ func Status(ctx context.Context) (*ipnstate.Status, error) {
}
// Status returns the Tailscale daemon's status.
//
// API maturity: this is considered a stable API.
func (lc *Client) Status(ctx context.Context) (*ipnstate.Status, error) {
return lc.status(ctx, "")
}
@@ -774,8 +674,6 @@ func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
}
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
//
// API maturity: this is considered a stable API.
func (lc *Client) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
return lc.status(ctx, "?peers=false")
}
@@ -880,9 +778,6 @@ func (lc *Client) PushFile(ctx context.Context, target tailcfg.StableNodeID, siz
// CheckIPForwarding asks the local Tailscale daemon whether it looks like the
// machine is properly configured to forward IP packets as a subnet router
// or exit node.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) CheckIPForwarding(ctx context.Context) error {
if !buildfeatures.HasAdvertiseRoutes {
return nil
@@ -906,9 +801,6 @@ func (lc *Client) CheckIPForwarding(ctx context.Context) error {
// CheckUDPGROForwarding asks the local Tailscale daemon whether it looks like
// the machine is optimally configured to forward UDP packets as a subnet router
// or exit node.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) CheckUDPGROForwarding(ctx context.Context) error {
body, err := lc.get200(ctx, "/localapi/v0/check-udp-gro-forwarding")
if err != nil {
@@ -958,9 +850,6 @@ func (lc *Client) CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
return err
}
// GetPrefs returns the [ipn.Prefs] of the current Tailscale profile.
//
// API maturity: this is considered a stable API.
func (lc *Client) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
body, err := lc.get200(ctx, "/localapi/v0/prefs")
if err != nil {
@@ -978,8 +867,6 @@ func (lc *Client) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
// or a policy restriction. An optional reason or justification for the request can be
// provided as a context value using [apitype.RequestReasonKey]. If permitted by policy,
// access may be granted, and the reason will be logged for auditing purposes.
//
// API maturity: this is considered a stable API.
func (lc *Client) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
body, err := lc.send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, jsonBody(mp))
if err != nil {
@@ -990,9 +877,6 @@ func (lc *Client) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Pref
// GetDNSOSConfig returns the system DNS configuration for the current device.
// That is, it returns the DNS configuration that the system would use if Tailscale weren't being used.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {
if !buildfeatures.HasDNS {
return nil, feature.ErrUnavailable
@@ -1026,26 +910,7 @@ func (lc *Client) QueryDNS(ctx context.Context, name string, queryType string) (
return res.Bytes, res.Resolvers, nil
}
// StartLoginInteractive starts an interactive login, requesting a new
// auth URL from the control plane if a login flow is not already in
// progress. If one is, the existing auth URL is re-sent.
//
// The auth URL is not returned by this method; it is delivered
// asynchronously to IPN bus watchers (see [Client.WatchIPNBus]) as an
// [ipn.Notify] with a non-empty BrowseToURL field. StartLoginInteractive
// returns as soon as the login has been requested; it does not wait for
// the login to complete.
//
// Calling StartLoginInteractive does not itself change the node's
// desired run state, but successfully completing the login does: the
// node's WantRunning pref is set to true, so a stopped node
// ("tailscale down") starts once the login finishes. If the login is
// completed as a different user or node identity than the current
// profile's, the node switches to an existing profile matching the new
// identity if one exists, or else updates the current profile to the
// new identity.
//
// API maturity: this is considered a stable API.
// StartLoginInteractive starts an interactive login.
func (lc *Client) StartLoginInteractive(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil)
return err
@@ -1070,8 +935,6 @@ func (lc *Client) Logout(ctx context.Context) error {
// tailscaled), a FQDN, or an IP address.
//
// The ctx is only used for the duration of the call, not the lifetime of the [net.Conn].
//
// API maturity: this is considered a stable API.
func (lc *Client) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
return lc.UserDial(ctx, "tcp", host, port)
}
@@ -1083,8 +946,6 @@ func (lc *Client) DialTCP(ctx context.Context, host string, port uint16) (net.Co
//
// The ctx is only used for the duration of the call, not the lifetime of the
// [net.Conn].
//
// API maturity: this is considered a stable API.
func (lc *Client) UserDial(ctx context.Context, network, host string, port uint16) (net.Conn, error) {
connCh := make(chan net.Conn, 1)
trace := httptrace.ClientTrace{
@@ -1111,19 +972,6 @@ func (lc *Client) UserDial(ctx context.Context, network, host string, port uint1
if res.StatusCode != http.StatusSwitchingProtocols {
body, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == http.StatusOK && res.Header.Get("Dial-Self") == "true" {
// Server told us to dial the address ourselves rather than
// proxying through the daemon. This happens for non-Tailscale
// addresses where the daemon shouldn't dial as root on the
// client's behalf. The server provides the resolved address
// to avoid a TOCTOU race with DNS re-resolution.
addr := res.Header.Get("Dial-Addr")
if addr == "" {
return nil, errors.New("server returned Dial-Self without Dial-Addr")
}
var d net.Dialer
return d.DialContext(ctx, network, addr)
}
return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body)
}
// From here on, the underlying net.Conn is ours to use, but there
@@ -1149,10 +997,6 @@ func (lc *Client) UserDial(ctx context.Context, network, host string, port uint1
// CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled.
// It is intended to be used with netcheck to see availability of DERPs.
//
// API maturity: this is considered a stable API, though the returned
// [tailcfg.DERPMap] type is subject to minor changes over time; its
// general shape is stable.
func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
var derpMap tailcfg.DERPMap
res, err := lc.send(ctx, "GET", "/localapi/v0/derpmap", 200, nil)
@@ -1165,66 +1009,6 @@ func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error)
return &derpMap, nil
}
// CertDomains returns the list of domains for which the local tailscaled can
// fetch TLS certificates, equivalent to the DNS.CertDomains field of the
// current netmap. The returned list is sorted in ascending order, and is
// empty if no netmap has been received yet.
//
// API maturity: this is considered a stable API.
func (lc *Client) CertDomains(ctx context.Context) ([]string, error) {
body, err := lc.get200(ctx, "/localapi/v0/cert-domains")
if err != nil {
return nil, err
}
return decodeJSON[[]string](body)
}
// DNSConfig returns the [tailcfg.DNSConfig] from the current netmap.
// It returns an error if no netmap has been received yet.
// It is intended for callers that need fields like ExtraRecords or CertDomains
// without pulling the rest of the netmap.
func (lc *Client) DNSConfig(ctx context.Context) (*tailcfg.DNSConfig, error) {
body, err := lc.get200(ctx, "/localapi/v0/dns-config")
if err != nil {
return nil, err
}
return decodeJSON[*tailcfg.DNSConfig](body)
}
// PeerByID returns a peer's current full [tailcfg.Node] looked up by its
// [tailcfg.NodeID]. It returns an error if no peer with that NodeID is in the
// current netmap.
//
// It is intended for callers that observed a peer-mutation signal (e.g.
// [ipn.Notify.PeerChangedPatch] or [ipn.Notify.PeersChanged]) and want the
// latest state of the affected node without having to apply the patch
// themselves.
func (lc *Client) PeerByID(ctx context.Context, id tailcfg.NodeID) (*tailcfg.Node, error) {
body, err := lc.get200(ctx, "/localapi/v0/peer-by-id?id="+strconv.FormatInt(int64(id), 10))
if err != nil {
return nil, err
}
return decodeJSON[*tailcfg.Node](body)
}
// UserProfile returns the current [tailcfg.UserProfile] for the given
// [tailcfg.UserID]. It returns an error if no user with that UserID is in the
// current netmap.
//
// It is the LocalAPI fallback for IPN-bus consumers that see a UserID
// referenced by a peer Node and want to resolve it to a UserProfile. Sessions
// opted in to [ipn.NotifyPeerChanges] / [ipn.NotifyPeerPatches] also receive
// UserProfiles automatically via [ipn.Notify.UserProfiles].
//
// API maturity: this is considered a stable API.
func (lc *Client) UserProfile(ctx context.Context, id tailcfg.UserID) (*tailcfg.UserProfile, error) {
body, err := lc.get200(ctx, "/localapi/v0/user-profile?id="+strconv.FormatInt(int64(id), 10))
if err != nil {
return nil, err
}
return decodeJSON[*tailcfg.UserProfile](body)
}
// PingOpts contains options for the ping request.
//
// The zero value is valid, which means to use defaults.
@@ -1261,8 +1045,6 @@ func (lc *Client) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.Ping
// DisconnectControl shuts down all connections to control, thus making control consider this node inactive. This can be
// run on HA subnet router or app connector replicas before shutting them down to ensure peers get told to switch over
// to another replica whilst there is still some grace period for the existing connections to terminate.
//
// API maturity: this is considered a stable API.
func (lc *Client) DisconnectControl(ctx context.Context) error {
_, _, err := lc.sendWithHeaders(ctx, "POST", "/localapi/v0/disconnect-control", 200, nil, nil)
if err != nil {
@@ -1358,18 +1140,13 @@ func (lc *Client) ReloadConfig(ctx context.Context) (ok bool, err error) {
// SwitchToEmptyProfile creates and switches to a new unnamed profile. The new
// profile is not assigned an ID until it is persisted after a successful login.
// In order to login to the new profile, the user must call
// [Client.StartLoginInteractive].
//
// API maturity: this is considered a stable API.
// In order to login to the new profile, the user must call LoginInteractive.
func (lc *Client) SwitchToEmptyProfile(ctx context.Context) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/profiles/", http.StatusCreated, nil)
return err
}
// SwitchProfile switches to the given profile.
//
// API maturity: this is considered a stable API.
func (lc *Client) SwitchProfile(ctx context.Context, profile ipn.ProfileID) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/profiles/"+url.PathEscape(string(profile)), 204, nil)
return err
@@ -1404,11 +1181,6 @@ func (lc *Client) QueryFeature(ctx context.Context, feature string) (*tailcfg.Qu
return decodeJSON[*tailcfg.QueryFeatureResponse](body)
}
// DebugDERPRegion reports diagnostic information about the DERP region with
// the given ID or code.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*ipnstate.DebugDERPRegionReport, error) {
v := url.Values{"region": {regionIDOrCode}}
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-derp-region?"+v.Encode(), 200, nil)
@@ -1419,9 +1191,6 @@ func (lc *Client) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*
}
// DebugPacketFilterRules returns the packet filter rules for the current device.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterRule, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-packet-filter-rules", 200, nil)
if err != nil {
@@ -1433,9 +1202,6 @@ func (lc *Client) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterR
// DebugSetExpireIn marks the current node key to expire in d.
//
// This is meant primarily for debug and testing.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DebugSetExpireIn(ctx context.Context, d time.Duration) error {
v := url.Values{"expiry": {fmt.Sprint(time.Now().Add(d).Unix())}}
_, err := lc.send(ctx, "POST", "/localapi/v0/set-expiry-sooner?"+v.Encode(), 200, nil)
@@ -1444,9 +1210,6 @@ func (lc *Client) DebugSetExpireIn(ctx context.Context, d time.Duration) error {
// DebugPeerRelaySessions returns debug information about the current peer
// relay sessions running through this node.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DebugPeerRelaySessions(ctx context.Context) (*status.ServerStatus, error) {
body, err := lc.send(ctx, "GET", "/localapi/v0/debug-peer-relay-sessions", 200, nil)
if err != nil {
@@ -1459,9 +1222,6 @@ func (lc *Client) DebugPeerRelaySessions(ctx context.Context) (*status.ServerSta
//
// The provided context does not determine the lifetime of the
// returned [io.ReadCloser].
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-capture", nil)
if err != nil {
@@ -1488,16 +1248,9 @@ func (lc *Client) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error)
// resources.
//
// A default set of ipn.Notify messages are returned but the set can be modified by mask.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*IPNBusWatcher, error) {
m, err := mask.MarshalText()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET",
"http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+string(m),
"http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+fmt.Sprint(mask),
nil)
if err != nil {
return nil, err
@@ -1521,8 +1274,6 @@ func (lc *Client) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*IP
// CheckUpdate returns a [*tailcfg.ClientVersion] indicating whether or not an update is available
// to be installed via the LocalAPI. In case the LocalAPI can't install updates, it returns a
// ClientVersion that says that we are up to date.
//
// API maturity: this is considered a stable API.
func (lc *Client) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, error) {
body, err := lc.get200(ctx, "/localapi/v0/update/check")
if err != nil {
@@ -1539,8 +1290,6 @@ func (lc *Client) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, erro
// To turn it on, there must have been a previously used exit node.
// The most previously used one is reused.
// This is a convenience method for GUIs. To select an actual one, update the prefs.
//
// API maturity: this is considered a stable API.
func (lc *Client) SetUseExitNode(ctx context.Context, on bool) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/set-use-exit-node-enabled?enabled="+strconv.FormatBool(on), http.StatusOK, nil)
return err
@@ -1549,9 +1298,6 @@ func (lc *Client) SetUseExitNode(ctx context.Context, on bool) error {
// DriveSetServerAddr instructs Taildrive to use the server at addr to access
// the filesystem. This is used on platforms like Windows and MacOS to let
// Taildrive know to use the file server running in the GUI app.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DriveSetServerAddr(ctx context.Context, addr string) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/fileserver-address", http.StatusCreated, strings.NewReader(addr))
return err
@@ -1560,9 +1306,6 @@ func (lc *Client) DriveSetServerAddr(ctx context.Context, addr string) error {
// DriveShareSet adds or updates the given share in the list of shares that
// Taildrive will serve to remote nodes. If a share with the same name already
// exists, the existing share is replaced/updated.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DriveShareSet(ctx context.Context, share *drive.Share) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/shares", http.StatusCreated, jsonBody(share))
return err
@@ -1570,9 +1313,6 @@ func (lc *Client) DriveShareSet(ctx context.Context, share *drive.Share) error {
// DriveShareRemove removes the share with the given name from the list of
// shares that Taildrive will serve to remote nodes.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DriveShareRemove(ctx context.Context, name string) error {
_, err := lc.send(
ctx,
@@ -1584,9 +1324,6 @@ func (lc *Client) DriveShareRemove(ctx context.Context, name string) error {
}
// DriveShareRename renames the share from old to new name.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DriveShareRename(ctx context.Context, oldName, newName string) error {
_, err := lc.send(
ctx,
@@ -1599,9 +1336,6 @@ func (lc *Client) DriveShareRename(ctx context.Context, oldName, newName string)
// DriveShareList returns the list of shares that drive is currently serving
// to remote nodes.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) DriveShareList(ctx context.Context) ([]*drive.Share, error) {
result, err := lc.get200(ctx, "/localapi/v0/drive/shares")
if err != nil {
@@ -1658,25 +1392,8 @@ func (lc *Client) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggesti
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
}
// SuggestExitNodeWithProbe requests an exit node suggestion based on an immediate routecheck probe,
// waits for the probe to finish, and returns the exit node's details.
func (lc *Client) SuggestExitNodeWithProbe(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
if !buildfeatures.HasRouteCheck {
return apitype.ExitNodeSuggestionResponse{}, feature.ErrUnavailable
}
v := url.Values{"probe": {"true"}}
body, err := lc.send(ctx, "POST", "/localapi/v0/suggest-exit-node?"+v.Encode(), 200, nil)
if err != nil {
return apitype.ExitNodeSuggestionResponse{}, err
}
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
}
// CheckSOMarkInUse reports whether the socket mark option is in use. This will only
// be true if tailscale is running on Linux and tailscaled uses SO_MARK.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {
body, err := lc.get200(ctx, "/localapi/v0/check-so-mark-in-use")
if err != nil {
@@ -1693,19 +1410,11 @@ func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {
}
// ShutdownTailscaled requests a graceful shutdown of tailscaled.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) ShutdownTailscaled(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/shutdown", 200, nil)
return err
}
// GetAppConnectorRouteInfo returns the current [appctype.RouteInfo] for this
// node's app connector.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) GetAppConnectorRouteInfo(ctx context.Context) (appctype.RouteInfo, error) {
body, err := lc.get200(ctx, "/localapi/v0/appc-route-info")
if err != nil {
@@ -1713,13 +1422,3 @@ func (lc *Client) GetAppConnectorRouteInfo(ctx context.Context) (appctype.RouteI
}
return decodeJSON[appctype.RouteInfo](body)
}
// GetServices returns the Services visible to this node,
// including their names, IP addresses, and ports, keyed by service name.
func (lc *Client) GetServices(ctx context.Context) (map[tailcfg.ServiceName]tailcfg.ServiceDetails, error) {
body, err := lc.get200(ctx, "/localapi/v0/services")
if err != nil {
return nil, err
}
return decodeJSON[map[tailcfg.ServiceName]tailcfg.ServiceDetails](body)
}
-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{
-43
View File
@@ -1,43 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_routecheck
package local
import (
"context"
"errors"
"fmt"
"net/http"
"tailscale.com/net/routecheck"
)
// ErrReportPending is returned by [Client.RouteCheck] and [Client.RouteCheckProbe]
// when the report is pending.
var ErrRouteCheckReportUnavailable = errors.New("report pending")
// RouteCheckProbe performs a routecheck probe and waits for its report.
func (lc *Client) RouteCheckProbe(ctx context.Context) (*routecheck.Report, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/routecheck?probe=true", http.StatusOK, nil)
if err != nil {
if hs, ok := errors.AsType[httpStatusError](err); ok && hs.HTTPStatus == http.StatusNoContent {
return nil, ErrRouteCheckReportUnavailable
}
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[*routecheck.Report](body)
}
// RouteCheck requests the report compiled by the latest routecheck probe.
func (lc *Client) RouteCheck(ctx context.Context) (*routecheck.Report, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/routecheck", http.StatusOK, nil)
if err != nil {
if hs, ok := errors.AsType[httpStatusError](err); ok && hs.HTTPStatus == http.StatusNoContent {
return nil, ErrRouteCheckReportUnavailable
}
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[*routecheck.Report](body)
}
-3
View File
@@ -17,9 +17,6 @@ import (
// GetServeConfig return the current serve config.
//
// If the serve config is empty, it returns (nil, nil).
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) {
body, h, err := lc.sendWithHeaders(ctx, "GET", "/localapi/v0/serve-config", 200, nil, nil)
if err != nil {
-37
View File
@@ -1,37 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serviceclientprefs
package local
import (
"context"
"net/http"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/feature/serviceclientprefs/serviceclient"
)
// GetServiceClientPrefs returns all of the current profile's [serviceclient.Prefs].
//
// API maturity: this method is not considered a stable API and is subject to change between releases.
func (lc *Client) GetServiceClientPrefs(ctx context.Context) (serviceclient.Prefs, error) {
body, err := lc.get200(ctx, "/localapi/v0/prefs/service-clients")
if err != nil {
return nil, err
}
return decodeJSON[serviceclient.Prefs](body)
}
// SetServiceClientPref merges the non-empty fields from an [apitype.ServiceClientPrefRequest] into the
// saved service client prefs for the current profile and returns the full updated set.
//
// API maturity: this method is not considered a stable API and is subject to change between releases.
func (lc *Client) SetServiceClientPref(ctx context.Context, req apitype.ServiceClientPrefRequest) (serviceclient.Prefs, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/prefs/service-clients", http.StatusOK, jsonBody(req))
if err != nil {
return nil, err
}
return decodeJSON[serviceclient.Prefs](body)
}
-3
View File
@@ -13,9 +13,6 @@ import (
)
// GetEffectivePolicy returns the effective policy for the specified scope.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) GetEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
scopeID, err := scope.MarshalText()
if err != nil {
+29 -94
View File
@@ -18,22 +18,17 @@ import (
"tailscale.com/types/tkatype"
)
// TailnetLockStatus fetches information about the tailnet key authority, if one is configured.
func (lc *Client) TailnetLockStatus(ctx context.Context) (*ipnstate.TailnetLockStatus, error) {
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
return decodeJSON[*ipnstate.TailnetLockStatus](body)
return decodeJSON[*ipnstate.NetworkLockStatus](body)
}
// Deprecated: use [Client.TailnetLockStatus] instead.
func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.TailnetLockStatus, error) {
return lc.TailnetLockStatus(ctx)
}
// TailnetLockInit initializes the tailnet key authority.
func (lc *Client) TailnetLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.TailnetLockStatus, error) {
// NetworkLockInit initializes the tailnet key authority.
func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.NetworkLockStatus, error) {
var b bytes.Buffer
type initRequest struct {
Keys []tka.Key
@@ -49,17 +44,12 @@ func (lc *Client) TailnetLockInit(ctx context.Context, keys []tka.Key, disableme
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
return decodeJSON[*ipnstate.TailnetLockStatus](body)
return decodeJSON[*ipnstate.NetworkLockStatus](body)
}
// Deprecated: use [Client.TailnetLockInit] instead.
func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.TailnetLockStatus, error) {
return lc.TailnetLockInit(ctx, keys, disablementValues, supportDisablement)
}
// TailnetLockWrapPreauthKey wraps a pre-auth key with information to
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
// enable unattended bringup in the locked tailnet.
func (lc *Client) TailnetLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
encodedPrivate, err := tkaKey.MarshalText()
if err != nil {
return "", err
@@ -81,13 +71,8 @@ func (lc *Client) TailnetLockWrapPreauthKey(ctx context.Context, preauthKey stri
return string(body), nil
}
// Deprecated: use [Client.TailnetLockWrapPreauthKey] instead.
func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
return lc.TailnetLockWrapPreauthKey(ctx, preauthKey, tkaKey)
}
// TailnetLockModify adds and/or removes key(s) to the tailnet key authority.
func (lc *Client) TailnetLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
// NetworkLockModify adds and/or removes key(s) to the tailnet key authority.
func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
var b bytes.Buffer
type modifyRequest struct {
AddKeys []tka.Key
@@ -104,14 +89,9 @@ func (lc *Client) TailnetLockModify(ctx context.Context, addKeys, removeKeys []t
return nil
}
// Deprecated: use [Client.TailnetLockModify] instead.
func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
return lc.TailnetLockModify(ctx, addKeys, removeKeys)
}
// TailnetLockSign signs the specified node-key and transmits that signature to the control plane.
// NetworkLockSign signs the specified node-key and transmits that signature to the control plane.
// rotationPublic, if specified, must be an ed25519 public key.
func (lc *Client) TailnetLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
var b bytes.Buffer
type signRequest struct {
NodeKey key.NodePublic
@@ -128,13 +108,8 @@ func (lc *Client) TailnetLockSign(ctx context.Context, nodeKey key.NodePublic, r
return nil
}
// Deprecated: use [Client.TailnetLockSign] instead.
func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
return lc.TailnetLockSign(ctx, nodeKey, rotationPublic)
}
// TailnetLockAffectedSigs returns all signatures signed by the specified keyID.
func (lc *Client) TailnetLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/affected-sigs", 200, bytes.NewReader(keyID))
if err != nil {
return nil, fmt.Errorf("error: %w", err)
@@ -142,29 +117,19 @@ func (lc *Client) TailnetLockAffectedSigs(ctx context.Context, keyID tkatype.Key
return decodeJSON[[]tkatype.MarshaledSignature](body)
}
// Deprecated: use [Client.TailnetLockAffectedSigs] instead.
func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
return lc.TailnetLockAffectedSigs(ctx, keyID)
}
// TailnetLockLog returns up to maxEntries number of changes to tailnet-lock state.
func (lc *Client) TailnetLockLog(ctx context.Context, maxEntries int) ([]ipnstate.TailnetLockUpdate, error) {
// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
v := url.Values{}
v.Set("limit", fmt.Sprint(maxEntries))
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/log?"+v.Encode(), 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[[]ipnstate.TailnetLockUpdate](body)
return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
}
// Deprecated: use [Client.TailnetLockLog] instead.
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.TailnetLockUpdate, error) {
return lc.TailnetLockLog(ctx, maxEntries)
}
// TailnetLockForceLocalDisable forcibly shuts down tailnet lock on this node.
func (lc *Client) TailnetLockForceLocalDisable(ctx context.Context) error {
// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
// This endpoint expects an empty JSON stanza as the payload.
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil {
@@ -177,14 +142,9 @@ func (lc *Client) TailnetLockForceLocalDisable(ctx context.Context) error {
return nil
}
// Deprecated: use [Client.TailnetLockForceLocalDisable] instead.
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
return lc.TailnetLockForceLocalDisable(ctx)
}
// TailnetLockVerifySigningDeeplink verifies the tailnet lock deeplink contained
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
// in url and returns information extracted from it.
func (lc *Client) TailnetLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
vr := struct {
URL string
}{url}
@@ -197,13 +157,8 @@ func (lc *Client) TailnetLockVerifySigningDeeplink(ctx context.Context, url stri
return decodeJSON[*tka.DeeplinkValidationResult](body)
}
// Deprecated: use [Client.TailnetLockVerifySigningDeeplink] instead.
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
return lc.TailnetLockVerifySigningDeeplink(ctx, url)
}
// TailnetLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
func (lc *Client) TailnetLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
// NetworkLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
vr := struct {
Keys []tkatype.KeyID
ForkFrom string
@@ -217,13 +172,8 @@ func (lc *Client) TailnetLockGenRecoveryAUM(ctx context.Context, removeKeys []tk
return body, nil
}
// Deprecated: use [Client.TailnetLockGenRecoveryAUM] instead.
func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
return lc.TailnetLockGenRecoveryAUM(ctx, removeKeys, forkFrom)
}
// TailnetLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
func (lc *Client) TailnetLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
// NetworkLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
r := bytes.NewReader(aum.Serialize())
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
if err != nil {
@@ -233,13 +183,8 @@ func (lc *Client) TailnetLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM)
return body, nil
}
// Deprecated: use [Client.TailnetLockCosignRecoveryAUM] instead.
func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
return lc.TailnetLockCosignRecoveryAUM(ctx, aum)
}
// TailnetLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
func (lc *Client) TailnetLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
r := bytes.NewReader(aum.Serialize())
_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
if err != nil {
@@ -248,20 +193,10 @@ func (lc *Client) TailnetLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM)
return nil
}
// Deprecated: use [Client.TailnetLockSubmitRecoveryAUM] instead.
func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
return lc.TailnetLockSubmitRecoveryAUM(ctx, aum)
}
// TailnetLockDisable shuts down tailnet-lock across the tailnet.
func (lc *Client) TailnetLockDisable(ctx context.Context, secret []byte) error {
// NetworkLockDisable shuts down network-lock across the tailnet.
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
return fmt.Errorf("error: %w", err)
}
return nil
}
// Deprecated: use [Client.TailnetLockDisable] instead.
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
return lc.TailnetLockDisable(ctx, secret)
}
+4 -42
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}
red = color.NRGBA{229, 111, 74, 255}
transparent = color.NRGBA{}
// default values to dark theme
bg = black
fg = white
gray = darkGray
bg = color.NRGBA{0, 0, 0, 255}
fg = color.NRGBA{255, 255, 255, 255}
gray = color.NRGBA{255, 255, 255, 102}
red = color.NRGBA{229, 111, 74, 255}
)
// SetTheme sets the color theme of the systray icon.
//
// Supported themes are:
// - dark - white and gray dots over black background
// - dark:nobg - white and grey dots over transparent background
// - light - black and gray dots over white background
// - light:nobg - black and grey dots over transparent background
func SetTheme(theme string) {
switch theme {
case "dark":
bg = black
fg = white
gray = darkGray
case "dark:nobg":
bg = transparent
fg = white
gray = darkGray
case "light":
bg = white
fg = black
gray = lightGray
case "light:nobg":
bg = transparent
fg = black
gray = lightGray
default:
log.Printf("unknown theme: %q", theme)
}
}
// render returns a PNG image of the logo.
func (logo tsLogo) render() *bytes.Buffer {
const borderUnits = 1
+1
View File
@@ -3,6 +3,7 @@
//go:build cgo || !darwin
// Package systray provides a minimal Tailscale systray application.
package systray
import (
+34 -68
View File
@@ -69,11 +69,6 @@ func (menu *Menu) Run(client *local.Client) {
go menu.lc.SetGauge(menu.bgCtx, "systray_running", 1)
defer menu.lc.SetGauge(menu.bgCtx, "systray_running", 0)
// set initial title, which is used by the systray package as the ID of the StatusNotifierItem.
// This value will get overwritten later as the client status changes.
// This must be called before systray.Run.
systray.SetTitle("tailscale")
systray.Run(menu.onReady, menu.onExit)
}
@@ -177,6 +172,10 @@ See https://tailscale.com/kb/1597/linux-systray for more information.`)
}
setAppIcon(disconnected)
// set initial title, which is used by the systray package as the ID of the StatusNotifierItem.
// This value will get overwritten later as the client status changes.
systray.SetTitle("tailscale")
menu.rebuild()
menu.mu.Lock()
@@ -293,23 +292,21 @@ func (menu *Menu) rebuild() {
accounts := systray.AddMenuItem(account, "")
setRemoteIcon(accounts, menu.curProfile.UserProfile.ProfilePicURL)
time.Sleep(newMenuDelay)
if len(menu.allProfiles) > 1 {
for _, profile := range menu.allProfiles {
title := profileTitle(profile)
var item *systray.MenuItem
if profile.ID == menu.curProfile.ID {
item = accounts.AddSubMenuItemCheckbox(title, "", true)
} else {
item = accounts.AddSubMenuItem(title, "")
}
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
onClick(ctx, item, func(ctx context.Context) {
select {
case <-ctx.Done():
case menu.accountsCh <- profile.ID:
}
})
for _, profile := range menu.allProfiles {
title := profileTitle(profile)
var item *systray.MenuItem
if profile.ID == menu.curProfile.ID {
item = accounts.AddSubMenuItemCheckbox(title, "", true)
} else {
item = accounts.AddSubMenuItem(title, "")
}
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
onClick(ctx, item, func(ctx context.Context) {
select {
case <-ctx.Done():
case menu.accountsCh <- profile.ID:
}
})
}
}
@@ -355,27 +352,16 @@ func (menu *Menu) rebuild() {
// profileTitle returns the title string for a profile menu item.
func profileTitle(profile ipn.LoginProfile) string {
tailnet := ""
title := profile.Name
if profile.NetworkProfile.DomainName != "" {
tailnet = profile.NetworkProfile.DisplayNameOrDefault()
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
// windows and mac don't support multi-line menu
title += " (" + profile.NetworkProfile.DisplayNameOrDefault() + ")"
} else {
title += "\n" + profile.NetworkProfile.DisplayNameOrDefault()
}
}
// windows and mac don't support multi-line menu items.
multiline := runtime.GOOS != "windows" && runtime.GOOS != "darwin"
return formatProfileTitle(profile.Name, tailnet, multiline)
}
// formatProfileTitle builds a profile menu label from a login name and an
// optional tailnet name. The tailnet portion is omitted when it matches the
// login name, so single-user tailnets don't show the same string twice.
func formatProfileTitle(name, tailnet string, multiline bool) string {
if tailnet == "" || strings.EqualFold(name, tailnet) {
return name
}
if multiline {
return name + "\n" + tailnet
}
return name + " (" + tailnet + ")"
return title
}
var (
@@ -635,9 +621,11 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
title += strings.Split(sugg.Name, ".")[0]
}
menu.exitNodes.AddSeparator()
active := recommendedIsActive(status, sugg.ID, sugg.Location.CountryCode(), sugg.Location.City())
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", active)
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
setExitNodeOnClick(rm, sugg.ID)
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
rm.Check()
}
}
}
@@ -659,11 +647,13 @@ func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
if !ps.Online {
name += " (offline)"
}
active := status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", active)
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", false)
if !ps.Online {
sm.Disable()
}
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
sm.Check()
}
setExitNodeOnClick(sm, ps.ID)
}
}
@@ -753,30 +743,6 @@ func (mc *mvCountry) sortedCities() []*mvCity {
return cities
}
// recommendedIsActive reports whether the suggested exit node corresponds to
// the currently active exit node in status.
func recommendedIsActive(status *ipnstate.Status, suggID tailcfg.StableNodeID, suggCountry, suggCity string) bool {
if status == nil || status.ExitNodeStatus == nil || status.ExitNodeStatus.ID.IsZero() {
return false
}
if suggID == status.ExitNodeStatus.ID {
return true
}
if suggCountry == "" || suggCity == "" {
return false
}
for _, p := range status.Peer {
if p.ID != status.ExitNodeStatus.ID {
continue
}
if loc := p.Location; loc != nil && loc.CountryCode == suggCountry && loc.City == suggCity {
return true
}
return false
}
return false
}
// countryFlag takes a 2-character ASCII string and returns the corresponding emoji flag.
// It returns the empty string on error.
func countryFlag(code string) string {
-147
View File
@@ -1,147 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build cgo || !darwin
package systray
import (
"testing"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
func TestProfileTitleMultiline(t *testing.T) {
t.Parallel()
tests := []struct {
name string
login string
tailnet string
multiline bool
want string
}{
{"no_tailnet", "alice@example.com", "", true, "alice@example.com"},
{"dup_exact", "example.com", "example.com", true, "example.com"},
{"dup_casefold", "Example.com", "example.com", false, "Example.com"},
{"distinct_multiline", "alice@example.com", "example.com", true, "alice@example.com\nexample.com"},
{"distinct_singleline", "alice@example.com", "example.com", false, "alice@example.com (example.com)"},
{"empty", "", "", true, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := formatProfileTitle(tt.login, tt.tailnet, tt.multiline); got != tt.want {
t.Errorf("profileTitleMultiline; got %v, want %v", got, tt.want)
}
})
}
}
func TestRecommendedIsActive(t *testing.T) {
t.Parallel()
const (
activeID = tailcfg.StableNodeID("active")
suggID = tailcfg.StableNodeID("suggestion")
)
usNYC := &tailcfg.Location{CountryCode: "US", City: "New York"}
usCHI := &tailcfg.Location{CountryCode: "US", City: "Chicago"}
seSTO := &tailcfg.Location{CountryCode: "SE", City: "Stockholm"}
statusWith := func(activePeer *ipnstate.PeerStatus) *ipnstate.Status {
s := &ipnstate.Status{
ExitNodeStatus: &ipnstate.ExitNodeStatus{ID: activeID},
}
if activePeer != nil {
s.Peer = map[key.NodePublic]*ipnstate.PeerStatus{{}: activePeer}
}
return s
}
tests := []struct {
name string
status *ipnstate.Status
suggID tailcfg.StableNodeID
suggCountry string
suggCity string
isActive bool
}{
{
name: "nil_status",
status: nil,
suggID: suggID,
},
{
name: "no_exit_node",
status: &ipnstate.Status{},
suggID: suggID,
},
{
name: "exit_node_id_is_zero",
status: &ipnstate.Status{ExitNodeStatus: &ipnstate.ExitNodeStatus{}},
suggID: suggID,
},
{
name: "exact_id_match_short-circuits",
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usCHI}),
suggID: activeID,
suggCountry: "US",
suggCity: "New York",
isActive: true,
},
{
name: "id_mismatch_but_same_city",
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usNYC}),
suggID: suggID,
suggCountry: "US",
suggCity: "New York",
isActive: true,
},
{
name: "different_city",
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usCHI}),
suggID: suggID,
suggCountry: "US",
suggCity: "New York",
},
{
name: "different_country",
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: seSTO}),
suggID: suggID,
suggCountry: "US",
suggCity: "New York",
},
{
name: "id_mismatch_suggestion_has_no_location",
status: statusWith(&ipnstate.PeerStatus{ID: activeID, Location: usNYC}),
suggID: suggID,
},
{
name: "id_mismatch_active_peer_has_no_location",
status: statusWith(&ipnstate.PeerStatus{ID: activeID}),
suggID: suggID,
suggCountry: "US",
suggCity: "New York",
},
{
name: "active_peer_not_in_status",
status: statusWith(nil),
suggID: suggID,
suggCountry: "US",
suggCity: "New York",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
isExitNodeActive := recommendedIsActive(tt.status, tt.suggID, tt.suggCountry, tt.suggCity)
if isExitNodeActive != tt.isActive {
t.Errorf("recommendedIsActive; got %v, want %v", isExitNodeActive, tt.isActive)
}
})
}
}
+1 -18
View File
@@ -76,7 +76,7 @@ type ReloadConfigResponse struct {
type ExitNodeSuggestionResponse struct {
ID tailcfg.StableNodeID
Name string
Location tailcfg.LocationView `json:",omitzero"`
Location tailcfg.LocationView `json:",omitempty"`
}
// DNSOSConfig mimics dns.OSConfig without forcing us to import the entire dns package
@@ -104,20 +104,3 @@ type OptionalFeatures struct {
// are not guaranteed to be present.)
Features map[string]bool
}
// ServiceClientPrefRequest is the body POSTed to the LocalAPI endpoint /localapi/v0/prefs/service-clients.
// Empty values for Client, Username, and DatabaseName mean "don't change this value".
type ServiceClientPrefRequest struct {
// Key is the identifier for the service client pref. Required. Format is "<serviceName>:<port>"
// where serviceName is a [tailcfg.ServiceName], e.g. "svc:my-db:5432".
Key string
// Client is the name of the client that the user picked in the service launch. Optional.
Client string `json:",omitzero"`
// Username is the username that the user entered in the service launch. Optional.
Username string `json:",omitzero"`
// DatabaseName is the database name that the user entered in the service launch. Optional.
DatabaseName string `json:",omitzero"`
}
+1 -1
View File
@@ -22,7 +22,7 @@ type Key struct {
// KeyCapabilities are the capabilities of a Key.
type KeyCapabilities struct {
Devices KeyDeviceCapabilities `json:"devices"`
Devices KeyDeviceCapabilities `json:"devices,omitempty"`
}
// KeyDeviceCapabilities are the device-related capabilities of a Key.
+1 -2
View File
@@ -199,8 +199,7 @@ func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
if err != nil {
return true
}
return strings.HasSuffix(controlURL.Host, ".tailscale.com") ||
controlURL.Host == "control.tailscale" // for natlab tests
return strings.HasSuffix(controlURL.Host, ".tailscale.com")
}
// awaitUserAuth blocks until the given session auth has been completed
@@ -61,7 +61,7 @@ export default function ExitNodeSelector({
none, // not using exit nodes
advertising, // advertising as exit node
using, // using another exit node
offline, // selected exit node is offline
offline, // selected exit node node is offline
] = useMemo(
() => [
selected.ID === noExitNode.ID,
+120 -79
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
}
if err := h(r.Context(), body); err != nil {
if httpErr, ok := errors.AsType[tsweb.HTTPError](err); ok {
tsweb.WriteHTTPError(w, r, httpErr)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusOK)
type apiHandler[data any] struct {
s *Server
w http.ResponseWriter
r *http.Request
// permissionCheck allows for defining whether a requesting peer's
// capabilities grant them access to make the given data update.
// If permissionCheck reports false, the request fails as unauthorized.
permissionCheck func(data data, peer peerCapabilities) bool
}
// newHandler constructs a new api handler which restricts the given request
// to the specified permission check. If the permission check fails for
// the peer associated with the request, an unauthorized error is returned
// to the client.
func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
return &apiHandler[data]{
s: s,
w: w,
r: r,
permissionCheck: permissionCheck,
}
}
var contextKeyPeer = ctxkey.New("peer-capabilities", peerCapabilities{})
// alwaysAllowed can be passed as the permissionCheck argument to newHandler
// for requests that are always allowed to complete regardless of a peer's
// capabilities.
func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
func (s *Server) setPeer(r *http.Request) (*http.Request, error) {
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
// WhoIs when originally checking for a session from authorizeRequest.
// Would be nice if we could pipe those through to here so we don't end
// up having to re-call them to grab the peer capabilities.
status, err := s.lc.StatusWithoutPeers(r.Context())
status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
if err != nil {
return nil, err
}
whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
if err != nil {
return nil, err
}
@@ -570,11 +573,56 @@ func (s *Server) setPeer(r *http.Request) (*http.Request, error) {
if err != nil {
return nil, err
}
return r.WithContext(contextKeyPeer.WithValue(r.Context(), peer)), nil
return peer, nil
}
func (s *Server) getPeer(ctx context.Context) peerCapabilities {
return contextKeyPeer.Value(ctx)
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
// handle runs the given handler if the source peer satisfies the
// constraints for running this request.
//
// handle is expected for use when `data` type is empty, or set to
// `noBodyData` in practice. For requests that expect JSON body data
// to be attached, use handleJSON instead.
func (a *apiHandler[data]) handle(h http.HandlerFunc) {
peer, err := a.getPeer()
if err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
var body data // not used
if !a.permissionCheck(body, peer) {
http.Error(a.w, "not allowed", http.StatusUnauthorized)
return
}
h(a.w, a.r)
}
// handleJSON manages decoding the request's body JSON and passing
// it on to the provided function if the source peer satisfies the
// constraints for running this request.
func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
defer a.r.Body.Close()
var body data
if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
peer, err := a.getPeer()
if err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
if !a.permissionCheck(body, peer) {
http.Error(a.w, "not allowed", http.StatusUnauthorized)
return
}
if err := h(a.r.Context(), body); err != nil {
http.Error(a.w, err.Error(), http.StatusInternalServerError)
return
}
a.w.WriteHeader(http.StatusOK)
}
// serveAPI serves requests for the web client api.
@@ -589,44 +637,67 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
}
}
var err error
r, err = s.setPeer(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api")
switch {
case path == "/data" && r.Method == httpm.GET:
s.serveGetNodeData(w, r)
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveGetNodeData)
return
case path == "/exit-nodes" && r.Method == httpm.GET:
s.serveGetExitNodes(w, r)
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveGetExitNodes)
return
case path == "/routes" && r.Method == httpm.POST:
handleJSON[postRoutesRequest](s.servePostRoutes)(w, r)
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
return false
} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
return false
}
return true
}
newHandler[postRoutesRequest](s, w, r, peerAllowed).
handleJSON(s.servePostRoutes)
return
case path == "/device-details-click" && r.Method == httpm.POST:
s.serveDeviceDetailsClick(w, r)
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveDeviceDetailsClick)
return
case path == "/local/v0/logout" && r.Method == httpm.POST:
s.proxyRequestToLocalAPI(w, r)
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
return peer.canEdit(capFeatureAccount)
}
newHandler[noBodyData](s, w, r, peerAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
handleJSON[maskedPrefs](s.serveUpdatePrefs)(w, r)
peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
return false
}
return true
}
newHandler[maskedPrefs](s, w, r, peerAllowed).
handleJSON(s.serveUpdatePrefs)
return
case path == "/local/v0/update/check" && r.Method == httpm.GET:
s.proxyRequestToLocalAPI(w, r)
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/update/check" && r.Method == httpm.POST:
s.proxyRequestToLocalAPI(w, r)
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
return peer.canEdit(capFeatureAccount)
}
newHandler[noBodyData](s, w, r, peerAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
s.proxyRequestToLocalAPI(w, r)
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
return
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
s.proxyRequestToLocalAPI(w, r)
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
@@ -1051,11 +1122,6 @@ type maskedPrefs struct {
}
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
peer := s.getPeer(ctx)
if prefs.RunSSHSet && !peer.canEdit(capFeatureSSH) {
return tsweb.Error(http.StatusUnauthorized, "RunSSHSet not allowed", nil)
}
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
RunSSHSet: prefs.RunSSHSet,
Prefs: ipn.Prefs{
@@ -1074,17 +1140,6 @@ type postRoutesRequest struct {
}
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
if !data.SetExitNode && !data.SetRoutes {
return tsweb.Error(http.StatusBadRequest, "must specify SetExitNode or SetRoutes", nil)
}
peer := s.getPeer(ctx)
if data.SetExitNode && !peer.canEdit(capFeatureExitNodes) {
return tsweb.Error(http.StatusUnauthorized, "SetExitNode not allowed", nil)
}
if data.SetRoutes && !peer.canEdit(capFeatureSubnets) {
return tsweb.Error(http.StatusUnauthorized, "SetRoutes not allowed", nil)
}
prefs, err := s.lc.GetPrefs(ctx)
if err != nil {
return err
@@ -1098,14 +1153,13 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
}
currNonExitRoutes = append(currNonExitRoutes, r.String())
}
// For each group of fields not being set, preserve the current prefs.
if !data.SetExitNode {
// Set non-edited fields to their current values.
if data.SetExitNode {
data.AdvertiseRoutes = currNonExitRoutes
} else if data.SetRoutes {
data.AdvertiseExitNode = currAdvertisingExitNode
data.UseExitNode = prefs.ExitNodeID
}
if !data.SetRoutes {
data.AdvertiseRoutes = currNonExitRoutes
}
// Calculate routes.
routesStr := strings.Join(data.AdvertiseRoutes, ",")
@@ -1282,19 +1336,6 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
return
}
switch path {
case "/v0/logout":
if !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
http.Error(w, "not allowed", http.StatusUnauthorized)
return
}
case "/v0/update/check":
if r.Method == httpm.POST && !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
http.Error(w, "not allowed", http.StatusUnauthorized)
return
}
}
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
if err != nil {
+2 -148
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)
}
})
}
}
+6 -95
View File
@@ -11,7 +11,6 @@ import (
"bufio"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
@@ -38,25 +37,6 @@ import (
"tailscale.com/version/distro"
)
// GokrazyUpdateArgs contains arguments for updating a Gokrazy appliance from a
// GAF fetched from a URL.
type GokrazyUpdateArgs struct {
// URL is the GAF download URL.
URL string
// AllowUnsigned permits installing a GAF without signature verification.
// It is intended for tests that serve a GAF from a fileserver that does
// not publish distsign.pub.
AllowUnsigned bool
// Logf is optional; nil discards log messages.
Logf logger.Logf
}
// GokrazyUpdateFromURL updates a Gokrazy appliance from a GAF fetched from a
// URL, if Gokrazy update support is linked into the binary.
var GokrazyUpdateFromURL feature.Hook[func(context.Context, GokrazyUpdateArgs) error]
const (
StableTrack = "stable"
UnstableTrack = "unstable"
@@ -217,17 +197,6 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
// release cadence with Synology Package Center and use their
// auto-update mechanism.
return up.updateSynology, false
case distro.Gokrazy:
// Only the official Tailscale appliance image (built with the
// ts_appliance build tag, which causes hostinfo to report
// Package="tsapp") is auto-updatable. A user running a custom
// Gokrazy build that happens to include tailscaled must not be
// updated with our stock GAFs. TS_FORCE_ALLOW_TSAPP_UPDATE is an
// escape hatch for callers who know what they're doing.
if hi.Package != "tsapp" && !envknob.Bool("TS_FORCE_ALLOW_TSAPP_UPDATE") {
return nil, false
}
return up.updateGokrazy, true
case distro.Debian: // includes Ubuntu
return up.updateDebLike, true
case distro.Arch:
@@ -361,7 +330,7 @@ func (up *Updater) updateSynology() error {
if err != nil {
return err
}
latest, err := LatestPackages(up.Track)
latest, err := latestPackages(up.Track)
if err != nil {
return err
}
@@ -895,56 +864,6 @@ func (up *Updater) updateFreeBSD() (err error) {
return nil
}
// updateGokrazy fetches the latest signed GAF for this gokrazy device variant
// (vm-amd64, vm-arm64, or pi-arm64) from up.PkgsAddr and applies it via the
// local gokrazy init update API.
func (up *Updater) updateGokrazy() error {
if !GokrazyUpdateFromURL.IsSet() {
return errors.New("gokrazy update support is not linked into this binary")
}
variant, err := gokrazyDeviceVariant()
if err != nil {
return err
}
latest, err := LatestPackages(up.Track)
if err != nil {
return err
}
gafName, ok := latest.GAFs[variant]
if !ok {
return fmt.Errorf("no GAF for device %q on %q track", variant, up.Track)
}
if latest.GAFsVersion == "" {
return fmt.Errorf("no GAF version on %q track", up.Track)
}
if !up.confirm(latest.GAFsVersion) {
return nil
}
gafURL := fmt.Sprintf("%s/%s/%s", strings.TrimRight(up.PkgsAddr, "/"), up.Track, gafName)
up.Logf("Updating to %s (%s)", latest.GAFsVersion, gafURL)
return GokrazyUpdateFromURL.Get()(context.Background(), GokrazyUpdateArgs{
URL: gafURL,
Logf: up.Logf,
})
}
// gokrazyDeviceVariant returns the GAFs JSON key for the current gokrazy
// device, e.g. "vm-amd64", "vm-arm64", or "pi-arm64". On arm64, it reads the
// device-tree model to tell a Raspberry Pi apart from a VM.
func gokrazyDeviceVariant() (string, error) {
switch runtime.GOARCH {
case "amd64":
return "vm-amd64", nil
case "arm64":
b, _ := os.ReadFile("/sys/firmware/devicetree/base/model")
if strings.HasPrefix(strings.Trim(string(b), "\x00\r\n\t "), "Raspberry Pi") {
return "pi-arm64", nil
}
return "vm-arm64", nil
}
return "", fmt.Errorf("unsupported gokrazy GOARCH %q", runtime.GOARCH)
}
func (up *Updater) updateLinuxBinary() error {
// Root is needed to overwrite binaries and restart systemd unit.
if err := requireRoot(); err != nil {
@@ -1305,7 +1224,7 @@ func LatestTailscaleVersion(track string) (string, error) {
track = CurrentTrack
}
latest, err := LatestPackages(track)
latest, err := latestPackages(track)
if err != nil {
return "", err
}
@@ -1317,11 +1236,8 @@ func LatestTailscaleVersion(track string) (string, error) {
ver = latest.MacZipsVersion
case "linux":
ver = latest.TarballsVersion
switch distro.Get() {
case distro.Synology:
if distro.Get() == distro.Synology {
ver = latest.SPKsVersion
case distro.Gokrazy:
ver = latest.GAFsVersion
}
}
@@ -1331,8 +1247,7 @@ func LatestTailscaleVersion(track string) (string, error) {
return ver, nil
}
// TrackPackages is the JSON shape served at <pkgs>/<track>/?mode=json.
type TrackPackages struct {
type trackPackages struct {
Version string
Tarballs map[string]string
TarballsVersion string
@@ -1340,8 +1255,6 @@ type TrackPackages struct {
ExesVersion string
MSIs map[string]string
MSIsVersion string
GAFs map[string]string
GAFsVersion string
MacZips map[string]string
MacZipsVersion string
SPKs map[string]map[string]string
@@ -1350,16 +1263,14 @@ type TrackPackages struct {
var tailscaleHTTPEndpoint = "https://pkgs.tailscale.com"
// LatestPackages fetches the package manifest served at
// <pkgs>/<track>/?mode=json for the current runtime.GOOS.
func LatestPackages(track string) (*TrackPackages, error) {
func latestPackages(track string) (*trackPackages, error) {
url := fmt.Sprintf("%s/%s/?mode=json&os=%s", tailscaleHTTPEndpoint, track, runtime.GOOS)
res, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("fetching latest tailscale version: %w", err)
}
defer res.Body.Close()
var latest TrackPackages
var latest trackPackages
if err := json.NewDecoder(res.Body).Decode(&latest); err != nil {
return nil, fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
}
-239
View File
@@ -1,239 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package clientupdate
import (
"archive/zip"
"context"
"fmt"
"hash/crc32"
"io"
"net"
"net/http"
"os"
"strings"
"time"
"tailscale.com/clientupdate/distsign"
"tailscale.com/types/logger"
"tailscale.com/util/progresstracking"
)
const (
gokrazyUpdateSocket = "/run/gokrazy-http.sock"
gokrazyUpdateBaseURL = "http://gokrazy-local-unixsock"
)
// GokrazyUpdateFromURL downloads a Gokrazy archive format file from args.URL,
// installs its partitions using the local gokrazy init update API, switches to
// the new root partition, and asks gokrazy to reboot.
//
// The local gokrazy API is reached over gokrazyUpdateSocket. The
// gokrazyUpdateBaseURL host is only a net/http URL sentinel; it is not resolved
// with DNS.
func init() {
GokrazyUpdateFromURL.Set(gokrazyUpdateFromURL)
}
func gokrazyUpdateFromURL(ctx context.Context, args GokrazyUpdateArgs) error {
logf := args.Logf
if logf == nil {
logf = logger.Discard
}
tmp, err := os.CreateTemp("", "tailscale-gokrazy-*.gaf")
if err != nil {
return err
}
tmpName := tmp.Name()
tmp.Close()
defer os.Remove(tmpName)
logf("downloading %s", args.URL)
if args.AllowUnsigned {
if err := downloadUnverified(ctx, logf, args.URL, tmpName); err != nil {
return err
}
} else {
if err := distsign.DownloadVerified(ctx, logf, args.URL, tmpName); err != nil {
return err
}
}
zr, err := zip.OpenReader(tmpName)
if err != nil {
return err
}
defer zr.Close()
logf("download complete")
gokClient := gokrazyHTTPClient()
for _, part := range []struct {
name string
path string
}{
{"root.img", "/update/root"},
{"boot.img", "/update/boot"},
{"mbr.img", "/update/mbr"},
} {
logf("writing %s...", part.name)
if err := putGokrazyGAFMember(ctx, gokClient, zr.File, part.name, part.path); err != nil {
return err
}
logf("wrote %s", part.name)
}
if err := postGokrazy(ctx, gokClient, "/update/switch"); err != nil {
return err
}
logf("switched boot target")
if err := postGokrazy(ctx, gokClient, "/reboot?async=true&kexec_merge_cmdline=true"); err != nil {
return err
}
logf("reboot requested")
return nil
}
// downloadUnverified saves the GAF at srcURL to dstPath without verifying
// a signature. It is used only when args.AllowUnsigned is set, for tests
// that serve the GAF from a fileserver that does not publish distsign.pub
// and for the gafpush "sftp the GAF onto the appliance and update from a
// local path" flow, which uses a "file://" URL.
func downloadUnverified(ctx context.Context, logf logger.Logf, srcURL, dstPath string) error {
if after, ok := strings.CutPrefix(srcURL, "file://"); ok {
return copyLocalFile(after, dstPath, logf)
}
req, err := http.NewRequestWithContext(ctx, "GET", srcURL, nil)
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("download GAF: %s", res.Status)
}
f, err := os.Create(dstPath)
if err != nil {
return err
}
total := res.ContentLength
pw := progresstracking.NewWriter(io.Discard, total, time.Second, func(done int64) {
if total > 0 {
logf("downloading: %d / %d MB (%.0f%%)", done>>20, total>>20, float64(done)/float64(total)*100)
}
})
if _, err := io.Copy(f, io.TeeReader(res.Body, pw)); err != nil {
f.Close()
return err
}
return f.Close()
}
// copyLocalFile copies the GAF at src to dst. Used by the "file://" branch
// of downloadUnverified. The source file is left in place; callers that
// staged it (e.g. gafpush) clean up after the update completes.
func copyLocalFile(src, dst string, logf logger.Logf) error {
sf, err := os.Open(src)
if err != nil {
return err
}
defer sf.Close()
df, err := os.Create(dst)
if err != nil {
return err
}
fi, err := sf.Stat()
if err != nil {
df.Close()
return err
}
total := fi.Size()
logf("copying local GAF %s (%d MB)", src, total>>20)
pw := progresstracking.NewWriter(io.Discard, total, time.Second, func(done int64) {
if total > 0 {
logf("copying: %d / %d MB (%.0f%%)", done>>20, total>>20, float64(done)/float64(total)*100)
}
})
if _, err := io.Copy(df, io.TeeReader(sf, pw)); err != nil {
df.Close()
return err
}
return df.Close()
}
func gokrazyHTTPClient() *http.Client {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", gokrazyUpdateSocket)
}
return &http.Client{
Transport: tr,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
func putGokrazyGAFMember(ctx context.Context, hc *http.Client, files []*zip.File, name, path string) error {
var zf *zip.File
for _, f := range files {
if f.Name == name {
zf = f
break
}
}
if zf == nil {
return fmt.Errorf("GAF is missing %s", name)
}
rc, err := zf.Open()
if err != nil {
return err
}
defer rc.Close()
h := crc32.NewIEEE()
body := io.TeeReader(rc, h)
req, err := http.NewRequestWithContext(ctx, "PUT", gokrazyUpdateBaseURL+path, body)
if err != nil {
return err
}
req.ContentLength = int64(zf.UncompressedSize64)
req.Header.Set("X-Gokrazy-Update-Hash", "crc32")
res, err := hc.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
resBody, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode != http.StatusOK {
return fmt.Errorf("PUT %s: %s: %s", path, res.Status, strings.TrimSpace(string(resBody)))
}
if got, want := strings.TrimSpace(string(resBody)), fmt.Sprintf("%08x", h.Sum32()); got != want {
return fmt.Errorf("PUT %s: gokrazy checksum = %q; want %q", path, got, want)
}
return nil
}
func postGokrazy(ctx context.Context, hc *http.Client, path string) error {
req, err := http.NewRequestWithContext(ctx, "POST", gokrazyUpdateBaseURL+path, nil)
if err != nil {
return err
}
res, err := hc.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
return fmt.Errorf("POST %s: %s: %s", path, res.Status, strings.TrimSpace(string(body)))
}
return nil
}
+1 -1
View File
@@ -373,7 +373,7 @@ func TestCheckOutdatedAlpineRepo(t *testing.T) {
testServ := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
version := TrackPackages{
version := trackPackages{
MSIsVersion: tt.latestHTTPVersion,
MacZipsVersion: tt.latestHTTPVersion,
TarballsVersion: tt.latestHTTPVersion,
+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
+23 -7
View File
@@ -56,11 +56,9 @@ import (
"github.com/hdevalence/ed25519consensus"
"golang.org/x/crypto/blake2s"
"tailscale.com/feature"
"tailscale.com/net/netutil"
"tailscale.com/types/logger"
"tailscale.com/util/httpm"
"tailscale.com/util/must"
"tailscale.com/util/progresstracking"
)
const (
@@ -331,7 +329,7 @@ func fetch(url string, limit int64) ([]byte, error) {
// download writes the response body of url into a local file at dst, up to
// limit bytes. On success, the returned value is a BLAKE2s hash of the file.
func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]byte, int64, error) {
tr := netutil.NewDefaultTransport()
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = feature.HookProxyFromEnvironment.GetOrNil()
defer tr.CloseIdleConnections()
hc := &http.Client{
@@ -374,10 +372,7 @@ func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]
return nil, 0, err
}
defer of.Close()
total := res.ContentLength
pw := progresstracking.NewWriter(io.Discard, total, 2*time.Second, func(done int64) {
c.logf("Downloaded %v/%v (%.1f%%)", done, total, float64(done)/float64(total)*100)
})
pw := &progressWriter{total: res.ContentLength, logf: c.logf}
h := NewPackageHash()
n, err := io.Copy(io.MultiWriter(of, h, pw), io.LimitReader(dlRes.Body, limit))
if err != nil {
@@ -392,10 +387,31 @@ func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]
if err := of.Close(); err != nil {
return nil, n, err
}
pw.print()
return h.Sum(nil), h.Len(), nil
}
type progressWriter struct {
done int64
total int64
lastPrint time.Time
logf logger.Logf
}
func (pw *progressWriter) Write(p []byte) (n int, err error) {
pw.done += int64(len(p))
if time.Since(pw.lastPrint) > 2*time.Second {
pw.print()
}
return len(p), nil
}
func (pw *progressWriter) print() {
pw.lastPrint = time.Now()
pw.logf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
}
func parsePrivateKey(data []byte, typeTag string) (ed25519.PrivateKey, error) {
b, rest := pem.Decode(data)
if b == nil {
-42
View File
@@ -1,42 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package distsign
import (
"context"
"fmt"
"net/url"
"strings"
"tailscale.com/types/logger"
)
// DownloadVerified is a convenience wrapper around [Client.Download]
// for callers that have a full URL (e.g.
// https://pkgs.tailscale.com/unstable/foo.gaf) rather than a base URL
// plus path. It splits srcURL into a base ("scheme://host") and a path,
// constructs a [Client] for the base, and downloads with signature
// verification to dstPath.
func DownloadVerified(ctx context.Context, logf logger.Logf, srcURL, dstPath string) error {
if logf == nil {
logf = logger.Discard
}
u, err := url.Parse(srcURL)
if err != nil {
return fmt.Errorf("parsing URL %q: %w", srcURL, err)
}
if u.Scheme == "" || u.Host == "" {
return fmt.Errorf("URL %q is missing scheme or host", srcURL)
}
base := &url.URL{Scheme: u.Scheme, User: u.User, Host: u.Host}
path := strings.TrimPrefix(u.Path, "/")
if path == "" {
return fmt.Errorf("URL %q has no path component", srcURL)
}
c, err := NewClient(logf, base.String())
if err != nil {
return err
}
return c.Download(ctx, path, dstPath)
}
+7 -40
View File
@@ -143,32 +143,19 @@ func main() {
log.Printf("Using cigocached at %s", *srvURL)
}
c.remote = &cachers.HTTPClient{
BaseURL: *srvURL,
Disk: c.disk,
HTTPClient: httpClient(srvHost, *srvHostDial),
AccessToken: *token,
Verbose: *verbose,
BestEffortHTTP: true,
AsyncPutTimeout: asyncPutTimeout,
AsyncPutMaxConcurrent: 10,
BaseURL: *srvURL,
Disk: c.disk,
HTTPClient: httpClient(srvHost, *srvHostDial),
AccessToken: *token,
Verbose: *verbose,
BestEffortHTTP: true,
}
}
var p *cacheproc.Process
p = &cacheproc.Process{
Close: func() error {
if c.remote != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if !c.remote.Shutdown(ctx) {
log.Printf("cigocacher: timed out waiting for background PUTs to drain")
}
// Always surface dropped PUTs.
if timedOut, canceled := c.remote.PutsTimedOut.Load(), c.remote.PutsCanceled.Load(); timedOut+canceled > 0 {
log.Printf("cigocacher: %d background PUTs timed out, %d canceled", timedOut, canceled)
}
}
if c.verbose {
log.Printf("cigocacher: closing; %d gets (%d hits, %d misses, %d errors); %d puts (%d errors)",
log.Printf("gocacheprog: closing; %d gets (%d hits, %d misses, %d errors); %d puts (%d errors)",
p.Gets.Load(), p.GetHits.Load(), p.GetMisses.Load(), p.GetErrors.Load(), p.Puts.Load(), p.PutErrors.Load())
}
return c.close()
@@ -351,23 +338,3 @@ func fetchStats(cl *http.Client, baseURL, accessToken string) (string, error) {
}
return string(b), nil
}
const (
// minPutTimeout is the floor we clamp to for small objects where the time is
// dominated by fixed overheads like connection establishment, waiting for a
// busy server to service the request etc.
minPutTimeout = 5 * time.Second
// maxPutTimeout is the ceiling we clamp to for large objects.
maxPutTimeout = 30 * time.Second
// minAverageBandwidth is the minimum average bandwidth (2MiB/s) we require
// for PUTs to complete within the timeout in its linear scaling region.
minAverageBandwidth = 2 * 1 << 20 / float64(time.Second)
)
// asyncPutTimeout returns a size-dependent timeout for async PUTs to the remote
// gocached server. It returns 5s for size <= 10MiB, 30s for size >= 60MiB and
// scales linearly in between.
func asyncPutTimeout(size int64) time.Duration {
timeout := time.Duration(float64(size) / minAverageBandwidth)
return min(max(minPutTimeout, timeout), maxPutTimeout)
}
-25
View File
@@ -1,25 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"testing"
"time"
)
func TestAsyncPutTimeout(t *testing.T) {
for size, expected := range map[int64]time.Duration{
0: 5 * time.Second,
10: 5 * time.Second,
10 * 1 << 20: 5 * time.Second,
20 * 1 << 20: 10 * time.Second,
40 * 1 << 20: 20 * time.Second,
60 * 1 << 20: 30 * time.Second,
10 * 1 << 30: 30 * time.Second,
} {
if actual := asyncPutTimeout(size); actual != expected {
t.Errorf("for size %d, expected %v, but got %v", size, expected, actual)
}
}
}
+25 -51
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 {
@@ -169,32 +185,15 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("}")
case *types.Map:
elem := ft.Elem()
if sliceType, isSlice := elem.Underlying().(*types.Slice); isSlice {
if sliceType, isSlice := elem.(*types.Slice); isSlice {
n := it.QualifiedName(sliceType.Elem())
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
if codegen.ContainsPointers(sliceType.Elem()) {
writef("\tfor k, sv := range src.%s {", fname)
writef("\t\tif sv == nil {")
writef("\t\t\tdst.%s[k] = nil", fname)
writef("\t\t\tcontinue")
writef("\t\t}")
writef("\t\tdst.%s[k] = make([]%s, len(sv))", fname, n)
writef("\t\tfor i := range sv {")
innerWritef := func(format string, args ...any) {
writef("\t\t"+format, args...)
}
writeSliceElemClone(innerWritef, sliceType.Elem(),
"sv[i]", fmt.Sprintf("dst.%s[k][i]", fname))
writef("\t\t}")
writef("\t}")
} else {
writef("\tfor k := range src.%s {", fname)
// use zero-length slice instead of nil to ensure
// the key is always copied.
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
writef("\t}")
}
writef("\tfor k := range src.%s {", fname)
// use zero-length slice instead of nil to ensure
// the key is always copied.
writef("\t\tdst.%s[k] = append([]%s{}, src.%s[k]...)", fname, n, fname)
writef("\t}")
writef("}")
} else if codegen.IsViewType(elem) || !codegen.ContainsPointers(elem) {
// If the map values are view types (which are
@@ -243,31 +242,6 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
buf.Write(codegen.AssertStructUnchanged(t, name, typeParams, "Clone", it))
}
// writeSliceElemClone generates code to deep-clone a single slice element
// from srcExpr to dstExpr. It handles pointer, json.RawMessage, interface,
// and named struct element types.
func writeSliceElemClone(writef func(string, ...any), elemType types.Type, srcExpr, dstExpr string) {
if ptr, isPtr := elemType.(*types.Pointer); isPtr {
writef("if %s == nil { %s = nil } else {", srcExpr, dstExpr)
if codegen.ContainsPointers(ptr.Elem()) {
if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface {
writef("\t%s = new((*%s).Clone())", dstExpr, srcExpr)
} else {
writef("\t%s = %s.Clone()", dstExpr, srcExpr)
}
} else {
writef("\t%s = new(*%s)", dstExpr, srcExpr)
}
writef("}")
} else if elemType.String() == "encoding/json.RawMessage" {
writef("%s = append(%s[:0:0], %s...)", dstExpr, srcExpr, srcExpr)
} else if _, isIface := elemType.Underlying().(*types.Interface); isIface {
writef("%s = %s.Clone()", dstExpr, srcExpr)
} else {
writef("%s = *%s.Clone()", dstExpr, srcExpr)
}
}
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
func hasBasicUnderlying(typ types.Type) bool {
switch typ.Underlying().(type) {
-51
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{
@@ -283,13 +242,3 @@ func TestDeeplyNestedMap(t *testing.T) {
t.Errorf("Clone() aliased FourLevels map: new nested key appeared in original")
}
}
func TestMapWithNamedSliceValues(t *testing.T) {
orig := &clonerex.MapWithNamedSliceValues{
M: map[string]clonerex.NamedSlice{"k": {"foo", "bar"}},
}
cloned := orig.Clone()
if diff := cmp.Diff(orig, cloned); diff != "" {
t.Errorf("Clone() mismatch (-orig +cloned):\n%s", diff)
}
}
+4 -20
View File
@@ -1,13 +1,11 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer,MapWithNamedSliceValues
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer
// Package clonerex is an example package for the cloner tool.
package clonerex
import "maps"
type SliceContainer struct {
Slice []*int
}
@@ -51,7 +49,9 @@ func (m NamedMap) Clone() NamedMap {
return nil
}
m2 := make(NamedMap, len(m))
maps.Copy(m2, m)
for k, v := range m {
m2[k] = v
}
return m2
}
@@ -60,24 +60,8 @@ type NamedMapContainer struct {
Attrs NamedMap
}
// MapSlicePointerContainer has a map whose values are slices of pointers.
// This tests that the cloner deep-clones the pointer elements in the slice,
// not just the slice itself (which would leave aliased pointers).
type MapSlicePointerContainer struct {
Routes map[string][]*SliceContainer
}
// DeeplyNestedMap tests arbitrary depth of map nesting (3+ levels)
type DeeplyNestedMap struct {
ThreeLevels map[string]map[string]map[string]int
FourLevels map[string]map[string]map[string]map[string]*SliceContainer
}
// MapWithNamedSliceValues has a map with a named slice type for values. This
// tests that the generator treats these values like any other slice and not a
// struct.
type MapWithNamedSliceValues struct {
M map[string]NamedSlice
}
type NamedSlice []string
+1 -74
View File
@@ -176,64 +176,9 @@ var _NamedMapContainerCloneNeedsRegeneration = NamedMapContainer(struct {
Attrs NamedMap
}{})
// Clone makes a deep copy of MapSlicePointerContainer.
// The result aliases no memory with the original.
func (src *MapSlicePointerContainer) Clone() *MapSlicePointerContainer {
if src == nil {
return nil
}
dst := new(MapSlicePointerContainer)
*dst = *src
if dst.Routes != nil {
dst.Routes = map[string][]*SliceContainer{}
for k, sv := range src.Routes {
if sv == nil {
dst.Routes[k] = nil
continue
}
dst.Routes[k] = make([]*SliceContainer, len(sv))
for i := range sv {
if sv[i] == nil {
dst.Routes[k][i] = nil
} else {
dst.Routes[k][i] = sv[i].Clone()
}
}
}
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _MapSlicePointerContainerCloneNeedsRegeneration = MapSlicePointerContainer(struct {
Routes map[string][]*SliceContainer
}{})
// Clone makes a deep copy of MapWithNamedSliceValues.
// The result aliases no memory with the original.
func (src *MapWithNamedSliceValues) Clone() *MapWithNamedSliceValues {
if src == nil {
return nil
}
dst := new(MapWithNamedSliceValues)
*dst = *src
if dst.M != nil {
dst.M = map[string]NamedSlice{}
for k := range src.M {
dst.M[k] = append([]string{}, src.M[k]...)
}
}
return dst
}
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _MapWithNamedSliceValuesCloneNeedsRegeneration = MapWithNamedSliceValues(struct {
M map[string]NamedSlice
}{})
// Clone duplicates src into dst and reports whether it succeeded.
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer,MapSlicePointerContainer,MapWithNamedSliceValues.
// where T is one of SliceContainer,InterfaceContainer,MapWithPointers,DeeplyNestedMap,NamedMapContainer.
func Clone(dst, src any) bool {
switch src := src.(type) {
case *SliceContainer:
@@ -281,24 +226,6 @@ func Clone(dst, src any) bool {
*dst = src.Clone()
return true
}
case *MapSlicePointerContainer:
switch dst := dst.(type) {
case *MapSlicePointerContainer:
*dst = *src.Clone()
return true
case **MapSlicePointerContainer:
*dst = src.Clone()
return true
}
case *MapWithNamedSliceValues:
switch dst := dst.(type) {
case *MapWithNamedSliceValues:
*dst = *src.Clone()
return true
case **MapWithNamedSliceValues:
*dst = src.Clone()
return true
}
}
return false
}
+60 -88
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/views"
"tailscale.com/util/httpm"
"tailscale.com/util/linuxfw"
"tailscale.com/util/mak"
@@ -55,10 +54,9 @@ type egressProxy struct {
tsClient *local.Client // never nil
netmapChan chan netmapState // chan to receive netmap state updates on
netmapChan chan ipn.Notify // chan to receive netmap updates on
podIPv4 string // empty if Pod does not have IPv4 address
podIPv6 string // empty if Pod does not have IPv6 address
podIPv4 string // never empty string, currently only IPv4 is supported
// tailnetFQDNs is the egress service FQDN to tailnet IP mappings that
// were last used to configure firewall rules for this proxy.
@@ -88,7 +86,7 @@ type httpClient interface {
// - the mounted egress config has changed
// - the proxy's tailnet IP addresses have changed
// - tailnet IPs have changed for any backend targets specified by tailnet FQDN
func (ep *egressProxy) run(ctx context.Context, nm netmapState, opts egressProxyRunOpts) error {
func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRunOpts) error {
ep.configure(opts)
var tickChan <-chan time.Time
var eventChan <-chan fsnotify.Event
@@ -107,7 +105,7 @@ func (ep *egressProxy) run(ctx context.Context, nm netmapState, opts egressProxy
eventChan = w.Events
}
if err := ep.sync(ctx, nm); err != nil {
if err := ep.sync(ctx, n); err != nil {
return err
}
for {
@@ -118,14 +116,14 @@ func (ep *egressProxy) run(ctx context.Context, nm netmapState, opts egressProxy
log.Printf("periodic sync, ensuring firewall config is up to date...")
case <-eventChan:
log.Printf("config file change detected, ensuring firewall config is up to date...")
case nm = <-ep.netmapChan:
shouldResync := ep.shouldResync(nm)
case n = <-ep.netmapChan:
shouldResync := ep.shouldResync(n)
if !shouldResync {
continue
}
log.Printf("netmap change detected, ensuring firewall config is up to date...")
}
if err := ep.sync(ctx, nm); err != nil {
if err := ep.sync(ctx, n); err != nil {
return fmt.Errorf("error syncing egress service config: %w", err)
}
}
@@ -137,9 +135,8 @@ type egressProxyRunOpts struct {
kc kubeclient.Client
tsClient *local.Client
stateSecret string
netmapChan chan netmapState
netmapChan chan ipn.Notify
podIPv4 string
podIPv6 string
tailnetAddrs []netip.Prefix
}
@@ -152,7 +149,6 @@ func (ep *egressProxy) configure(opts egressProxyRunOpts) {
ep.stateSecret = opts.stateSecret
ep.netmapChan = opts.netmapChan
ep.podIPv4 = opts.podIPv4
ep.podIPv6 = opts.podIPv6
ep.tailnetAddrs = opts.tailnetAddrs
ep.client = &http.Client{} // default HTTP client
sleepDuration := time.Second
@@ -168,7 +164,7 @@ func (ep *egressProxy) configure(opts egressProxyRunOpts) {
// any firewall rules need to be updated. Currently using status in state Secret as a reference for what is the current
// firewall configuration is good enough because - the status is keyed by the Pod IP - we crash the Pod on errors such
// as failed firewall update
func (ep *egressProxy) sync(ctx context.Context, nm netmapState) error {
func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
cfgs, err := ep.getConfigs()
if err != nil {
return fmt.Errorf("error retrieving egress service configs: %w", err)
@@ -177,27 +173,28 @@ func (ep *egressProxy) sync(ctx context.Context, nm netmapState) error {
if err != nil {
return fmt.Errorf("error retrieving current egress proxy status: %w", err)
}
newStatus, err := ep.syncEgressConfigs(cfgs, status, nm)
newStatus, err := ep.syncEgressConfigs(cfgs, status, n)
if err != nil {
return fmt.Errorf("error syncing egress service configs: %w", err)
}
if !servicesStatusIsEqual(newStatus, status) {
if err := ep.setStatus(ctx, newStatus, nm); err != nil {
if err := ep.setStatus(ctx, newStatus, n); err != nil {
return fmt.Errorf("error setting egress proxy status: %w", err)
}
}
return nil
}
// addrsHaveChanged returns true if the provided netmap state contains tailnet address change for this proxy node.
func (ep *egressProxy) addrsHaveChanged(nm netmapState) bool {
return !views.SliceEqual(views.SliceOf(ep.tailnetAddrs), nm.self.Addresses())
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
// Netmap must not be nil.
func (ep *egressProxy) addrsHaveChanged(n ipn.Notify) bool {
return !reflect.DeepEqual(ep.tailnetAddrs, n.NetMap.SelfNode.Addresses())
}
// syncEgressConfigs adds and deletes firewall rules to match the desired
// configuration. It uses the provided status to determine what is currently
// applied and updates the status after a successful sync.
func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *egressservices.Status, nm netmapState) (*egressservices.Status, error) {
func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *egressservices.Status, n ipn.Notify) (*egressservices.Status, error) {
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
return nil, nil
}
@@ -215,8 +212,8 @@ func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *eg
// Add new services, update rules for any that have changed.
rulesPerSvcToAdd := make(map[string][]rule, 0)
rulesPerSvcToDelete := make(map[string][]rule, 0)
for svcName, cfg := range cfgs {
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, nm)
for svcName, cfg := range *cfgs {
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, n)
if err != nil {
return nil, fmt.Errorf("error determining tailnet target IPs: %w", err)
}
@@ -231,12 +228,12 @@ func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *eg
if len(rulesToDelete) != 0 {
mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete)
}
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(nm) {
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(n) {
// For each tailnet target, set up SNAT from the local tailnet device address of the matching
// family.
for _, t := range tailnetTargetIPs {
var local netip.Addr
for _, pfx := range nm.self.Addresses().All() {
for _, pfx := range n.NetMap.SelfNode.Addresses().All() {
if !pfx.IsSingleIP() {
continue
}
@@ -252,9 +249,6 @@ func (ep *egressProxy) syncEgressConfigs(cfgs egressservices.Configs, status *eg
if err := ep.nfr.EnsureSNATForDst(local, t); err != nil {
return nil, fmt.Errorf("error setting up SNAT rule: %w", err)
}
if err := ep.nfr.ClampMSSToPMTU(tailscaleTunInterface, t); err != nil {
return nil, fmt.Errorf("error clamping MSS to PMTU: %w", err)
}
}
}
// Update the status. Status will be written back to the state Secret by the caller.
@@ -358,7 +352,7 @@ func updatesForCfg(svcName string, cfg egressservices.Config, status *egressserv
// deleteUnneccessaryServices ensure that any services found on status, but not
// present in config are deleted.
func (ep *egressProxy) deleteUnnecessaryServices(cfgs egressservices.Configs, status *egressservices.Status) error {
func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, status *egressservices.Status) error {
if !hasServicesConfigured(status) {
return nil
}
@@ -373,7 +367,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs egressservices.Configs, st
}
for svcName, svc := range status.Services {
if _, ok := cfgs[svcName]; !ok {
if _, ok := (*cfgs)[svcName]; !ok {
log.Printf("service %s is no longer required, deleting", svcName)
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
return fmt.Errorf("error deleting service %s: %w", svcName, err)
@@ -385,7 +379,7 @@ func (ep *egressProxy) deleteUnnecessaryServices(cfgs egressservices.Configs, st
}
// getConfigs gets the mounted egress service configuration.
func (ep *egressProxy) getConfigs() (egressservices.Configs, error) {
func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) {
svcsCfg := filepath.Join(ep.cfgPath, egressservices.KeyEgressServices)
j, err := os.ReadFile(svcsCfg)
if os.IsNotExist(err) {
@@ -397,7 +391,7 @@ func (ep *egressProxy) getConfigs() (egressservices.Configs, error) {
if len(j) == 0 || string(j) == "" {
return nil, nil
}
cfg := egressservices.Configs{}
cfg := &egressservices.Configs{}
if err := json.Unmarshal(j, &cfg); err != nil {
return nil, err
}
@@ -421,7 +415,7 @@ func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, e
if err := json.Unmarshal([]byte(raw), status); err != nil {
return nil, fmt.Errorf("error unmarshalling previous config: %w", err)
}
if status.PodIPv4 == ep.podIPv4 && status.PodIPv6 == ep.podIPv6 {
if reflect.DeepEqual(status.PodIPv4, ep.podIPv4) {
return status, nil
}
return nil, nil
@@ -429,13 +423,12 @@ func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, e
// setStatus writes egress proxy's currently configured firewall to the state
// Secret and updates proxy's tailnet addresses.
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, nm netmapState) error {
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, n ipn.Notify) error {
// Pod IP is used to determine if a stored status applies to THIS proxy Pod.
if status == nil {
status = &egressservices.Status{}
}
status.PodIPv4 = ep.podIPv4
status.PodIPv6 = ep.podIPv6
secret, err := ep.kc.GetSecret(ctx, ep.stateSecret)
if err != nil {
return fmt.Errorf("error retrieving state Secret: %w", err)
@@ -453,7 +446,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
return fmt.Errorf("error patching state Secret: %w", err)
}
ep.tailnetAddrs = nm.self.Addresses().AsSlice()
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
return nil
}
@@ -463,7 +456,7 @@ func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Sta
// FQDN, resolve the FQDN and return the resolved IPs. It checks if the
// netfilter runner supports IPv6 NAT and skips any IPv6 addresses if it
// doesn't.
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm netmapState) (addrs []netip.Addr, err error) {
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.Notify) (addrs []netip.Addr, err error) {
if svc.TailnetTarget.IP != "" {
addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
if err != nil {
@@ -479,11 +472,11 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm netm
if svc.TailnetTarget.FQDN == "" {
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
}
if !nm.self.Valid() {
log.Printf("netmap state is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
if n.NetMap == nil {
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
return addrs, nil
}
egressAddrs, err := resolveTailnetFQDN(nm, svc.TailnetTarget.FQDN)
egressAddrs, err := resolveTailnetFQDN(n.NetMap, svc.TailnetTarget.FQDN)
if err != nil {
log.Printf("error fetching backend addresses for %q: %v", svc.TailnetTarget.FQDN, err)
return addrs, nil
@@ -507,26 +500,26 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, nm netm
return addrs, nil
}
// shouldResync parses netmap state update and returns true if the update contains
// shouldResync parses netmap update and returns true if the update contains
// changes for which the egress proxy's firewall should be reconfigured.
func (ep *egressProxy) shouldResync(nm netmapState) bool {
if !nm.self.Valid() {
func (ep *egressProxy) shouldResync(n ipn.Notify) bool {
if n.NetMap == nil {
return false
}
// If proxy's tailnet addresses have changed, resync.
if !views.SliceEqual(nm.self.Addresses(), views.SliceOf(ep.tailnetAddrs)) {
if !reflect.DeepEqual(n.NetMap.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
log.Printf("node addresses have changed, trigger egress config resync")
ep.tailnetAddrs = nm.self.Addresses().AsSlice()
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
return true
}
// If the IPs for any of the egress services configured via FQDN have
// changed, resync.
for fqdn, ips := range ep.targetFQDNs {
for nn := range nm.peers() {
for _, nn := range n.NetMap.Peers {
if equalFQDNs(nn.Name(), fqdn) {
if !views.SliceEqual(views.SliceOf(ips), nn.Addresses()) {
if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) {
log.Printf("backend addresses for egress target %q have changed old IPs %v, new IPs %v trigger egress config resync", nn.Name(), ips, nn.Addresses().AsSlice())
return true
}
@@ -609,8 +602,8 @@ type rule struct {
protocol string
}
func wantsServicesConfigured(cfgs egressservices.Configs) bool {
return cfgs != nil && len(cfgs) != 0
func wantsServicesConfigured(cfgs *egressservices.Configs) bool {
return cfgs != nil && len(*cfgs) != 0
}
func hasServicesConfigured(status *egressservices.Status) bool {
@@ -626,8 +619,6 @@ func servicesStatusIsEqual(st, st1 *egressservices.Status) bool {
}
st.PodIPv4 = ""
st1.PodIPv4 = ""
st.PodIPv6 = ""
st1.PodIPv6 = ""
return reflect.DeepEqual(*st, *st1)
}
@@ -666,42 +657,37 @@ func (ep *egressProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// would normally be this Pod. When this Pod is being deleted, the operator should have removed it from the Service
// backends and eventually kube proxy routing rules should be updated to no longer route traffic for the Service to this
// Pod.
func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs egressservices.Configs, hp int) {
if cfgs == nil || len(cfgs) == 0 { // avoid sleeping if no services are configured
func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs *egressservices.Configs, hp int) {
if cfgs == nil || len(*cfgs) == 0 { // avoid sleeping if no services are configured
return
}
log.Printf("Ensuring that cluster traffic for egress targets is no longer routed via this Pod...")
var wg sync.WaitGroup
for s, cfg := range cfgs {
for s, cfg := range *cfgs {
hep := cfg.HealthCheckEndpoint
if hep == "" {
log.Printf("Tailnet target %q does not have a cluster healthcheck specified, unable to verify if cluster traffic for the target is still routed via this Pod", s)
continue
}
svc := s
// TODO(beckypauley): In dual-stack clusters, this is a best-effort check as we do not control which IP family is used.
// This confirms removal from routing on this node for one family only. The other IP family then relies on the longSleep below.
wg.Go(func() {
log.Printf("Ensuring that cluster traffic is no longer routed to %q via this Pod...", svc)
podIP, header := ep.podIPv4, kubetypes.PodIPv4Header
if podIP == "" {
podIP, header = ep.podIPv6, kubetypes.PodIPv6Header
}
if ep.podDrained(ctx, svc, hep, podIP, header, hp) {
return
}
ticker := time.NewTicker(ep.shortSleep)
defer ticker.Stop()
for {
select {
case <-ctx.Done(): // kubelet's HTTP request timeout
if ctx.Err() != nil { // kubelet's HTTP request timeout
log.Printf("Cluster traffic for %s did not stop being routed to this Pod.", svc)
return
case <-ticker.C:
if ep.podDrained(ctx, svc, hep, podIP, header, hp) {
return
}
}
found, err := lookupPodRoute(ctx, hep, ep.podIPv4, hp, ep.client)
if err != nil {
log.Printf("unable to reach endpoint %q, assuming the routing rules for this Pod have been deleted: %v", hep, err)
break
}
if !found {
log.Printf("service %q is no longer routed through this Pod", svc)
break
}
log.Printf("service %q is still routed through this Pod, waiting...", svc)
time.Sleep(ep.shortSleep)
}
})
}
@@ -715,9 +701,9 @@ func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs egressse
// lookupPodRoute calls the healthcheck endpoint repeat times and returns true if the endpoint returns with the podIP
// header at least once.
func lookupPodRoute(ctx context.Context, hep, podIP, podIPHeader string, repeat int, client httpClient) (bool, error) {
func lookupPodRoute(ctx context.Context, hep, podIP string, repeat int, client httpClient) (bool, error) {
for range repeat {
f, err := lookup(ctx, hep, podIP, podIPHeader, client)
f, err := lookup(ctx, hep, podIP, client)
if err != nil {
return false, err
}
@@ -729,7 +715,7 @@ func lookupPodRoute(ctx context.Context, hep, podIP, podIPHeader string, repeat
}
// lookup calls the healthcheck endpoint and returns true if the response contains the podIP header.
func lookup(ctx context.Context, hep, podIP, podIPHeader string, client httpClient) (bool, error) {
func lookup(ctx context.Context, hep, podIP string, client httpClient) (bool, error) {
req, err := http.NewRequestWithContext(ctx, httpm.GET, hep, nil)
if err != nil {
return false, fmt.Errorf("error creating new HTTP request: %v", err)
@@ -744,7 +730,7 @@ func lookup(ctx context.Context, hep, podIP, podIPHeader string, client httpClie
return true, nil
}
defer resp.Body.Close()
gotIP := resp.Header.Get(podIPHeader)
gotIP := resp.Header.Get(kubetypes.PodIPv4Header)
return strings.EqualFold(podIP, gotIP), nil
}
@@ -773,17 +759,3 @@ func (ep *egressProxy) getHEPPings() (int, error) {
}
return hp, nil
}
func (ep *egressProxy) podDrained(ctx context.Context, svc, hep, podIP, header string, hp int) bool {
found, err := lookupPodRoute(ctx, hep, podIP, header, hp, ep.client)
if err != nil {
log.Printf("unable to reach endpoint %q, assuming the routing rules for this Pod have been deleted: %v", hep, err)
return true
}
if !found {
log.Printf("service %q is no longer routed through this Pod", svc)
return true
}
log.Printf("service %q is still routed through this Pod, waiting...", svc)
return false
}
+3 -5
View File
@@ -15,7 +15,6 @@ import (
"strings"
"sync"
"testing"
"time"
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubetypes"
@@ -256,13 +255,13 @@ func TestWaitTillSafeToShutdown(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfgs := egressservices.Configs{}
cfgs := &egressservices.Configs{}
switches := make(map[string]int)
for svc, callsToSwitch := range tt.services {
endpoint := fmt.Sprintf("http://%s.local", svc)
if tt.healthCheckSet {
cfgs[svc] = egressservices.Config{
(*cfgs)[svc] = egressservices.Config{
HealthCheckEndpoint: endpoint,
}
}
@@ -270,8 +269,7 @@ func TestWaitTillSafeToShutdown(t *testing.T) {
}
ep := &egressProxy{
podIPv4: podIP,
shortSleep: time.Millisecond,
podIPv4: podIP,
client: &mockHTTPClient{
podIP: podIP,
anotherIP: anotherIP,
+1 -7
View File
@@ -265,13 +265,7 @@ func ensureIngressRulesAdded(cfgs map[string]ingressservices.Config, nfr linuxfw
func addDNATRuleForSvc(nfr linuxfw.NetfilterRunner, serviceName string, tsIP, clusterIP netip.Addr) error {
log.Printf("adding DNAT rule for Tailscale Service %s with IP %s to Kubernetes Service IP %s", serviceName, tsIP, clusterIP)
if err := nfr.EnsureDNATRuleForSvc(serviceName, tsIP, clusterIP); err != nil {
return err
}
if err := nfr.ClampMSSToPMTU(tailscaleTunInterface, clusterIP); err != nil {
return fmt.Errorf("error clamping MSS to PMTU: %w", err)
}
return nil
return nfr.EnsureDNATRuleForSvc(serviceName, tsIP, clusterIP)
}
// ensureIngressRulesDeleted takes a map of Tailscale Services and rules and ensures that the firewall rules are deleted.
+3 -39
View File
@@ -7,7 +7,6 @@ package main
import (
"net/netip"
"slices"
"testing"
"tailscale.com/kube/ingressservices"
@@ -23,7 +22,6 @@ func TestSyncIngressConfigs(t *testing.T) {
TailscaleServiceIP netip.Addr
ClusterIP netip.Addr
}
wantClampedAddrs []netip.Addr // cluster IPs that should have MSS clamping applied
}{
{
name: "add_new_rules_when_no_existing_config",
@@ -37,7 +35,6 @@ func TestSyncIngressConfigs(t *testing.T) {
}{
"svc:foo": makeWantService("100.64.0.1", "10.0.0.1"),
},
wantClampedAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
},
{
name: "add_multiple_services",
@@ -55,11 +52,6 @@ func TestSyncIngressConfigs(t *testing.T) {
"svc:bar": makeWantService("100.64.0.2", "10.0.0.2"),
"svc:baz": makeWantService("100.64.0.3", "10.0.0.3"),
},
wantClampedAddrs: []netip.Addr{
netip.MustParseAddr("10.0.0.1"),
netip.MustParseAddr("10.0.0.2"),
netip.MustParseAddr("10.0.0.3"),
},
},
{
name: "add_both_ipv4_and_ipv6_rules",
@@ -73,10 +65,6 @@ func TestSyncIngressConfigs(t *testing.T) {
}{
"svc:foo": makeWantService("2001:db8::1", "2001:db8::2"),
},
wantClampedAddrs: []netip.Addr{
netip.MustParseAddr("10.0.0.1"),
netip.MustParseAddr("2001:db8::2"),
},
},
{
name: "add_ipv6_only_rules",
@@ -90,7 +78,6 @@ func TestSyncIngressConfigs(t *testing.T) {
}{
"svc:ipv6": makeWantService("2001:db8::10", "2001:db8::20"),
},
wantClampedAddrs: []netip.Addr{netip.MustParseAddr("2001:db8::20")},
},
{
name: "delete_all_rules_when_config_removed",
@@ -107,7 +94,6 @@ func TestSyncIngressConfigs(t *testing.T) {
TailscaleServiceIP netip.Addr
ClusterIP netip.Addr
}{},
wantClampedAddrs: nil, // no rules added, no clamping
},
{
name: "add_remove_modify",
@@ -131,10 +117,6 @@ func TestSyncIngressConfigs(t *testing.T) {
"svc:foo": makeWantService("100.64.0.1", "10.0.0.2"),
"svc:new": makeWantService("100.64.0.4", "10.0.0.4"),
},
wantClampedAddrs: []netip.Addr{
netip.MustParseAddr("10.0.0.2"),
netip.MustParseAddr("10.0.0.4"),
},
},
{
name: "update_with_outdated_status",
@@ -170,17 +152,12 @@ func TestSyncIngressConfigs(t *testing.T) {
"svc:web-ipv6": makeWantService("2001:db8::10", "2001:db8::20"),
"svc:api": makeWantService("100.64.0.20", "10.0.0.20"),
},
wantClampedAddrs: []netip.Addr{
netip.MustParseAddr("10.0.0.10"),
netip.MustParseAddr("10.0.0.20"),
netip.MustParseAddr("2001:db8::20"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
nfr := linuxfw.NewFakeNetfilterRunner()
var nfr linuxfw.NetfilterRunner = linuxfw.NewFakeNetfilterRunner()
ep := &ingressProxy{
nfr: nfr,
@@ -193,7 +170,8 @@ func TestSyncIngressConfigs(t *testing.T) {
t.Fatalf("syncIngressConfigs failed: %v", err)
}
gotServices := nfr.GetServiceState()
fake := nfr.(*linuxfw.FakeNetfilterRunner)
gotServices := fake.GetServiceState()
if len(gotServices) != len(tt.wantServices) {
t.Errorf("got %d services, want %d", len(gotServices), len(tt.wantServices))
}
@@ -210,20 +188,6 @@ func TestSyncIngressConfigs(t *testing.T) {
t.Errorf("service %s: got ClusterIP %v, want %v", svc, got.ClusterIP, want.ClusterIP)
}
}
gotClamped := nfr.GetClampedAddrs()
slices.SortFunc(gotClamped, func(a, b netip.Addr) int { return a.Compare(b) })
slices.SortFunc(tt.wantClampedAddrs, func(a, b netip.Addr) int { return a.Compare(b) })
if len(gotClamped) != len(tt.wantClampedAddrs) {
t.Errorf("ClampMSSToPMTU: got %v, want %v", gotClamped, tt.wantClampedAddrs)
} else {
for i := range gotClamped {
if gotClamped[i] != tt.wantClampedAddrs[i] {
t.Errorf("ClampMSSToPMTU: got %v, want %v", gotClamped, tt.wantClampedAddrs)
break
}
}
}
})
}
}
+86 -29
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 {
@@ -142,7 +139,12 @@ func (kc *kubeClient) resetContainerbootState(ctx context.Context, podUID string
s := &kubeapi.Secret{
Data: map[string][]byte{
kubetypes.KeyCapVer: fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion),
kubetypes.KeyCapVer: fmt.Appendf(nil, "%d", tailcfg.CurrentCapabilityVersion),
// TODO(tomhjp): Perhaps shouldn't clear device ID and use a different signal, as this could leak tailnet devices.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil,
@@ -167,18 +169,47 @@ func (kc *kubeClient) setAndWaitForAuthKeyReissue(ctx context.Context, client *l
return fmt.Errorf("error disconnecting from control: %w", err)
}
err = authkey.SetReissueAuthKey(ctx, kc.Client, kc.stateSecret, tailscaledConfigAuthKey, authkey.TailscaleContainerFieldManager)
err = kc.setReissueAuthKey(ctx, tailscaledConfigAuthKey)
if err != nil {
return fmt.Errorf("failed to set reissue_authkey in Kubernetes Secret: %w", err)
}
clearFn := func(ctx context.Context) error {
return authkey.ClearReissueAuthKey(ctx, kc.Client, kc.stateSecret, authkey.TailscaleContainerFieldManager)
err = kc.waitForAuthKeyReissue(ctx, cfg.TailscaledConfigFilePath, tailscaledConfigAuthKey, 10*time.Minute)
if err != nil {
return fmt.Errorf("failed to receive new auth key: %w", err)
}
getAuthKey := func() string { return authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath) }
tailscaledCfgDir := filepath.Dir(cfg.TailscaledConfigFilePath)
var notify <-chan struct{}
return nil
}
func (kc *kubeClient) setReissueAuthKey(ctx context.Context, authKey string) error {
s := &kubeapi.Secret{
Data: map[string][]byte{
kubetypes.KeyReissueAuthkey: []byte(authKey),
},
}
log.Printf("Requesting a new auth key from operator")
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, fieldManager)
}
func (kc *kubeClient) waitForAuthKeyReissue(ctx context.Context, configPath string, oldAuthKey string, maxWait time.Duration) error {
log.Printf("Waiting for operator to provide new auth key (max wait: %v)", maxWait)
ctx, cancel := context.WithTimeout(ctx, maxWait)
defer cancel()
tailscaledCfgDir := filepath.Dir(configPath)
toWatch := filepath.Join(tailscaledCfgDir, kubeletMountedConfigLn)
var (
pollTicker <-chan time.Time
eventChan <-chan fsnotify.Event
)
pollInterval := 5 * time.Second
// Try to use fsnotify for faster notification
if w, err := fsnotify.NewWatcher(); err != nil {
log.Printf("auth key reissue: fsnotify unavailable, using polling: %v", err)
} else if err := w.Add(tailscaledCfgDir); err != nil {
@@ -186,28 +217,54 @@ func (kc *kubeClient) setAndWaitForAuthKeyReissue(ctx context.Context, client *l
log.Printf("auth key reissue: fsnotify watch failed, using polling: %v", err)
} else {
defer w.Close()
ch := make(chan struct{}, 1)
toWatch := filepath.Join(tailscaledCfgDir, "..data")
go func() {
for ev := range w.Events {
if ev.Name == toWatch {
select {
case ch <- struct{}{}:
default:
}
}
}
}()
notify = ch
log.Printf("auth key reissue: watching for config changes via fsnotify")
eventChan = w.Events
}
err = authkey.WaitForAuthKeyReissue(ctx, tailscaledConfigAuthKey, 10*time.Minute, getAuthKey, clearFn, notify)
if err != nil {
return fmt.Errorf("failed to receive new auth key: %w", err)
}
// still keep polling if using fsnotify, for logging and in case fsnotify fails
pt := time.NewTicker(pollInterval)
defer pt.Stop()
pollTicker = pt.C
return nil
start := time.Now()
for {
select {
case <-ctx.Done():
return fmt.Errorf("timeout waiting for auth key reissue after %v", maxWait)
case <-pollTicker: // Waits for polling tick, continues when received
case event := <-eventChan:
if event.Name != toWatch {
continue
}
}
newAuthKey := authkeyFromTailscaledConfig(configPath)
if newAuthKey != "" && newAuthKey != oldAuthKey {
log.Printf("New auth key received from operator after %v", time.Since(start).Round(time.Second))
if err := kc.clearReissueAuthKeyRequest(ctx); err != nil {
log.Printf("Warning: failed to clear reissue request: %v", err)
}
return nil
}
if eventChan == nil && pollTicker != nil {
log.Printf("Waiting for new auth key from operator (%v elapsed)", time.Since(start).Round(time.Second))
}
}
}
// clearReissueAuthKeyRequest removes the reissue_authkey marker from the Secret
// to signal to the operator that we've successfully received the new key.
func (kc *kubeClient) clearReissueAuthKeyRequest(ctx context.Context) error {
s := &kubeapi.Secret{
Data: map[string][]byte{
kubetypes.KeyReissueAuthkey: nil,
},
}
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, fieldManager)
}
// waitForConsistentState waits for tailscaled to finish writing state if it
+23 -3
View File
@@ -257,8 +257,12 @@ func TestResetContainerbootState(t *testing.T) {
authkey: "new-authkey",
initial: map[string][]byte{},
expected: map[string][]byte{
kubetypes.KeyCapVer: capver,
kubetypes.KeyPodUID: []byte("1234"),
kubetypes.KeyCapVer: capver,
kubetypes.KeyPodUID: []byte("1234"),
// Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil,
@@ -267,7 +271,11 @@ func TestResetContainerbootState(t *testing.T) {
"empty_initial_no_pod_uid": {
initial: map[string][]byte{},
expected: map[string][]byte{
kubetypes.KeyCapVer: capver,
kubetypes.KeyCapVer: capver,
// Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil,
@@ -295,6 +303,9 @@ func TestResetContainerbootState(t *testing.T) {
kubetypes.KeyCapVer: capver,
kubetypes.KeyPodUID: []byte("1234"),
// Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil,
@@ -310,6 +321,9 @@ func TestResetContainerbootState(t *testing.T) {
kubetypes.KeyCapVer: capver,
kubetypes.KeyReissueAuthkey: nil,
// Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil,
@@ -324,6 +338,9 @@ func TestResetContainerbootState(t *testing.T) {
kubetypes.KeyCapVer: capver,
// reissue_authkey not cleared.
// Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil,
@@ -338,6 +355,9 @@ func TestResetContainerbootState(t *testing.T) {
kubetypes.KeyCapVer: capver,
// reissue_authkey not cleared.
// Cleared keys.
kubetypes.KeyDeviceID: nil,
kubetypes.KeyDeviceFQDN: nil,
kubetypes.KeyDeviceIPs: nil,
kubetypes.KeyHTTPSEndpoint: nil,
egressservices.KeyEgressServices: nil,
ingressservices.IngressConfigKey: nil,
+264 -444
View File
@@ -120,7 +120,6 @@ import (
"errors"
"fmt"
"io/fs"
"iter"
"log"
"math"
"net"
@@ -136,15 +135,12 @@ import (
"syscall"
"time"
"github.com/benbjohnson/immutable"
"golang.org/x/sys/unix"
"tailscale.com/client/local"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/ipn/conffile"
kubeutils "tailscale.com/k8s-operator"
"tailscale.com/kube/authkey"
healthz "tailscale.com/kube/health"
"tailscale.com/kube/kubetypes"
klc "tailscale.com/kube/localclient"
@@ -152,170 +148,21 @@ import (
"tailscale.com/kube/services"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/types/netmap"
"tailscale.com/util/deephash"
"tailscale.com/util/def"
"tailscale.com/util/dnsname"
"tailscale.com/util/linuxfw"
)
func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) {
if def.Bool(os.Getenv("TS_TEST_FAKE_NETFILTER"), false) {
if defaultBool("TS_TEST_FAKE_NETFILTER", false) {
return linuxfw.NewFakeIPTablesRunner(), nil
}
return linuxfw.New(logf, "")
}
func getAutoAdvertiseBool() bool {
return def.Bool(os.Getenv("TS_EXPERIMENTAL_SERVICE_AUTO_ADVERTISEMENT"), true)
}
const containerbootWatchMask = ipn.NotifyInitialStatus |
ipn.NotifyPeerChanges |
ipn.NotifyNoNetMap
func notifyState(n ipn.Notify) (_ ipn.State, ok bool) {
if n.State != nil {
return *n.State, true
}
if n.InitialStatus != nil && n.InitialStatus.BackendState != "" {
if state, ok := ipn.StateFromString(n.InitialStatus.BackendState); ok {
return state, true
}
}
return ipn.NoState, false
}
var netmapStatePeerIDHasher = immutable.NewHasher(tailcfg.NodeID(0))
type netmapState struct {
self tailcfg.NodeView
peersByID *immutable.Map[tailcfg.NodeID, tailcfg.NodeView]
peersByName *immutable.Map[string, tailcfg.NodeView] // keyed by tailcfg.Node.Name when NodeID is unavailable
certDomains views.Slice[string]
dnsExtraRecords views.Slice[tailcfg.DNSRecord]
}
func (s netmapState) updateFromNotify(n ipn.Notify) netmapState {
if n.InitialStatus != nil {
s = s.updateFromStatus(n.InitialStatus)
}
if n.SelfChange != nil {
s.self = n.SelfChange.View()
}
for _, p := range n.PeersChanged {
s = s.upsertPeer(p.View())
}
for _, id := range n.PeersRemoved {
if s.peersByID != nil {
s.peersByID = s.peersByID.Delete(id)
}
}
return s
}
// processNotify updates the netmap state from an IPN bus Notify. On
// SelfChange it also refetches DNS via the LocalAPI dns-config
// endpoint; the bus carries no DNS delta.
func (s netmapState) processNotify(ctx context.Context, client *local.Client, n ipn.Notify) netmapState {
s = s.updateFromNotify(n)
if n.SelfChange != nil {
dns, err := client.DNSConfig(ctx)
if err != nil {
log.Printf("error refreshing DNS config from tailscaled: %v", err)
} else if dns != nil {
s.dnsExtraRecords = views.SliceOf(dns.ExtraRecords)
s.certDomains = views.SliceOf(dns.CertDomains)
}
}
return s
}
func (s netmapState) updateFromStatus(st *ipnstate.Status) netmapState {
s.certDomains = views.SliceOf(st.CertDomains)
s.dnsExtraRecords = views.SliceOf(st.ExtraRecords)
if st.Self != nil {
s.self = nodeFromPeerStatus(st.Self).View()
}
if len(st.Peer) != 0 {
s.peersByID = nil
s.peersByName = nil
for _, ps := range st.Peer {
s = s.upsertPeer(nodeFromPeerStatus(ps).View())
}
}
return s
}
func (s netmapState) upsertPeer(n tailcfg.NodeView) netmapState {
if !n.Valid() {
return s
}
if s.peersByID == nil {
s.peersByID = immutable.NewMap[tailcfg.NodeID, tailcfg.NodeView](netmapStatePeerIDHasher)
}
if s.peersByName == nil {
s.peersByName = immutable.NewMap[string, tailcfg.NodeView](nil)
}
if n.ID() != 0 {
s.peersByID = s.peersByID.Set(n.ID(), n)
if name := n.Name(); name != "" {
s.peersByName = s.peersByName.Delete(name)
}
return s
}
if n.Name() != "" {
s.peersByName = s.peersByName.Set(n.Name(), n)
}
return s
}
func nodeFromPeerStatus(ps *ipnstate.PeerStatus) *tailcfg.Node {
if ps == nil {
return nil
}
n := &tailcfg.Node{
ID: ps.NodeID,
StableID: ps.ID,
Name: ps.DNSName,
Key: ps.PublicKey,
}
for _, ip := range ps.TailscaleIPs {
n.Addresses = append(n.Addresses, netip.PrefixFrom(ip, ip.BitLen()))
}
if ps.AllowedIPs != nil {
n.AllowedIPs = ps.AllowedIPs.AsSlice()
}
return n
}
func (s netmapState) peers() iter.Seq[tailcfg.NodeView] {
return func(yield func(tailcfg.NodeView) bool) {
if s.peersByID != nil {
it := s.peersByID.Iterator()
for {
_, p, ok := it.Next()
if !ok {
break
}
if !yield(p) {
return
}
}
}
if s.peersByName != nil {
it := s.peersByName.Iterator()
for {
_, p, ok := it.Next()
if !ok {
break
}
if !yield(p) {
return
}
}
}
}
return defaultBool("TS_EXPERIMENTAL_SERVICE_AUTO_ADVERTISEMENT", true)
}
func main() {
@@ -362,7 +209,7 @@ func run() error {
var tailscaledConfigAuthkey string
if isOneStepConfig(cfg) {
tailscaledConfigAuthkey = authkey.AuthKeyFromConfig(cfg.TailscaledConfigFilePath)
tailscaledConfigAuthkey = authkeyFromTailscaledConfig(cfg.TailscaledConfigFilePath)
}
var kc *kubeClient
@@ -424,7 +271,7 @@ func run() error {
mux := http.NewServeMux()
log.Printf("Running healthcheck endpoint at %s/healthz", cfg.HealthCheckAddrPort)
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, cfg.PodIPv6, log.Printf)
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, log.Printf)
close := runHTTPServer(mux, cfg.HealthCheckAddrPort)
defer close()
@@ -440,7 +287,7 @@ func run() error {
if cfg.localHealthEnabled() {
log.Printf("Running healthcheck endpoint at %s/healthz", cfg.LocalAddrPort)
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, cfg.PodIPv6, log.Printf)
healthCheck = healthz.RegisterHealthHandlers(mux, cfg.PodIPv4, log.Printf)
}
if cfg.egressSvcsTerminateEPEnabled() {
@@ -458,7 +305,7 @@ func run() error {
}
}
w, err := client.WatchIPNBus(bootCtx, containerbootWatchMask|ipn.NotifyInitialPrefs|ipn.NotifyInitialHealthState)
w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState|ipn.NotifyInitialHealthState|ipn.NotifyRateLimit)
if err != nil {
return fmt.Errorf("failed to watch tailscaled for updates: %w", err)
}
@@ -498,7 +345,7 @@ func run() error {
if err := tailscaleUp(bootCtx, cfg); err != nil {
return fmt.Errorf("failed to auth tailscale: %w", err)
}
w, err = client.WatchIPNBus(bootCtx, containerbootWatchMask)
w, err = client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState|ipn.NotifyRateLimit)
if err != nil {
return fmt.Errorf("rewatching tailscaled for updates after auth: %w", err)
}
@@ -518,8 +365,8 @@ authLoop:
return fmt.Errorf("failed to read from tailscaled: %w", err)
}
if state, ok := notifyState(n); ok {
switch state {
if n.State != nil {
switch *n.State {
case ipn.NeedsLogin:
if isOneStepConfig(cfg) {
// This could happen if this is the first time tailscaled was run for this
@@ -527,7 +374,7 @@ authLoop:
if hasKubeStateStore(cfg) {
log.Printf("Auth key missing or invalid (NeedsLogin state), disconnecting from control and requesting new key from operator")
err := kc.setAndWaitForAuthKeyReissue(ctx, client, cfg, tailscaledConfigAuthkey)
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey)
if err != nil {
return fmt.Errorf("failed to get a reissued authkey: %w", err)
}
@@ -555,7 +402,7 @@ authLoop:
// deadline to continue monitoring for changes.
break authLoop
default:
log.Printf("tailscaled in state %q, waiting", state)
log.Printf("tailscaled in state %q, waiting", *n.State)
}
}
@@ -567,7 +414,7 @@ authLoop:
if isOneStepConfig(cfg) && hasKubeStateStore(cfg) {
log.Printf("Auth key failed to authenticate (may be expired or single-use), disconnecting from control and requesting new key from operator")
err := kc.setAndWaitForAuthKeyReissue(ctx, client, cfg, tailscaledConfigAuthkey)
err := kc.setAndWaitForAuthKeyReissue(bootCtx, client, cfg, tailscaledConfigAuthkey)
if err != nil {
return fmt.Errorf("failed to get a reissued authkey: %w", err)
}
@@ -610,7 +457,7 @@ authLoop:
}
}
w, err = client.WatchIPNBus(ctx, containerbootWatchMask)
w, err = client.WatchIPNBus(ctx, ipn.NotifyInitialNetMap|ipn.NotifyInitialState|ipn.NotifyRateLimit)
if err != nil {
return fmt.Errorf("rewatching tailscaled for updates after auth: %w", err)
}
@@ -689,7 +536,7 @@ authLoop:
failedResolveAttempts++
}
var egressSvcsNotify chan netmapState
var egressSvcsNotify chan ipn.Notify
notifyChan := make(chan ipn.Notify)
errChan := make(chan error)
go func() {
@@ -703,12 +550,10 @@ authLoop:
}
}
}()
var nmState netmapState
var wg sync.WaitGroup
runLoop:
for {
var processNetmap bool
select {
case <-ctx.Done():
// Although killTailscaled() is deferred earlier, if we
@@ -722,17 +567,244 @@ runLoop:
case err := <-cfgWatchErrChan:
return fmt.Errorf("failed to watch tailscaled config: %w", err)
case n := <-notifyChan:
nmState = nmState.processNotify(ctx, client, n)
if state, ok := notifyState(n); ok && state != ipn.Running {
// TODO: (ChaosInTheCRD) Add node removed check when supported by ipn
if n.State != nil && *n.State != ipn.Running {
// Something's gone wrong and we've left the authenticated state.
// Our container image never recovered gracefully from this, and the
// control flow required to make it work now is hard. So, just crash
// the container and rely on the container runtime to restart us,
// whereupon we'll go through initial auth again.
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", state)
return fmt.Errorf("tailscaled left running state (now in state %q), exiting", *n.State)
}
if n.InitialStatus != nil || n.SelfChange != nil || len(n.PeersChanged) != 0 || len(n.PeersRemoved) != 0 || len(n.PeerChangedPatch) != 0 {
processNetmap = true
if n.NetMap != nil {
addrs = n.NetMap.SelfNode.Addresses().AsSlice()
newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs
// Store device ID in a Kubernetes Secret before
// setting up any routing rules. This ensures
// that, for containerboot instances that are
// Kubernetes operator proxies, the operator is
// able to retrieve the device ID from the
// Kubernetes Secret to clean up tailnet nodes
// for proxies whose route setup continuously
// fails.
deviceID := n.NetMap.SelfNode.StableID()
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceID, &deviceID) {
if err := kc.storeDeviceID(ctx, n.NetMap.SelfNode.StableID()); err != nil {
return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err)
}
}
if cfg.TailnetTargetFQDN != "" {
egressAddrs, err := resolveTailnetFQDN(n.NetMap, cfg.TailnetTargetFQDN)
if err != nil {
log.Print(err.Error())
break
}
newCurentEgressIPs := deephash.Hash(&egressAddrs)
egressIPsHaveChanged := newCurentEgressIPs != currentEgressIPs
// The firewall rules get (re-)installed:
// - on startup
// - when the tailnet IPs of the tailnet target have changed
// - when the tailnet IPs of this node have changed
if (egressIPsHaveChanged || ipsHaveChanged) && len(egressAddrs) != 0 {
var rulesInstalled bool
for _, egressAddr := range egressAddrs {
ea := egressAddr.Addr()
if ea.Is4() || (ea.Is6() && nfr.HasIPV6NAT()) {
rulesInstalled = true
log.Printf("Installing forwarding rules for destination %v", ea.String())
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
return fmt.Errorf("installing egress proxy rules for destination %s: %v", ea.String(), err)
}
}
}
if !rulesInstalled {
return fmt.Errorf("no forwarding rules for egress addresses %v, host supports IPv6: %v", egressAddrs, nfr.HasIPV6NAT())
}
}
currentEgressIPs = newCurentEgressIPs
}
if cfg.ProxyTargetIP != "" && len(addrs) != 0 && ipsHaveChanged {
log.Printf("Installing proxy rules")
if err := installIngressForwardingRule(ctx, cfg.ProxyTargetIP, addrs, nfr); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
}
}
if cfg.ProxyTargetDNSName != "" && len(addrs) != 0 && ipsHaveChanged {
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
if err != nil {
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
resetTimer(true)
continue
}
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
}))
if backendsHaveChanged {
log.Printf("installing ingress proxy rules for backends %v", newBackendAddrs)
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
return fmt.Errorf("error installing ingress proxy rules: %w", err)
}
}
resetTimer(false)
backendAddrs = newBackendAddrs
}
if cfg.ServeConfigPath != "" {
cd := certDomainFromNetmap(n.NetMap)
if cd == "" {
cd = kubetypes.ValueNoHTTPS
}
prev := certDomain.Swap(new(cd))
if prev == nil || *prev != cd {
select {
case certDomainChanged <- true:
default:
}
}
}
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) != 0 {
log.Printf("Installing forwarding rules for destination %v", cfg.TailnetTargetIP)
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs, nfr); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
}
// If this is a L7 cluster ingress proxy (set up
// by Kubernetes operator) and proxying of
// cluster traffic to the ingress target is
// enabled, set up proxy rule each time the
// tailnet IPs of this node change (including
// the first time they become available).
if cfg.AllowProxyingClusterTrafficViaIngress && cfg.ServeConfigPath != "" && ipsHaveChanged && len(addrs) != 0 {
log.Printf("installing rules to forward traffic for %s to node's tailnet IP", cfg.PodIP)
if err := installTSForwardingRuleForDestination(ctx, cfg.PodIP, addrs, nfr); err != nil {
return fmt.Errorf("installing rules to forward traffic to node's tailnet IP: %w", err)
}
}
currentIPs = newCurrentIPs
// Only store device FQDN and IP addresses to
// Kubernetes Secret when any required proxy
// route setup has succeeded. IPs and FQDN are
// read from the Secret by the Tailscale
// Kubernetes operator and, for some proxy
// types, such as Tailscale Ingress, advertized
// on the Ingress status. Writing them to the
// Secret only after the proxy routing has been
// set up ensures that the operator does not
// advertize endpoints of broken proxies.
// TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
deviceEndpoints := []any{n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses()}
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceEndpoints, &deviceEndpoints) {
if err := kc.storeDeviceEndpoints(ctx, n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
return fmt.Errorf("storing device IPs and FQDN in Kubernetes Secret: %w", err)
}
}
if healthCheck != nil {
healthCheck.Update(len(addrs) != 0)
}
var prevServeConfig *ipn.ServeConfig
if getAutoAdvertiseBool() {
prevServeConfig, err = client.GetServeConfig(ctx)
if err != nil {
return fmt.Errorf("autoadvertisement: failed to get serve config: %w", err)
}
err = refreshAdvertiseServices(ctx, prevServeConfig, klc.New(client))
if err != nil {
return fmt.Errorf("autoadvertisement: failed to refresh advertise services: %w", err)
}
}
if cfg.ServeConfigPath != "" {
triggerWatchServeConfigChanges.Do(func() {
go watchServeConfigChanges(ctx, certDomainChanged, certDomain, client, kc, cfg, prevServeConfig)
})
}
if egressSvcsNotify != nil {
egressSvcsNotify <- n
}
}
if !startupTasksDone {
// For containerboot instances that act as TCP proxies (proxying traffic to an endpoint
// passed via one of the env vars that containerboot reads) and store state in a
// Kubernetes Secret, we consider startup tasks done at the point when device info has
// been successfully stored to state Secret. For all other containerboot instances, if
// we just get to this point the startup tasks can be considered done.
if !isL3Proxy(cfg) || !hasKubeStateStore(cfg) || (currentDeviceEndpoints != deephash.Sum{} && currentDeviceID != deephash.Sum{}) {
// This log message is used in tests to detect when all
// post-auth configuration is done.
log.Println("Startup complete, waiting for shutdown signal")
startupTasksDone = true
// Configure egress proxy. Egress proxy will set up firewall rules to proxy
// traffic to tailnet targets configured in the provided configuration file. It
// will then continuously monitor the config file and netmap updates and
// reconfigure the firewall rules as needed. If any of its operations fail, it
// will crash this node.
if cfg.EgressProxiesCfgPath != "" {
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath)
egressSvcsNotify = make(chan ipn.Notify)
opts := egressProxyRunOpts{
cfgPath: cfg.EgressProxiesCfgPath,
nfr: nfr,
kc: kc,
tsClient: client,
stateSecret: cfg.KubeSecret,
netmapChan: egressSvcsNotify,
podIPv4: cfg.PodIPv4,
tailnetAddrs: addrs,
}
go func() {
if err := ep.run(ctx, n, opts); err != nil {
egressSvcsErrorChan <- err
}
}()
}
ip := ingressProxy{}
if cfg.IngressProxiesCfgPath != "" {
log.Printf("configuring ingress proxy using configuration file at %s", cfg.IngressProxiesCfgPath)
opts := ingressProxyOpts{
cfgPath: cfg.IngressProxiesCfgPath,
nfr: nfr,
kc: kc,
stateSecret: cfg.KubeSecret,
podIPv4: cfg.PodIPv4,
podIPv6: cfg.PodIPv6,
}
go func() {
if err := ip.run(ctx, opts); err != nil {
ingressSvcsErrorChan <- err
}
}()
}
// Wait on tailscaled process. It won't be cleaned up by default when the
// container exits as it is not PID1. TODO (irbekrm): perhaps we can replace the
// reaper by a running cmd.Wait in a goroutine immediately after starting
// tailscaled?
reaper := func() {
defer wg.Done()
for {
var status unix.WaitStatus
_, err := unix.Wait4(daemonProcess.Pid, &status, 0, nil)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
log.Fatalf("Waiting for tailscaled to exit: %v", err)
}
log.Print("tailscaled exited")
os.Exit(0)
}
}
wg.Add(1)
go reaper()
}
}
case <-tc:
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
@@ -752,253 +824,11 @@ runLoop:
}
backendAddrs = newBackendAddrs
resetTimer(false)
continue
case e := <-egressSvcsErrorChan:
return fmt.Errorf("egress proxy failed: %v", e)
case e := <-ingressSvcsErrorChan:
return fmt.Errorf("ingress proxy failed: %v", e)
}
if !processNetmap {
continue
}
self := nmState.self
if !self.Valid() {
continue
}
{
addrs = self.Addresses().AsSlice()
newCurrentIPs := deephash.Hash(&addrs)
ipsHaveChanged := newCurrentIPs != currentIPs
// Store device ID in a Kubernetes Secret before
// setting up any routing rules. This ensures
// that, for containerboot instances that are
// Kubernetes operator proxies, the operator is
// able to retrieve the device ID from the
// Kubernetes Secret to clean up tailnet nodes
// for proxies whose route setup continuously
// fails.
deviceID := self.StableID()
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceID, &deviceID) {
if err := kc.storeDeviceID(ctx, deviceID); err != nil {
return fmt.Errorf("storing device ID in Kubernetes Secret: %w", err)
}
}
if cfg.TailnetTargetFQDN != "" {
egressAddrs, err := resolveTailnetFQDN(nmState, cfg.TailnetTargetFQDN)
if err != nil {
log.Print(err.Error())
break
}
newCurentEgressIPs := deephash.Hash(&egressAddrs)
egressIPsHaveChanged := newCurentEgressIPs != currentEgressIPs
// The firewall rules get (re-)installed:
// - on startup
// - when the tailnet IPs of the tailnet target have changed
// - when the tailnet IPs of this node have changed
if (egressIPsHaveChanged || ipsHaveChanged) && len(egressAddrs) != 0 {
var rulesInstalled bool
for _, egressAddr := range egressAddrs {
ea := egressAddr.Addr()
if ea.Is4() || (ea.Is6() && nfr.HasIPV6NAT()) {
rulesInstalled = true
log.Printf("Installing forwarding rules for destination %v", ea.String())
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
return fmt.Errorf("installing egress proxy rules for destination %s: %v", ea.String(), err)
}
}
}
if !rulesInstalled {
return fmt.Errorf("no forwarding rules for egress addresses %v, host supports IPv6: %v", egressAddrs, nfr.HasIPV6NAT())
}
}
currentEgressIPs = newCurentEgressIPs
}
if cfg.ProxyTargetIP != "" && len(addrs) != 0 && ipsHaveChanged {
log.Printf("Installing proxy rules")
if err := installIngressForwardingRule(ctx, cfg.ProxyTargetIP, addrs, nfr); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
}
}
if cfg.ProxyTargetDNSName != "" && len(addrs) != 0 && ipsHaveChanged {
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
if err != nil {
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
resetTimer(true)
continue
}
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
}))
if backendsHaveChanged {
log.Printf("installing ingress proxy rules for backends %v", newBackendAddrs)
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
return fmt.Errorf("error installing ingress proxy rules: %w", err)
}
}
resetTimer(false)
backendAddrs = newBackendAddrs
}
if cfg.ServeConfigPath != "" {
var cd string
if nmState.certDomains.Len() != 0 {
cd = nmState.certDomains.At(0)
}
if cd == "" {
cd = kubetypes.ValueNoHTTPS
}
prev := certDomain.Swap(new(cd))
if prev == nil || *prev != cd {
select {
case certDomainChanged <- true:
default:
}
}
}
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) != 0 {
log.Printf("Installing forwarding rules for destination %v", cfg.TailnetTargetIP)
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs, nfr); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
}
// If this is a L7 cluster ingress proxy (set up
// by Kubernetes operator) and proxying of
// cluster traffic to the ingress target is
// enabled, set up proxy rule each time the
// tailnet IPs of this node change (including
// the first time they become available).
if cfg.AllowProxyingClusterTrafficViaIngress && cfg.ServeConfigPath != "" && ipsHaveChanged && len(addrs) != 0 {
log.Printf("installing rules to forward traffic for %s to node's tailnet IP", cfg.PodIP)
if err := installTSForwardingRuleForDestination(ctx, cfg.PodIP, addrs, nfr); err != nil {
return fmt.Errorf("installing rules to forward traffic to node's tailnet IP: %w", err)
}
}
currentIPs = newCurrentIPs
// Only store device FQDN and IP addresses to
// Kubernetes Secret when any required proxy
// route setup has succeeded. IPs and FQDN are
// read from the Secret by the Tailscale
// Kubernetes operator and, for some proxy
// types, such as Tailscale Ingress, advertized
// on the Ingress status. Writing them to the
// Secret only after the proxy routing has been
// set up ensures that the operator does not
// advertize endpoints of broken proxies.
// TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
deviceEndpoints := []any{self.Name(), self.Addresses()}
if hasKubeStateStore(cfg) && deephash.Update(&currentDeviceEndpoints, &deviceEndpoints) {
if err := kc.storeDeviceEndpoints(ctx, self.Name(), addrs); err != nil {
return fmt.Errorf("storing device IPs and FQDN in Kubernetes Secret: %w", err)
}
}
if healthCheck != nil {
healthCheck.Update(len(addrs) != 0)
}
var prevServeConfig *ipn.ServeConfig
if getAutoAdvertiseBool() {
prevServeConfig, err = client.GetServeConfig(ctx)
if err != nil {
return fmt.Errorf("autoadvertisement: failed to get serve config: %w", err)
}
err = refreshAdvertiseServices(ctx, prevServeConfig, klc.New(client))
if err != nil {
return fmt.Errorf("autoadvertisement: failed to refresh advertise services: %w", err)
}
}
if cfg.ServeConfigPath != "" {
triggerWatchServeConfigChanges.Do(func() {
go watchServeConfigChanges(ctx, certDomainChanged, certDomain, client, kc, cfg, prevServeConfig)
})
}
if egressSvcsNotify != nil {
egressSvcsNotify <- nmState
}
}
if !startupTasksDone {
// For containerboot instances that act as TCP proxies (proxying traffic to an endpoint
// passed via one of the env vars that containerboot reads) and store state in a
// Kubernetes Secret, we consider startup tasks done at the point when device info has
// been successfully stored to state Secret. For all other containerboot instances, if
// we just get to this point the startup tasks can be considered done.
if !isL3Proxy(cfg) || !hasKubeStateStore(cfg) || (currentDeviceEndpoints != deephash.Sum{} && currentDeviceID != deephash.Sum{}) {
// This log message is used in tests to detect when all
// post-auth configuration is done.
log.Println("Startup complete, waiting for shutdown signal")
startupTasksDone = true
// Configure egress proxy. Egress proxy will set up firewall rules to proxy
// traffic to tailnet targets configured in the provided configuration file. It
// will then continuously monitor the config file and netmap updates and
// reconfigure the firewall rules as needed. If any of its operations fail, it
// will crash this node.
if cfg.EgressProxiesCfgPath != "" {
log.Printf("configuring egress proxy using configuration file at %s", cfg.EgressProxiesCfgPath)
egressSvcsNotify = make(chan netmapState)
opts := egressProxyRunOpts{
cfgPath: cfg.EgressProxiesCfgPath,
nfr: nfr,
kc: kc,
tsClient: client,
stateSecret: cfg.KubeSecret,
netmapChan: egressSvcsNotify,
podIPv4: cfg.PodIPv4,
podIPv6: cfg.PodIPv6,
tailnetAddrs: addrs,
}
go func() {
if err := ep.run(ctx, nmState, opts); err != nil {
egressSvcsErrorChan <- err
}
}()
}
ip := ingressProxy{}
if cfg.IngressProxiesCfgPath != "" {
log.Printf("configuring ingress proxy using configuration file at %s", cfg.IngressProxiesCfgPath)
opts := ingressProxyOpts{
cfgPath: cfg.IngressProxiesCfgPath,
nfr: nfr,
kc: kc,
stateSecret: cfg.KubeSecret,
podIPv4: cfg.PodIPv4,
podIPv6: cfg.PodIPv6,
}
go func() {
if err := ip.run(ctx, opts); err != nil {
ingressSvcsErrorChan <- err
}
}()
}
// Wait on tailscaled process. It won't be cleaned up by default when the
// container exits as it is not PID1. TODO (irbekrm): perhaps we can replace the
// reaper by a running cmd.Wait in a goroutine immediately after starting
// tailscaled?
reaper := func() {
defer wg.Done()
for {
var status unix.WaitStatus
_, err := unix.Wait4(daemonProcess.Pid, &status, 0, nil)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
log.Fatalf("Waiting for tailscaled to exit: %v", err)
}
log.Print("tailscaled exited")
os.Exit(0)
}
}
wg.Add(1)
go reaper()
}
}
}
wg.Wait()
@@ -1134,52 +964,34 @@ func runHTTPServer(mux *http.ServeMux, addr string) (close func() error) {
}
// resolveTailnetFQDN resolves a tailnet FQDN to a list of IP prefixes, which
// can be either a peer device, a Tailscale Service, or a 4via6 synthesized
// DNS name (e.g. "10-1-0-5-via-7.tailnet.ts.net").
func resolveTailnetFQDN(nm netmapState, fqdn string) ([]netip.Prefix, error) {
// can be either a peer device or a Tailscale Service.
func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, error) {
dnsFQDN, err := dnsname.ToFQDN(fqdn)
if err != nil {
return nil, fmt.Errorf("error parsing %q as FQDN: %w", fqdn, err)
}
// Check all peer devices first.
var ret []netip.Prefix
for p := range nm.peers() {
for _, p := range nm.Peers {
if strings.EqualFold(p.Name(), dnsFQDN.WithTrailingDot()) {
ret = p.Addresses().AsSlice()
break
return p.Addresses().AsSlice(), nil
}
}
if ret != nil {
return ret, nil
}
// If not found yet, check for a matching Tailscale Service.
if svcIPs := serviceIPsFromNetMap(nm, dnsFQDN); len(svcIPs) != 0 {
return svcIPs, nil
}
// If not found yet, check for a matching 4via6 DNS name.
if addr, ok := kubeutils.ResolveViaDomain(dnsFQDN.WithTrailingDot()); ok {
prefix := netip.PrefixFrom(addr, addr.BitLen())
for nn := range nm.peers() {
for _, allowedIP := range nn.AllowedIPs().All() {
if allowedIP.Contains(addr) {
return []netip.Prefix{prefix}, nil
}
}
}
return nil, fmt.Errorf("resolved 4via6 address %v for %q but no peer advertises a route containing it", addr, fqdn)
}
return nil, fmt.Errorf("could not find Tailscale node, service or 4via6 address %q; it either does not exist, or not reachable because of ACLs", fqdn)
return nil, fmt.Errorf("could not find Tailscale node or service %q; it either does not exist, or not reachable because of ACLs", fqdn)
}
// serviceIPsFromNetMap returns all IPs of a Tailscale Service if its FQDN is
// found in the netmap. Note that Tailscale Services are not a first-class
// object in the netmap, so we guess based on DNS ExtraRecords and AllowedIPs.
func serviceIPsFromNetMap(nm netmapState, fqdn dnsname.FQDN) []netip.Prefix {
func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Prefix {
var extraRecords []tailcfg.DNSRecord
for _, rec := range nm.dnsExtraRecords.All() {
for _, rec := range nm.DNS.ExtraRecords {
recFQDN, err := dnsname.ToFQDN(rec.Name)
if err != nil {
continue
@@ -1201,7 +1013,7 @@ func serviceIPsFromNetMap(nm netmapState, fqdn dnsname.FQDN) []netip.Prefix {
continue
}
ipPrefix := netip.PrefixFrom(ip, ip.BitLen())
for ps := range nm.peers() {
for _, ps := range nm.Peers {
for _, allowedIP := range ps.AllowedIPs().All() {
if allowedIP == ipPrefix {
prefixes = append(prefixes, ipPrefix)
@@ -1212,3 +1024,11 @@ func serviceIPsFromNetMap(nm netmapState, fqdn dnsname.FQDN) []netip.Prefix {
return prefixes
}
func authkeyFromTailscaledConfig(path string) string {
if cfg, err := conffile.Load(path); err == nil && cfg.Parsed.AuthKey != nil {
return *cfg.Parsed.AuthKey
}
return ""
}
+37 -150
View File
@@ -7,7 +7,6 @@ package main
import (
"bytes"
"context"
_ "embed"
"encoding/base64"
"encoding/json"
@@ -33,30 +32,24 @@ import (
"github.com/google/go-cmp/cmp"
"golang.org/x/sys/unix"
"tailscale.com/client/local"
"tailscale.com/cmd/testwrapper/flakytest"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/net/memnet"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
)
const configFileAuthKey = "some-auth-key"
func TestContainerBoot(t *testing.T) {
flakytest.Mark(t, "https://github.com/tailscale/tailscale/issues/19380")
boot := filepath.Join(t.TempDir(), "containerboot")
if err := exec.Command("go", "build", "-ldflags", "-X main.testSleepDuration=1ms", "-o", boot, "tailscale.com/cmd/containerboot").Run(); err != nil {
t.Fatalf("Building containerboot: %v", err)
}
egressStatus := egressSvcStatus("foo", "foo.tailnetxyz.ts.net", "100.64.0.2")
egressStatusUpdated := egressSvcStatus("foo", "foo.tailnetxyz.ts.net", "100.64.0.3")
metricsURL := func(port int) string {
return fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
@@ -110,10 +103,12 @@ func TestContainerBoot(t *testing.T) {
}
runningNotify := &ipn.Notify{
State: new(ipn.Running),
SelfChange: &tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
},
}
type testCase struct {
@@ -386,16 +381,18 @@ func TestContainerBoot(t *testing.T) {
{
Notify: &ipn.Notify{
State: new(ipn.Running),
SelfChange: &tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
PeersChanged: []*tailcfg.Node{
{
StableID: tailcfg.StableNodeID("ipv6ID"),
Name: "ipv6-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
StableID: tailcfg.StableNodeID("ipv6ID"),
Name: "ipv6-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
}).View(),
},
},
},
@@ -632,10 +629,12 @@ func TestContainerBoot(t *testing.T) {
{
Notify: &ipn.Notify{
State: new(ipn.Running),
SelfChange: &tailcfg.Node{
StableID: tailcfg.StableNodeID("newID"),
Name: "new-name.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("newID"),
Name: "new-name.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
},
},
WantKubeSecret: map[string]string{
@@ -1094,16 +1093,18 @@ func TestContainerBoot(t *testing.T) {
{
Notify: &ipn.Notify{
State: new(ipn.Running),
SelfChange: &tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
PeersChanged: []*tailcfg.Node{
{
StableID: tailcfg.StableNodeID("fooID"),
Name: "foo.tailnetxyz.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
StableID: tailcfg.StableNodeID("fooID"),
Name: "foo.tailnetxyz.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
}).View(),
},
},
},
@@ -1119,23 +1120,6 @@ func TestContainerBoot(t *testing.T) {
egressSvcTerminateURL(env.localAddrPort): 200,
},
},
{
Notify: &ipn.Notify{
PeersChanged: []*tailcfg.Node{{
StableID: tailcfg.StableNodeID("fooID"),
Name: "foo.tailnetxyz.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.3/32")},
}},
},
WantKubeSecret: map[string]string{
"egress-services": string(mustJSON(t, egressStatusUpdated)),
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net.",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
kubetypes.KeyCapVer: capver,
},
},
},
}
},
@@ -1290,12 +1274,6 @@ func TestContainerBoot(t *testing.T) {
t.Fatalf("phase %d: updating mtime for %q: %v", i, path, err)
}
}
if p.Notify != nil && p.Notify.InitialStatus == nil {
// Shallow-copy before mutating to avoid a race with
// parallel subtests that share the same *ipn.Notify.
p.Notify = new(*p.Notify)
p.Notify.InitialStatus = statusFromNotify(p.Notify)
}
env.lapi.Notify(p.Notify)
if p.Signal != nil {
cmd.Process.Signal(*p.Signal)
@@ -1524,43 +1502,6 @@ func (lc *localAPI) Notify(n *ipn.Notify) {
lc.cond.Broadcast()
}
func statusFromNotify(n *ipn.Notify) *ipnstate.Status {
st := new(ipnstate.Status)
if n.State != nil {
st.BackendState = n.State.String()
}
if n.SelfChange != nil {
st.Self = peerStatusFromNode(n.SelfChange.View())
}
if len(n.PeersChanged) != 0 {
st.Peer = map[key.NodePublic]*ipnstate.PeerStatus{}
for _, p := range n.PeersChanged {
pv := p.View()
st.Peer[pv.Key()] = peerStatusFromNode(pv)
}
}
return st
}
func peerStatusFromNode(n tailcfg.NodeView) *ipnstate.PeerStatus {
ps := &ipnstate.PeerStatus{
ID: n.StableID(),
NodeID: n.ID(),
PublicKey: n.Key(),
DNSName: n.Name(),
}
for _, p := range n.Addresses().All() {
if p.IsSingleIP() {
ps.TailscaleIPs = append(ps.TailscaleIPs, p.Addr())
}
}
if n.AllowedIPs().Len() != 0 {
v := n.AllowedIPs()
ps.AllowedIPs = &v
}
return ps
}
func (lc *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/localapi/v0/serve-config":
@@ -1948,57 +1889,3 @@ func newTestEnv(t *testing.T) testEnv {
healthAddrPort: healthAddrPort,
}
}
// TestProcessNotifyRefreshesDNSOnSelfChange verifies that a SelfChange
// notification triggers a DNS refresh; without it, VIPServices created
// after pod boot are invisible to resolveTailnetFQDN.
func TestProcessNotifyRefreshesDNSOnSelfChange(t *testing.T) {
extraRec := tailcfg.DNSRecord{
Name: "my-ingress.tailnet.ts.net.",
Type: "A",
Value: "100.99.10.20",
}
dnsCfg := &tailcfg.DNSConfig{
ExtraRecords: []tailcfg.DNSRecord{extraRec},
CertDomains: []string{"node.tailnet.ts.net"},
}
lal := memnet.Listen("local-tailscaled.sock:80")
defer lal.Close()
mux := http.NewServeMux()
mux.HandleFunc("/localapi/v0/dns-config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(dnsCfg); err != nil {
t.Errorf("encoding dns config: %v", err)
}
})
srv := &http.Server{Handler: mux}
go srv.Serve(lal)
t.Cleanup(func() { srv.Shutdown(context.Background()) })
client := &local.Client{Dial: lal.Dial}
// Empty starting state, as if the InitialStatus captured at pod
// boot carried no ExtraRecords because the VIPService didn't exist
// yet at that time.
var s netmapState
n := ipn.Notify{
SelfChange: &tailcfg.Node{
ID: 1,
Name: "self.tailnet.ts.net.",
},
}
got := s.processNotify(context.Background(), client, n)
if got.dnsExtraRecords.Len() != 1 {
t.Fatalf("dnsExtraRecords.Len() = %d, want 1", got.dnsExtraRecords.Len())
}
if rec := got.dnsExtraRecords.At(0); rec.Name != extraRec.Name {
t.Errorf("dnsExtraRecords[0].Name = %q, want %q", rec.Name, extraRec.Name)
}
if got.certDomains.Len() != 1 || got.certDomains.At(0) != "node.tailnet.ts.net" {
t.Errorf("certDomains = %v, want [node.tailnet.ts.net]", got.certDomains.AsSlice())
}
}
+8
View File
@@ -24,6 +24,7 @@ import (
"tailscale.com/kube/kubetypes"
klc "tailscale.com/kube/localclient"
"tailscale.com/kube/services"
"tailscale.com/types/netmap"
)
// watchServeConfigChanges watches path for changes, and when it sees one, reads
@@ -141,6 +142,13 @@ func refreshAdvertiseServices(ctx context.Context, sc *ipn.ServeConfig, lc klc.L
return nil
}
func certDomainFromNetmap(nm *netmap.NetworkMap) string {
if len(nm.DNS.CertDomains) == 0 {
return ""
}
return nm.DNS.CertDomains[0]
}
func updateServeConfig(ctx context.Context, sc *ipn.ServeConfig, certDomain string, lc klc.LocalClient) error {
if !isValidHTTPSConfig(certDomain, sc) {
return nil
+64 -40
View File
@@ -6,7 +6,6 @@
package main
import (
"cmp"
"context"
"errors"
"fmt"
@@ -19,7 +18,6 @@ import (
"tailscale.com/ipn/conffile"
"tailscale.com/kube/kubeclient"
"tailscale.com/util/def"
)
// settings is all the configuration for containerboot.
@@ -91,50 +89,47 @@ type settings struct {
func configFromEnv() (*settings, error) {
cfg := &settings{
AuthKey: cmp.Or(os.Getenv("TS_AUTHKEY"), os.Getenv("TS_AUTH_KEY")),
ClientID: os.Getenv("TS_CLIENT_ID"),
ClientSecret: os.Getenv("TS_CLIENT_SECRET"),
IDToken: os.Getenv("TS_ID_TOKEN"),
Audience: os.Getenv("TS_AUDIENCE"),
Hostname: os.Getenv("TS_HOSTNAME"),
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
ClientID: defaultEnv("TS_CLIENT_ID", ""),
ClientSecret: defaultEnv("TS_CLIENT_SECRET", ""),
IDToken: defaultEnv("TS_ID_TOKEN", ""),
Audience: defaultEnv("TS_AUDIENCE", ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnvStringPointer("TS_ROUTES"),
ServeConfigPath: os.Getenv("TS_SERVE_CONFIG"),
ProxyTargetIP: os.Getenv("TS_DEST_IP"),
ProxyTargetDNSName: os.Getenv("TS_EXPERIMENTAL_DEST_DNS_NAME"),
TailnetTargetIP: os.Getenv("TS_TAILNET_TARGET_IP"),
TailnetTargetFQDN: os.Getenv("TS_TAILNET_TARGET_FQDN"),
DaemonExtraArgs: os.Getenv("TS_TAILSCALED_EXTRA_ARGS"),
ExtraArgs: os.Getenv("TS_EXTRA_ARGS"),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTargetIP: defaultEnv("TS_DEST_IP", ""),
ProxyTargetDNSName: defaultEnv("TS_EXPERIMENTAL_DEST_DNS_NAME", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
UserspaceMode: def.Bool(os.Getenv("TS_USERSPACE"), true),
StateDir: os.Getenv("TS_STATE_DIR"),
UserspaceMode: defaultBool("TS_USERSPACE", true),
StateDir: defaultEnv("TS_STATE_DIR", ""),
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
KubeSecret: func() string {
if os.Getenv("KUBERNETES_SERVICE_HOST") == "" {
return os.Getenv("TS_KUBE_SECRET")
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
return defaultEnv("TS_KUBE_SECRET", "tailscale")
}
// An explicitly empty TS_KUBE_SECRET disables Secret storage, so
// unset and empty must stay distinguishable: def.LookupEnv keeps
// an explicit "" rather than falling back to the default.
return def.LookupEnv("TS_KUBE_SECRET", "tailscale")
return defaultEnv("TS_KUBE_SECRET", "")
}(),
SOCKSProxyAddr: os.Getenv("TS_SOCKS5_SERVER"),
HTTPProxyAddr: os.Getenv("TS_OUTBOUND_HTTP_PROXY_LISTEN"),
Socket: cmp.Or(os.Getenv("TS_SOCKET"), "/tmp/tailscaled.sock"),
AuthOnce: def.Bool(os.Getenv("TS_AUTH_ONCE"), false),
Root: cmp.Or(os.Getenv("TS_TEST_ONLY_ROOT"), "/"),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
TailscaledConfigFilePath: tailscaledConfigFilePath(),
AllowProxyingClusterTrafficViaIngress: def.Bool(os.Getenv("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS"), false),
PodIP: os.Getenv("POD_IP"),
EnableForwardingOptimizations: def.Bool(os.Getenv("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS"), false),
HealthCheckAddrPort: os.Getenv("TS_HEALTHCHECK_ADDR_PORT"),
LocalAddrPort: cmp.Or(os.Getenv("TS_LOCAL_ADDR_PORT"), "[::]:9002"),
MetricsEnabled: def.Bool(os.Getenv("TS_ENABLE_METRICS"), false),
HealthCheckEnabled: def.Bool(os.Getenv("TS_ENABLE_HEALTH_CHECK"), false),
DebugAddrPort: os.Getenv("TS_DEBUG_ADDR_PORT"),
EgressProxiesCfgPath: os.Getenv("TS_EGRESS_PROXIES_CONFIG_PATH"),
IngressProxiesCfgPath: os.Getenv("TS_INGRESS_PROXIES_CONFIG_PATH"),
PodUID: os.Getenv("POD_UID"),
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
PodIP: defaultEnv("POD_IP", ""),
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""),
LocalAddrPort: defaultEnv("TS_LOCAL_ADDR_PORT", "[::]:9002"),
MetricsEnabled: defaultBool("TS_ENABLE_METRICS", false),
HealthCheckEnabled: defaultBool("TS_ENABLE_HEALTH_CHECK", false),
DebugAddrPort: defaultEnv("TS_DEBUG_ADDR_PORT", ""),
EgressProxiesCfgPath: defaultEnv("TS_EGRESS_PROXIES_CONFIG_PATH", ""),
IngressProxiesCfgPath: defaultEnv("TS_INGRESS_PROXIES_CONFIG_PATH", ""),
PodUID: defaultEnv("POD_UID", ""),
}
podIPs, ok := os.LookupEnv("POD_IPS")
@@ -158,7 +153,7 @@ func configFromEnv() (*settings, error) {
// If cert share is enabled, set the replica as read or write. Only 0th
// replica should be able to write.
isInCertShareMode := def.Bool(os.Getenv("TS_EXPERIMENTAL_CERT_SHARE"), false)
isInCertShareMode := defaultBool("TS_EXPERIMENTAL_CERT_SHARE", false)
if isInCertShareMode {
cfg.CertShareMode = "ro"
podName := os.Getenv("POD_NAME")
@@ -459,6 +454,15 @@ func (cfg *settings) egressSvcsTerminateEPEnabled() bool {
return cfg.LocalAddrPort != "" && cfg.EgressProxiesCfgPath != ""
}
// defaultEnv returns the value of the given envvar name, or defVal if
// unset.
func defaultEnv(name, defVal string) string {
if v, ok := os.LookupEnv(name); ok {
return v
}
return defVal
}
// defaultEnvStringPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a
// variable being set to empty string vs unset.
@@ -480,3 +484,23 @@ func defaultEnvBoolPointer(name string) *bool {
}
return &ret
}
func defaultEnvs(names []string, defVal string) string {
for _, name := range names {
if v, ok := os.LookupEnv(name); ok {
return v
}
}
return defVal
}
// defaultBool returns the boolean value of the given envvar name, or
// defVal if unset or not a bool.
func defaultBool(name string, defVal bool) bool {
v := os.Getenv(name)
ret, err := strconv.ParseBool(v)
if err != nil {
return defVal
}
return ret
}
-73
View File
@@ -7,7 +7,6 @@ package main
import (
"net/netip"
"os"
"strings"
"testing"
)
@@ -229,78 +228,6 @@ func TestValidateAuthMethods(t *testing.T) {
}
}
func TestConfigFromEnvEmptyDefaults(t *testing.T) {
tests := []struct {
env string
get func(*settings) string
want string
}{
{
env: "TS_SOCKET",
get: func(c *settings) string { return c.Socket },
want: "/tmp/tailscaled.sock",
},
{
env: "TS_LOCAL_ADDR_PORT",
get: func(c *settings) string { return c.LocalAddrPort },
want: "[::]:9002",
},
{
env: "TS_TEST_ONLY_ROOT",
get: func(c *settings) string { return c.Root },
want: "/",
},
}
for _, tt := range tests {
t.Run(tt.env, func(t *testing.T) {
t.Setenv(tt.env, "")
cfg, err := configFromEnv()
if err != nil {
t.Fatal(err)
}
if got := tt.get(cfg); got != tt.want {
t.Errorf(`%s set to empty "": got %q, want default %q`, tt.env, got, tt.want)
}
})
}
}
func TestConfigFromEnvKubeSecret(t *testing.T) {
tests := []struct {
name string
inKubernetes bool
unset bool
value string
want string
}{
{name: "in_kubernetes_unset", inKubernetes: true, unset: true, want: "tailscale"},
{name: "in_kubernetes_empty", inKubernetes: true, value: "", want: ""},
{name: "in_kubernetes_set", inKubernetes: true, value: "custom", want: "custom"},
{name: "not_in_kubernetes_unset", inKubernetes: false, unset: true, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// t.Setenv registers a t.Cleanup to restore the original value, so
// route the unset cases through it rather than a bare os.Unsetenv.
t.Setenv("KUBERNETES_SERVICE_HOST", "10.96.0.1")
if !tt.inKubernetes {
os.Unsetenv("KUBERNETES_SERVICE_HOST")
}
t.Setenv("TS_KUBE_SECRET", tt.value)
if tt.unset {
os.Unsetenv("TS_KUBE_SECRET")
}
cfg, err := configFromEnv()
if err != nil {
t.Fatal(err)
}
if cfg.KubeSecret != tt.want {
t.Errorf("KubeSecret = %q, want %q", cfg.KubeSecret, tt.want)
}
})
}
}
func TestHandlesKubeIPV6(t *testing.T) {
t.Setenv("TS_LOCAL_ADDR_PORT", "fd7a:115c:a1e0::6c34:352:9002")
t.Setenv("POD_IPS", "fd7a:115c:a1e0::6c34:352")
+2 -14
View File
@@ -150,15 +150,7 @@ func tailscaleUp(ctx context.Context, cfg *settings) error {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
// A canceled context kills the command, and cmd.Run can
// report the subprocess's death ("signal: killed") rather
// than the context error that caused it. Return the
// context error so that callers (and ultimately main) can
// recognize a graceful shutdown with errors.Is.
return fmt.Errorf("tailscale up failed: %w", ctxErr)
}
return fmt.Errorf("tailscale up failed: %w", err)
return fmt.Errorf("tailscale up failed: %v", err)
}
return nil
}
@@ -188,11 +180,7 @@ func tailscaleSet(ctx context.Context, cfg *settings) error {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
// See the equivalent check in tailscaleUp.
return fmt.Errorf("tailscale set failed: %w", ctxErr)
}
return fmt.Errorf("tailscale set failed: %w", err)
return fmt.Errorf("tailscale set failed: %v", err)
}
return nil
}
+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)
}
+11 -27
View File
@@ -23,7 +23,6 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"time"
"golang.org/x/crypto/acme"
@@ -36,34 +35,21 @@ var unsafeHostnameCharacters = regexp.MustCompile(`[^a-zA-Z0-9-\.]`)
type certProvider interface {
// TLSConfig creates a new TLS config suitable for net/http.Server servers.
//
// The returned Config must have a GetCertificate function set. The
// *tls.Certificate values it returns may be shared and cached, so
// callers must not mutate them.
// The returned Config must have a GetCertificate function set and that
// function must return a unique *tls.Certificate for each call. The
// returned *tls.Certificate will be mutated by the caller to append to the
// (*tls.Certificate).Certificate field.
TLSConfig() *tls.Config
// HTTPHandler handle ACME related request, if any.
HTTPHandler(fallback http.Handler) http.Handler
}
func certProviderByCertMode(mode, dir, hostname string, ipCerts bool, eabKID, eabKey, email string) (certProvider, error) {
func certProviderByCertMode(mode, dir, hostname, eabKID, eabKey, email string) (certProvider, error) {
if dir == "" {
return nil, errors.New("missing required --certdir flag")
}
if ipCerts && mode != "letsencrypt" {
return nil, errors.New("--acme-ip-certs requires --certmode=letsencrypt")
}
switch mode {
case "letsencrypt", "gcp":
if net.ParseIP(hostname) != nil {
if mode == "gcp" {
return nil, errors.New("--certmode=gcp requires --hostname to be a DNS name, not an IP address")
}
if !ipCerts {
return nil, errors.New("--hostname is an IP address; use --certmode=manual for a self-signed cert, or set --acme-ip-certs to get LetsEncrypt IP address certs")
}
// IP-only server: certs are issued on demand per
// connection, so there is no hostname cert provider.
return newIPCertManager(dir, email, "", nil)
}
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(hostname),
@@ -96,9 +82,6 @@ func certProviderByCertMode(mode, dir, hostname string, ipCerts bool, eabKID, ea
} else if hostname == "derp.tailscale.com" {
certManager.Email = "security@tailscale.com"
}
if ipCerts {
return newIPCertManager(dir, email, "", certManager)
}
return certManager, nil
case "manual":
return NewManualCertManager(dir, hostname)
@@ -174,11 +157,12 @@ func (m *manualCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certif
return nil, fmt.Errorf("cert mismatch with hostname: %q", hi.ServerName)
}
// Return a shallow copy of the cert with a capacity-clamped chain
// so callers can never mutate the manager's long-lived certificate.
certCopy := *m.cert
certCopy.Certificate = slices.Clip(certCopy.Certificate)
return &certCopy, nil
// Return a shallow copy of the cert so the caller can append to its
// Certificate field.
certCopy := new(tls.Certificate)
*certCopy = *m.cert
certCopy.Certificate = certCopy.Certificate[:len(certCopy.Certificate):len(certCopy.Certificate)]
return certCopy, nil
}
func (m *manualCertManager) HTTPHandler(fallback http.Handler) http.Handler {
+6 -6
View File
@@ -91,7 +91,7 @@ func TestCertIP(t *testing.T) {
t.Fatalf("Error closing key.pem: %v", err)
}
cp, err := certProviderByCertMode("manual", dir, hostname, false, "", "", "")
cp, err := certProviderByCertMode("manual", dir, hostname, "", "", "")
if err != nil {
t.Fatal(err)
}
@@ -174,25 +174,25 @@ func TestGCPCertMode(t *testing.T) {
dir := t.TempDir()
// Missing EAB credentials
_, err := certProviderByCertMode("gcp", dir, "test.example.com", false, "", "", "test@example.com")
_, err := certProviderByCertMode("gcp", dir, "test.example.com", "", "", "test@example.com")
if err == nil {
t.Fatal("expected error when EAB credentials are missing")
}
// Missing email
_, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk", "")
_, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk", "")
if err == nil {
t.Fatal("expected error when email is missing")
}
// Invalid base64
_, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "not-valid!", "test@example.com")
_, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "not-valid!", "test@example.com")
if err == nil {
t.Fatal("expected error for invalid base64")
}
// Valid base64url (no padding)
cp, err := certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk", "test@example.com")
cp, err := certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk", "test@example.com")
if err != nil {
t.Fatalf("base64url: %v", err)
}
@@ -201,7 +201,7 @@ func TestGCPCertMode(t *testing.T) {
}
// Valid standard base64 (with padding, gcloud format)
cp, err = certProviderByCertMode("gcp", dir, "test.example.com", false, "kid", "dGVzdC1rZXk=", "test@example.com")
cp, err = certProviderByCertMode("gcp", dir, "test.example.com", "kid", "dGVzdC1rZXk=", "test@example.com")
if err != nil {
t.Fatalf("base64: %v", err)
}
+17 -23
View File
@@ -6,9 +6,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
github.com/axiomhq/hyperloglog from tailscale.com/derp/derpserver
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
github.com/coder/websocket from tailscale.com/derp/derpserver+
github.com/coder/websocket from tailscale.com/cmd/derper+
github.com/coder/websocket/internal/errd from github.com/coder/websocket
github.com/coder/websocket/internal/util from github.com/coder/websocket
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
github.com/creachadair/msync/throttle from github.com/tailscale/setec/client/setec
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
github.com/dgryski/go-metro from github.com/axiomhq/hyperloglog
@@ -19,8 +20,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck+
💣 github.com/go4org/hashtriemap from tailscale.com/derp/derpserver
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
@@ -91,7 +90,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/feature from tailscale.com/tsweb+
tailscale.com/feature/buildfeatures from tailscale.com/feature+
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
tailscale.com/health from tailscale.com/net/tlsdial+
tailscale.com/hostinfo from tailscale.com/net/netmon+
tailscale.com/ipn from tailscale.com/client/local
@@ -108,23 +106,19 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/net/netns from tailscale.com/derp/derphttp
tailscale.com/net/netutil from tailscale.com/client/local
tailscale.com/net/netx from tailscale.com/net/dnscache+
tailscale.com/net/routecheck from tailscale.com/client/local
tailscale.com/net/routecheck/peernode from tailscale.com/net/routecheck
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
tailscale.com/net/stun from tailscale.com/net/stunserver
tailscale.com/net/stunserver from tailscale.com/cmd/derper
L tailscale.com/net/tcpinfo from tailscale.com/derp/derpserver
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
tailscale.com/net/traffic from tailscale.com/net/routecheck
tailscale.com/net/tsaddr from tailscale.com/ipn+
tailscale.com/net/udprelay/status from tailscale.com/client/local
tailscale.com/net/wsconn from tailscale.com/derp/derpserver
tailscale.com/net/wsconn from tailscale.com/cmd/derper
tailscale.com/paths from tailscale.com/client/local
💣 tailscale.com/safesocket from tailscale.com/client/local
tailscale.com/syncs from tailscale.com/cmd/derper+
tailscale.com/tailcfg from tailscale.com/client/local+
tailscale.com/tempfork/acme from tailscale.com/cmd/derper
tailscale.com/tka from tailscale.com/client/local+
tailscale.com/tsconst from tailscale.com/net/netmon+
tailscale.com/tstime from tailscale.com/derp+
@@ -140,7 +134,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/types/key from tailscale.com/client/local+
tailscale.com/types/lazy from tailscale.com/version+
tailscale.com/types/logger from tailscale.com/cmd/derper+
tailscale.com/types/netmap from tailscale.com/ipn+
tailscale.com/types/netmap from tailscale.com/ipn
tailscale.com/types/opt from tailscale.com/envknob+
tailscale.com/types/persist from tailscale.com/ipn+
tailscale.com/types/preftype from tailscale.com/ipn
@@ -169,7 +163,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/syspolicy/pkey from tailscale.com/ipn+
tailscale.com/util/syspolicy/policyclient from tailscale.com/ipn
tailscale.com/util/syspolicy/ptype from tailscale.com/util/syspolicy/policyclient+
tailscale.com/util/syspolicy/setting from tailscale.com/client/local+
tailscale.com/util/syspolicy/setting from tailscale.com/client/local
tailscale.com/util/testenv from tailscale.com/net/bakedroots+
tailscale.com/util/usermetric from tailscale.com/health
tailscale.com/util/vizerror from tailscale.com/tailcfg+
@@ -249,22 +243,22 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
crypto/internal/boring/bbig from crypto/ecdsa+
crypto/internal/boring/sig from crypto/internal/boring
crypto/internal/constanttime from crypto/internal/fips140/edwards25519+
crypto/internal/fips140 from crypto/fips140+
crypto/internal/fips140 from crypto/internal/fips140/aes+
crypto/internal/fips140/aes from crypto/aes+
crypto/internal/fips140/aes/gcm from crypto/cipher+
crypto/internal/fips140/alias from crypto/cipher+
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
crypto/internal/fips140/check from crypto/fips140+
crypto/internal/fips140/drbg from crypto/hpke+
crypto/internal/fips140/check from crypto/internal/fips140/aes+
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
crypto/internal/fips140/ecdh from crypto/ecdh
crypto/internal/fips140/ecdsa from crypto/ecdsa
crypto/internal/fips140/ed25519 from crypto/ed25519
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
crypto/internal/fips140/hkdf from crypto/hkdf+
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
crypto/internal/fips140/hmac from crypto/hmac+
crypto/internal/fips140/mlkem from crypto/mlkem
crypto/internal/fips140/nistec from crypto/ecdsa+
crypto/internal/fips140/nistec from crypto/elliptic+
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
crypto/internal/fips140/rsa from crypto/rsa
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
@@ -315,8 +309,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
go/token from google.golang.org/protobuf/internal/strs
hash from crypto+
hash/crc32 from compress/gzip+
hash/fnv from google.golang.org/protobuf/internal/detrand+
hash/maphash from go4.org/mem+
hash/fnv from google.golang.org/protobuf/internal/detrand
hash/maphash from go4.org/mem
html from net/http/pprof+
html/template from tailscale.com/cmd/derper+
internal/abi from crypto/x509/internal/macos+
@@ -330,13 +324,13 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
internal/filepathlite from os+
internal/fmtsort from fmt+
internal/goarch from crypto/internal/fips140deps/cpu+
internal/godebug from crypto/ed25519+
internal/godebug from crypto/internal/fips140deps/godebug+
internal/godebugs from internal/godebug+
internal/goexperiment from net/http/pprof+
internal/goos from crypto/x509+
internal/msan from internal/runtime/maps+
internal/nettrace from net+
internal/oserror from internal/syscall/windows+
internal/oserror from io/fs+
internal/poll from net+
internal/profile from net/http/pprof
internal/profilerecord from runtime+
@@ -346,9 +340,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
internal/runtime/atomic from internal/runtime/exithook+
L internal/runtime/cgroup from runtime
internal/runtime/exithook from runtime
internal/runtime/gc from internal/runtime/gc/scan+
internal/runtime/gc from runtime+
internal/runtime/gc/scan from runtime
internal/runtime/maps from hash/maphash+
internal/runtime/maps from reflect+
internal/runtime/math from internal/runtime/maps+
internal/runtime/pprof/label from runtime+
internal/runtime/sys from crypto/subtle+
@@ -362,7 +356,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
internal/synctest from sync
internal/syscall/execenv from os+
LD internal/syscall/unix from crypto/internal/sysrand+
W internal/syscall/windows from crypto/internal/fips140deps/time+
W internal/syscall/windows from crypto/internal/sysrand+
W internal/syscall/windows/registry from mime+
W internal/syscall/windows/sysdll from internal/syscall/windows+
internal/testlog from os
+20 -32
View File
@@ -62,11 +62,10 @@ var (
configPath = flag.String("c", "", "config file path")
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt, gcp")
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store ACME (e.g. LetsEncrypt) certs, if addr's port is :443")
hostname = flag.String("hostname", "derp.tailscale.com", "TLS host name for certs, if addr's port is :443. It can be an IP address when --certmode=manual (to avoid SNI checks) or when --acme-ip-certs is set (to run an IP-only server with no hostname cert)")
hostname = flag.String("hostname", "derp.tailscale.com", "TLS host name for certs, if addr's port is :443. When --certmode=manual, this can be an IP address to avoid SNI checks")
acmeEABKid = flag.String("acme-eab-kid", "", "ACME External Account Binding (EAB) Key ID (required for --certmode=gcp)")
acmeEABKey = flag.String("acme-eab-key", "", "ACME External Account Binding (EAB) HMAC key, base64-encoded (required for --certmode=gcp)")
acmeEmail = flag.String("acme-email", "", "ACME account contact email address (required for --certmode=gcp, optional for letsencrypt)")
acmeIPCerts = flag.Bool("acme-ip-certs", false, "whether to serve LetsEncrypt certs for the server's IP addresses: when a client connects by IP address (sending no TLS SNI, or an IP address SNI matching the connection's destination IP), get and serve a LetsEncrypt cert for that IP, using the short-lived (~6 day) ACME certificate profile. This works for both IPv4 and IPv6 with no per-address configuration. It requires --certmode=letsencrypt and the ACME server must be able to reach port 80 at each such IP for the HTTP-01 challenge.")
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.")
flagHome = flag.String("home", "", "what to serve at the root path. It may be left empty (the default, for a default homepage), \"blank\" for a blank page, or a URL to redirect to")
@@ -88,7 +87,8 @@ var (
acceptConnLimit = flag.Float64("accept-connection-limit", math.Inf(+1), "rate limit for accepting new connection")
acceptConnBurst = flag.Int("accept-connection-burst", math.MaxInt, "burst limit for accepting new connection")
rateConfigPath = flag.String("rate-config", "", "if non-empty, path to JSON rate limit config file. Rate limiting is experimental and subject to change. Configuration is reloaded on SIGHUP.")
perClientRateLimit = flag.Uint("per-client-rate-limit", 0, "per-client receive rate limit in bytes/sec; 0 means unlimited. Mesh peers are exempt.")
perClientRateBurst = flag.Uint("per-client-rate-burst", 0, "per-client receive rate burst in bytes; 0 defaults to 2x the rate limit (only relevant when using nonzero --per-client-rate-limit)")
// tcpKeepAlive is intentionally long, to reduce battery cost. There is an L7 keepalive on a higher frequency schedule.
tcpKeepAlive = flag.Duration("tcp-keepalive-time", 10*time.Minute, "TCP keepalive time")
@@ -195,11 +195,12 @@ func main() {
s.SetVerifyClientURL(*verifyClientURL)
s.SetVerifyClientURLFailOpen(*verifyFailOpen)
s.SetTCPWriteTimeout(*tcpWriteTimeout)
if *rateConfigPath != "" {
if err := s.LoadAndApplyRateConfig(*rateConfigPath); err != nil {
log.Fatalf("derper: loading rate config: %v", err)
if *perClientRateLimit > 0 {
burst := *perClientRateBurst
if burst < 1 {
burst = *perClientRateLimit * 2
}
go watchRateConfig(ctx, s, *rateConfigPath)
s.SetPerClientRateLimit(*perClientRateLimit, burst)
}
var meshKey string
@@ -253,7 +254,7 @@ func main() {
if err := startMesh(s); err != nil {
log.Fatalf("startMesh: %v", err)
}
expvar.Publish("derp", s.ExpVar(*rateConfigPath != ""))
expvar.Publish("derp", s.ExpVar())
handleHome, ok := getHomeHandler(*flagHome)
if !ok {
@@ -263,7 +264,7 @@ func main() {
mux := http.NewServeMux()
if *runDERP {
derpHandler := derpserver.Handler(s)
derpHandler = derpserver.AddWebSocketSupport(s, derpHandler)
derpHandler = addWebSocketSupport(s, derpHandler)
mux.Handle("/derp", derpHandler)
} else {
mux.Handle("/derp", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -350,12 +351,20 @@ func main() {
if serveTLS {
log.Printf("derper: serving on %s with TLS", *addr)
var certManager certProvider
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeIPCerts, *acmeEABKid, *acmeEABKey, *acmeEmail)
certManager, err = certProviderByCertMode(*certMode, *certDir, *hostname, *acmeEABKid, *acmeEABKey, *acmeEmail)
if err != nil {
log.Fatalf("derper: can not start cert provider: %v", err)
}
httpsrv.TLSConfig = certManager.TLSConfig()
s.ModifyTLSConfigToAddMetaCert(httpsrv.TLSConfig)
getCert := httpsrv.TLSConfig.GetCertificate
httpsrv.TLSConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := getCert(hi)
if err != nil {
return nil, err
}
cert.Certificate = append(cert.Certificate, s.MetaCert())
return cert, nil
}
// Disable TLS 1.0 and 1.1, which are obsolete and have security issues.
httpsrv.TLSConfig.MinVersion = tls.VersionTLS12
httpsrv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -427,27 +436,6 @@ func main() {
}
}
// watchRateConfig listens for SIGHUP signals and reloads the rate config
// file on each signal, applying it to the server. It returns when ctx is done.
func watchRateConfig(ctx context.Context, s *derpserver.Server, path string) {
sighup := make(chan os.Signal, 1)
signal.Notify(sighup, syscall.SIGHUP)
defer signal.Stop(sighup)
for {
select {
case <-ctx.Done():
return
case <-sighup:
log.Printf("derper: received SIGHUP, reloading rate config from %s", path)
if err := s.LoadAndApplyRateConfig(path); err != nil {
log.Printf("derper: rate config reload failed: %v", err)
continue
}
log.Printf("derper: rate config reloaded successfully")
}
}
}
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
func prodAutocertHostPolicy(_ context.Context, host string) error {
-497
View File
@@ -1,497 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"tailscale.com/atomicfile"
"tailscale.com/tailcfg"
"tailscale.com/tempfork/acme"
)
// shortlivedProfile is the ACME certificate profile required by
// LetsEncrypt for IP address certificates. Certificates issued under
// it are valid for about six days.
// See https://letsencrypt.org/docs/profiles/.
const shortlivedProfile = "shortlived"
// ipCertManager is a certProvider that obtains and renews LetsEncrypt
// TLS certificates for the server's IP addresses on demand, using the
// short-lived ACME certificate profile and the HTTP-01 challenge
// served on the derper's plaintext HTTP port.
//
// Clients connecting to an IP address usually send no SNI, so the
// requested IP address is taken from the TCP connection's local
// address. That works for however many IPv4 and IPv6 addresses the
// server has, with no configuration. Clients that do send an IP
// address in the SNI get a certificate only if it matches the
// connection's local address, so a client can never make us request a
// certificate for an address that isn't ours.
//
// Connections with a DNS name in the SNI are passed through to the
// optional next provider (the regular autocert manager for the
// --hostname certificate), if any.
type ipCertManager struct {
certDir string
email string // optional ACME account contact
client *acme.Client
next certProvider // provider for DNS hostname connections, or nil
nextTLS *tls.Config // next.TLSConfig(), or nil
mu sync.Mutex
certs map[netip.Addr]*ipCertEntry
tokens map[string]string // HTTP-01 challenge URL path => response body
}
// ipCertEntry is the issuance state for one IP address.
// All fields are guarded by ipCertManager.mu.
type ipCertEntry struct {
cert *tls.Certificate // current cert with Leaf set, or nil if not yet issued
flight chan struct{} // non-nil while an issuance is running; closed when it finishes
flightErr error // result of the last finished issuance
nextAttempt time.Time // earliest time of the next issuance attempt, after a failure
retryDelay time.Duration // backoff to apply after the next failure
}
// newIPCertManager returns an ipCertManager storing its ACME account
// key and issued certificates in certdir.
//
// If directoryURL is empty, the LetsEncrypt production directory is
// used; tests point it at a fake ACME server. If next is non-nil,
// connections with a DNS name in the SNI are served by it.
func newIPCertManager(certdir, email, directoryURL string, next certProvider) (*ipCertManager, error) {
if err := os.MkdirAll(certdir, 0700); err != nil {
return nil, err
}
accountKey, err := loadOrCreateAccountKey(filepath.Join(certdir, "acme-account.key"))
if err != nil {
return nil, fmt.Errorf("ACME account key: %w", err)
}
m := &ipCertManager{
certDir: certdir,
email: email,
client: &acme.Client{
Key: accountKey,
DirectoryURL: directoryURL,
UserAgent: "tailscale-derper",
},
next: next,
certs: make(map[netip.Addr]*ipCertEntry),
tokens: make(map[string]string),
}
if next != nil {
m.nextTLS = next.TLSConfig()
}
go m.renewLoop()
return m, nil
}
func loadOrCreateAccountKey(path string) (*ecdsa.PrivateKey, error) {
if pemBytes, err := os.ReadFile(path); err == nil {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, fmt.Errorf("invalid PEM in %s", path)
}
return x509.ParseECPrivateKey(block.Bytes)
} else if !os.IsNotExist(err) {
return nil, err
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil, err
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
if err := atomicfile.WriteFile(path, pemBytes, 0600); err != nil {
return nil, err
}
return key, nil
}
// certPaths returns the cert and key file paths for ip in m.certDir.
// Colons in IPv6 addresses are replaced with dots to keep the names
// filesystem-safe.
func (m *ipCertManager) certPaths(ip netip.Addr) (crtPath, keyPath string) {
base := strings.ReplaceAll(ip.String(), ":", ".")
return filepath.Join(m.certDir, base+".crt"), filepath.Join(m.certDir, base+".key")
}
func (m *ipCertManager) TLSConfig() *tls.Config {
var conf *tls.Config
if m.nextTLS != nil {
conf = m.nextTLS.Clone()
} else {
conf = &tls.Config{
NextProtos: []string{
"http/1.1",
},
}
}
conf.GetCertificate = m.getCertificate
return conf
}
// connLocalIP returns the local (server side) IP address of the
// connection that sent the ClientHello.
func connLocalIP(hi *tls.ClientHelloInfo) (netip.Addr, bool) {
if hi.Conn == nil {
return netip.Addr{}, false
}
ta, ok := hi.Conn.LocalAddr().(*net.TCPAddr)
if !ok {
return netip.Addr{}, false
}
ip := ta.AddrPort().Addr().Unmap()
return ip, ip.IsValid()
}
func (m *ipCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
connIP, connIPOK := connLocalIP(hi)
if hi.ServerName != "" {
sniIP, err := netip.ParseAddr(hi.ServerName)
if err != nil {
// The SNI is a DNS name; let the hostname provider handle it.
if m.nextTLS != nil && m.nextTLS.GetCertificate != nil {
return m.nextTLS.GetCertificate(hi)
}
return nil, fmt.Errorf("no certificate for hostname %q; this server only serves IP address certificates", hi.ServerName)
}
if !connIPOK || sniIP.Unmap() != connIP {
return nil, fmt.Errorf("requested certificate for IP %v does not match the connection's IP address", sniIP)
}
}
if !connIPOK {
return nil, errors.New("unable to determine the connection's local IP address")
}
ctx := hi.Context()
if ctx == nil {
ctx = context.Background()
}
return m.certForIP(ctx, connIP)
}
// certForIP returns the current certificate for ip, obtaining one
// first if there is no unexpired certificate for it. Concurrent
// callers for the same IP share a single issuance.
func (m *ipCertManager) certForIP(ctx context.Context, ip netip.Addr) (*tls.Certificate, error) {
m.mu.Lock()
e := m.entryLocked(ip)
if e.cert != nil && time.Now().Before(e.cert.Leaf.NotAfter) {
defer m.mu.Unlock()
return clipCert(e.cert), nil
}
if e.flight == nil && time.Now().Before(e.nextAttempt) {
m.mu.Unlock()
return nil, fmt.Errorf("cert issuance for %v failed recently; next attempt no earlier than %v", ip, e.nextAttempt.Format(time.RFC3339))
}
flight := m.startFlightLocked(ip, e)
m.mu.Unlock()
select {
case <-flight:
case <-ctx.Done():
return nil, ctx.Err()
}
m.mu.Lock()
defer m.mu.Unlock()
if e.cert == nil {
return nil, e.flightErr
}
return clipCert(e.cert), nil
}
func (m *ipCertManager) entryLocked(ip netip.Addr) *ipCertEntry {
e, ok := m.certs[ip]
if !ok {
e = &ipCertEntry{}
m.certs[ip] = e
}
return e
}
// clipCert returns a shallow copy of cert with a capacity-clamped
// chain so callers can never mutate the manager's long-lived
// certificate.
func clipCert(cert *tls.Certificate) *tls.Certificate {
certCopy := *cert
certCopy.Certificate = slices.Clip(certCopy.Certificate)
return &certCopy
}
// startFlightLocked starts an issuance for ip if none is running and
// returns a channel that is closed when it finishes.
func (m *ipCertManager) startFlightLocked(ip netip.Addr, e *ipCertEntry) chan struct{} {
if e.flight != nil {
return e.flight
}
done := make(chan struct{})
e.flight = done
go func() {
err := m.issue(ip)
m.mu.Lock()
defer m.mu.Unlock()
e.flight = nil
e.flightErr = err
if err != nil {
if e.retryDelay == 0 {
e.retryDelay = time.Minute
}
e.nextAttempt = time.Now().Add(e.retryDelay)
e.retryDelay = min(e.retryDelay*2, 30*time.Minute)
log.Printf("derper: acme: getting cert for %v: %v (next attempt in %v)", ip, err, time.Until(e.nextAttempt).Round(time.Second))
} else {
e.retryDelay = 0
e.nextAttempt = time.Time{}
}
close(done)
}()
return done
}
// issue obtains a certificate for ip, preferring a still-fresh one
// cached on disk over a new ACME order.
func (m *ipCertManager) issue(ip netip.Addr) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if cert, err := m.loadCachedCert(ip); err == nil && !certNeedsRenewal(cert.Leaf) {
m.mu.Lock()
m.entryLocked(ip).cert = cert
m.mu.Unlock()
log.Printf("derper: acme: loaded cached cert for %v (expires %v)", ip, cert.Leaf.NotAfter)
return nil
}
return m.obtainCert(ctx, ip)
}
// loadCachedCert loads a previously issued certificate for ip from
// disk, if present and still valid.
func (m *ipCertManager) loadCachedCert(ip netip.Addr) (*tls.Certificate, error) {
crtPath, keyPath := m.certPaths(ip)
cert, err := tls.LoadX509KeyPair(crtPath, keyPath)
if err != nil {
return nil, err
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return nil, err
}
now := time.Now()
if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
return nil, fmt.Errorf("cached cert is expired or not yet valid (NotAfter %v)", leaf.NotAfter)
}
if err := leaf.VerifyHostname(ip.String()); err != nil {
return nil, err
}
cert.Leaf = leaf
return &cert, nil
}
// HTTPHandler returns a handler serving HTTP-01 challenge responses on
// the derper's plaintext HTTP port, sending all other requests to the
// next provider's handler, if any, and otherwise to fallback.
func (m *ipCertManager) HTTPHandler(fallback http.Handler) http.Handler {
if m.next != nil {
fallback = m.next.HTTPHandler(fallback)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") {
fallback.ServeHTTP(w, r)
return
}
m.mu.Lock()
response, ok := m.tokens[r.URL.Path]
m.mu.Unlock()
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain")
io.WriteString(w, response)
})
}
func (m *ipCertManager) setToken(path, response string) {
m.mu.Lock()
defer m.mu.Unlock()
m.tokens[path] = response
}
func (m *ipCertManager) deleteToken(path string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.tokens, path)
}
// certNeedsRenewal reports whether leaf has less than a third of its
// lifetime remaining. LetsEncrypt short-lived certs are valid for
// about six days, so renewal happens roughly every four.
func certNeedsRenewal(leaf *x509.Certificate) bool {
total := leaf.NotAfter.Sub(leaf.NotBefore)
return time.Until(leaf.NotAfter) < total/3
}
// renewLoop runs for the lifetime of the process, renewing each issued
// certificate as it approaches expiry.
func (m *ipCertManager) renewLoop() {
for {
time.Sleep(time.Hour)
m.mu.Lock()
now := time.Now()
for ip, e := range m.certs {
if e.cert != nil && certNeedsRenewal(e.cert.Leaf) && e.flight == nil && now.After(e.nextAttempt) {
m.startFlightLocked(ip, e)
}
}
m.mu.Unlock()
}
}
// obtainCert does one ACME issuance flow for ip: it registers the
// account if needed, orders a short-lived profile certificate for the
// IP address identifier, fulfills the HTTP-01 challenges, and installs
// and caches the issued certificate.
func (m *ipCertManager) obtainCert(ctx context.Context, ip netip.Addr) error {
ipStr := ip.String()
var contact []string
if m.email != "" {
contact = []string{"mailto:" + m.email}
}
_, err := m.client.Register(ctx, &acme.Account{Contact: contact}, acme.AcceptTOS)
if err != nil && !errors.Is(err, acme.ErrAccountAlreadyExists) {
return fmt.Errorf("register: %w", err)
}
order, err := m.client.AuthorizeOrder(ctx, acme.IPIDs(ipStr), acme.WithOrderProfile(shortlivedProfile))
if err != nil {
return fmt.Errorf("new order: %w", err)
}
for _, authzURL := range order.AuthzURLs {
if err := m.fulfillAuthz(ctx, authzURL); err != nil {
return err
}
}
order, err = m.client.WaitOrder(ctx, order.URI)
if err != nil {
return fmt.Errorf("waiting for order: %w", err)
}
certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
IPAddresses: []net.IP{ip.AsSlice()},
}, certKey)
if err != nil {
return err
}
der, _, err := m.client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
if err != nil {
return fmt.Errorf("finalizing order: %w", err)
}
leaf, err := x509.ParseCertificate(der[0])
if err != nil {
return fmt.Errorf("parsing issued cert: %w", err)
}
if err := leaf.VerifyHostname(ipStr); err != nil {
return fmt.Errorf("issued cert: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(certKey)
if err != nil {
return err
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
var chainPEM []byte
for _, b := range der {
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: b})...)
}
crtPath, keyPath := m.certPaths(ip)
if err := atomicfile.WriteFile(keyPath, keyPEM, 0600); err != nil {
return err
}
if err := atomicfile.WriteFile(crtPath, chainPEM, 0644); err != nil {
return err
}
m.mu.Lock()
m.entryLocked(ip).cert = &tls.Certificate{
Certificate: der,
PrivateKey: certKey,
Leaf: leaf,
}
m.mu.Unlock()
dn := &tailcfg.DERPNode{
Name: "custom",
RegionID: 900,
HostName: ipStr,
}
dnJSON, _ := json.Marshal(dn)
log.Printf("derper: acme: got cert for %v (expires %v). Configure it in DERPMap using (https://tailscale.com/s/custom-derp):\n %s", ip, leaf.NotAfter, dnJSON)
return nil
}
// fulfillAuthz completes the HTTP-01 challenge for one authorization,
// if it is still pending.
func (m *ipCertManager) fulfillAuthz(ctx context.Context, authzURL string) error {
authz, err := m.client.GetAuthorization(ctx, authzURL)
if err != nil {
return fmt.Errorf("getting authorization: %w", err)
}
if authz.Status != acme.StatusPending {
return nil
}
var challenge *acme.Challenge
for _, c := range authz.Challenges {
if c.Type == "http-01" {
challenge = c
break
}
}
if challenge == nil {
return errors.New("authorization offers no http-01 challenge")
}
response, err := m.client.HTTP01ChallengeResponse(challenge.Token)
if err != nil {
return err
}
path := m.client.HTTP01ChallengePath(challenge.Token)
m.setToken(path, response)
defer m.deleteToken(path)
if _, err := m.client.Accept(ctx, challenge); err != nil {
return fmt.Errorf("accepting challenge: %w", err)
}
if _, err := m.client.WaitAuthorization(ctx, authz.URI); err != nil {
return fmt.Errorf("waiting for authorization: %w", err)
}
return nil
}
-486
View File
@@ -1,486 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// fakeIPACME is a minimal fake ACME (RFC 8555) certificate authority
// for testing ipCertManager. It implements just enough of the protocol
// for http-01 order flows with IP address identifiers and the
// "shortlived" profile: one order at a time, no JWS signature
// verification, no nonce tracking.
type fakeIPACME struct {
t *testing.T
srv *httptest.Server
challengeBase string // base URL at which http-01 challenges are fetched
caKey *ecdsa.PrivateKey
caCert *x509.Certificate
mu sync.Mutex
orders int // number of orders created
gotProfile string // profile of the last order
gotIDType string // identifier type of the last order
gotIDValue string // identifier value of the last order
authzStatus string // "pending" or "valid"
orderStatus string // "pending", "ready", or "valid"
token string
certPEM []byte
}
func newFakeIPACME(t *testing.T) *fakeIPACME {
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
caTmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "fake IP ACME root"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
caCert, err := x509.ParseCertificate(caDER)
if err != nil {
t.Fatal(err)
}
f := &fakeIPACME{
t: t,
caKey: caKey,
caCert: caCert,
}
f.srv = httptest.NewServer(http.HandlerFunc(f.serveHTTP))
t.Cleanup(f.srv.Close)
return f
}
func (f *fakeIPACME) directoryURL() string { return f.srv.URL + "/directory" }
func (f *fakeIPACME) numOrders() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.orders
}
func (f *fakeIPACME) serveHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Replay-Nonce", "test-nonce")
writeJSON := func(v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
orderJSON := func() any {
return map[string]any{
"status": f.orderStatus,
"identifiers": []map[string]string{{"type": f.gotIDType, "value": f.gotIDValue}},
"authorizations": []string{f.srv.URL + "/authz/1"},
"finalize": f.srv.URL + "/finalize/1",
"certificate": f.srv.URL + "/cert/1",
}
}
switch {
case r.URL.Path == "/directory":
writeJSON(map[string]any{
"newNonce": f.srv.URL + "/new-nonce",
"newAccount": f.srv.URL + "/new-account",
"newOrder": f.srv.URL + "/new-order",
"revokeCert": f.srv.URL + "/revoke-cert",
"keyChange": f.srv.URL + "/key-change",
"meta": map[string]any{
"profiles": map[string]string{
"classic": "the default profile",
shortlivedProfile: "six day certificates",
},
},
})
case r.URL.Path == "/new-nonce":
// The Replay-Nonce header was already set above.
case r.URL.Path == "/new-account":
w.Header().Set("Location", f.srv.URL+"/account/1")
w.WriteHeader(http.StatusCreated)
writeJSON(map[string]any{"status": "valid"})
case r.URL.Path == "/new-order":
var req struct {
Identifiers []struct{ Type, Value string } `json:"identifiers"`
Profile string `json:"profile"`
}
if err := decodeJWSPayload(r, &req); err != nil {
f.t.Errorf("new-order payload: %v", err)
}
f.mu.Lock()
f.orders++
f.gotProfile = req.Profile
if len(req.Identifiers) == 1 {
f.gotIDType = req.Identifiers[0].Type
f.gotIDValue = req.Identifiers[0].Value
}
f.authzStatus = "pending"
f.orderStatus = "pending"
f.token = fmt.Sprintf("tok-%d", f.orders)
f.mu.Unlock()
w.Header().Set("Location", f.srv.URL+"/order/1")
w.WriteHeader(http.StatusCreated)
writeJSON(orderJSON())
case r.URL.Path == "/authz/1":
f.mu.Lock()
defer f.mu.Unlock()
writeJSON(map[string]any{
"status": f.authzStatus,
"identifier": map[string]string{"type": f.gotIDType, "value": f.gotIDValue},
"challenges": []map[string]string{{
"type": "http-01",
"url": f.srv.URL + "/chal/1",
"token": f.token,
"status": f.authzStatus,
}},
})
case r.URL.Path == "/chal/1":
if err := f.validateChallenge(); err != nil {
f.t.Errorf("challenge validation: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
f.mu.Lock()
f.authzStatus = "valid"
f.orderStatus = "ready"
f.mu.Unlock()
writeJSON(map[string]any{"type": "http-01", "status": "valid", "token": f.token})
case r.URL.Path == "/order/1":
f.mu.Lock()
defer f.mu.Unlock()
w.Header().Set("Location", f.srv.URL+"/order/1")
writeJSON(orderJSON())
case r.URL.Path == "/finalize/1":
var req struct {
CSR string `json:"csr"`
}
if err := decodeJWSPayload(r, &req); err != nil {
f.t.Errorf("finalize payload: %v", err)
}
if err := f.issueCert(req.CSR); err != nil {
f.t.Errorf("issuing cert: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
f.mu.Lock()
f.orderStatus = "valid"
f.mu.Unlock()
w.Header().Set("Location", f.srv.URL+"/order/1")
writeJSON(orderJSON())
case r.URL.Path == "/cert/1":
f.mu.Lock()
defer f.mu.Unlock()
w.Header().Set("Content-Type", "application/pem-certificate-chain")
w.Write(f.certPEM)
default:
f.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}
// validateChallenge fetches the http-01 challenge response from the
// server under test, standing in for the CA dialing port 80 at the IP
// address being validated.
func (f *fakeIPACME) validateChallenge() error {
f.mu.Lock()
token := f.token
base := f.challengeBase
f.mu.Unlock()
res, err := http.Get(base + "/.well-known/acme-challenge/" + token)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", res.StatusCode)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
if !strings.HasPrefix(string(body), token+".") {
return fmt.Errorf("challenge response %q does not start with %q", body, token+".")
}
return nil
}
// issueCert signs a certificate for the CSR (base64url DER), valid for
// six days like a LetsEncrypt shortlived profile certificate.
func (f *fakeIPACME) issueCert(csrB64 string) error {
csrDER, err := base64.RawURLEncoding.DecodeString(csrB64)
if err != nil {
return err
}
csr, err := x509.ParseCertificateRequest(csrDER)
if err != nil {
return err
}
if len(csr.IPAddresses) != 1 {
return fmt.Errorf("CSR has %d IP addresses; want 1", len(csr.IPAddresses))
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(2),
IPAddresses: csr.IPAddresses,
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(6 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, f.caCert, csr.PublicKey, f.caKey)
if err != nil {
return err
}
var buf []byte
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...)
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: f.caCert.Raw})...)
f.mu.Lock()
f.certPEM = buf
f.mu.Unlock()
return nil
}
// decodeJWSPayload decodes the payload of a JWS-encoded ACME request
// without verifying its signature.
func decodeJWSPayload(r *http.Request, v any) error {
var req struct{ Payload string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return err
}
b, err := base64.RawURLEncoding.DecodeString(req.Payload)
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
// ipConn is a stub net.Conn whose LocalAddr is the given IP, standing
// in for the accepted TLS connection whose destination address decides
// which certificate to serve.
type ipConn struct {
net.Conn
local net.Addr
}
func (c ipConn) LocalAddr() net.Addr { return c.local }
// helloFor returns a ClientHelloInfo as ipCertManager.getCertificate
// would see it for a connection to localIP with the given SNI value.
func helloFor(t *testing.T, localIP, sni string) *tls.ClientHelloInfo {
t.Helper()
ip := net.ParseIP(localIP)
if ip == nil {
t.Fatalf("bad IP %q", localIP)
}
return &tls.ClientHelloInfo{
ServerName: sni,
Conn: ipConn{local: &net.TCPAddr{IP: ip, Port: 443}},
}
}
// stubCertProvider is a certProvider returning a fixed certificate,
// standing in for the autocert manager handling DNS hostname
// connections.
type stubCertProvider struct {
cert *tls.Certificate
}
func (p *stubCertProvider) TLSConfig() *tls.Config {
return &tls.Config{
GetCertificate: func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return p.cert, nil
},
}
}
func (p *stubCertProvider) HTTPHandler(fallback http.Handler) http.Handler { return fallback }
// TestIPCertManager exercises the on-demand issuance flow of
// ipCertManager against a fake ACME CA: certs are ordered for whatever
// IP address a connection arrives on (IPv4 and IPv6), with the
// shortlived profile, answering the http-01 challenge, and reusing the
// on-disk cache.
func TestIPCertManager(t *testing.T) {
const ip4 = "203.0.113.7"
const ip6 = "2001:db8::7"
dir := t.TempDir()
ca := newFakeIPACME(t)
m, err := newIPCertManager(dir, "test@example.com", ca.directoryURL(), nil)
if err != nil {
t.Fatal(err)
}
// Serve the manager's HTTP-01 challenge handler like derper's port
// 80 listener does.
challengeSrv := httptest.NewServer(m.HTTPHandler(http.NotFoundHandler()))
defer challengeSrv.Close()
ca.challengeBase = challengeSrv.URL
// A connection to the IPv4 address with no SNI mints a cert for it.
cert, err := m.getCertificate(helloFor(t, ip4, ""))
if err != nil {
t.Fatal(err)
}
if err := cert.Leaf.VerifyHostname(ip4); err != nil {
t.Errorf("issued cert not valid for %v: %v", ip4, err)
}
if got, want := ca.gotProfile, shortlivedProfile; got != want {
t.Errorf("order profile = %q; want %q", got, want)
}
if ca.gotIDType != "ip" || ca.gotIDValue != ip4 {
t.Errorf("order identifier = %q %q; want %q %q", ca.gotIDType, ca.gotIDValue, "ip", ip4)
}
if n := ca.numOrders(); n != 1 {
t.Errorf("orders created = %d; want 1", n)
}
// A connection to the IPv6 address mints a second, separate cert.
cert6, err := m.getCertificate(helloFor(t, ip6, ""))
if err != nil {
t.Fatal(err)
}
if err := cert6.Leaf.VerifyHostname(ip6); err != nil {
t.Errorf("issued cert not valid for %v: %v", ip6, err)
}
if ca.gotIDType != "ip" || ca.gotIDValue != ip6 {
t.Errorf("order identifier = %q %q; want %q %q", ca.gotIDType, ca.gotIDValue, "ip", ip6)
}
if n := ca.numOrders(); n != 2 {
t.Errorf("orders created = %d; want 2", n)
}
// An SNI containing the connection's own IP address is served from
// the cache.
if _, err := m.getCertificate(helloFor(t, ip4, ip4)); err != nil {
t.Errorf("getCertificate with matching IP SNI: %v", err)
}
if n := ca.numOrders(); n != 2 {
t.Errorf("orders created after cached hit = %d; want 2", n)
}
// An SNI naming some other IP address is rejected.
if _, err := m.getCertificate(helloFor(t, ip4, "203.0.113.8")); err == nil {
t.Error("getCertificate with mismatched IP SNI succeeded; want error")
}
// A DNS name SNI has no provider to go to here.
if _, err := m.getCertificate(helloFor(t, ip4, "derp.example.com")); err == nil {
t.Error("getCertificate with DNS SNI and no next provider succeeded; want error")
}
// A second manager over the same cert directory must use the
// on-disk cache rather than creating more orders.
m2, err := newIPCertManager(dir, "test@example.com", ca.directoryURL(), nil)
if err != nil {
t.Fatal(err)
}
cachedCert, err := m2.getCertificate(helloFor(t, ip4, ""))
if err != nil {
t.Fatal(err)
}
if err := cachedCert.Leaf.VerifyHostname(ip4); err != nil {
t.Errorf("cached cert not valid for %v: %v", ip4, err)
}
if n := ca.numOrders(); n != 2 {
t.Errorf("orders created after cache reuse = %d; want 2", n)
}
// Non-challenge requests go to the fallback handler.
rec := httptest.NewRecorder()
m.HTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "fallback")
})).ServeHTTP(rec, httptest.NewRequest("GET", "/other", nil))
if got := rec.Body.String(); got != "fallback" {
t.Errorf("fallback body = %q; want %q", got, "fallback")
}
}
// TestIPCertManagerNextProvider verifies that connections with a DNS
// name in the SNI are passed through to the next provider.
func TestIPCertManagerNextProvider(t *testing.T) {
dir := t.TempDir()
ca := newFakeIPACME(t)
stubCert := &tls.Certificate{}
m, err := newIPCertManager(dir, "", ca.directoryURL(), &stubCertProvider{cert: stubCert})
if err != nil {
t.Fatal(err)
}
got, err := m.getCertificate(helloFor(t, "203.0.113.7", "derp.example.com"))
if err != nil {
t.Fatal(err)
}
if got != stubCert {
t.Errorf("DNS SNI returned %p; want the next provider's cert %p", got, stubCert)
}
if n := ca.numOrders(); n != 0 {
t.Errorf("orders created = %d; want 0", n)
}
}
// TestCertModeIPCertsGating verifies the flag validation around
// --acme-ip-certs and IP address hostnames.
func TestCertModeIPCertsGating(t *testing.T) {
tests := []struct {
name string
mode string
host string
ipCerts bool
wantErr string // or empty to expect success
}{
{"letsencrypt_ip_no_flag", "letsencrypt", "1.2.3.4", false, "--acme-ip-certs"},
{"gcp_ip", "gcp", "1.2.3.4", false, "--certmode=gcp requires --hostname to be a DNS name"},
{"gcp_flag", "gcp", "1.2.3.4", true, "--acme-ip-certs requires --certmode=letsencrypt"},
{"manual_flag", "manual", "1.2.3.4", true, "--acme-ip-certs requires --certmode=letsencrypt"},
{"letsencrypt_ip_flag", "letsencrypt", "1.2.3.4", true, ""},
{"letsencrypt_hostname_flag", "letsencrypt", "derp.example.com", true, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cp, err := certProviderByCertMode(tt.mode, t.TempDir(), tt.host, tt.ipCerts, "", "", "")
if tt.wantErr == "" {
if err != nil {
t.Fatalf("certProviderByCertMode(%q, %q, ipCerts=%v) = %v; want success", tt.mode, tt.host, tt.ipCerts, err)
}
m, ok := cp.(*ipCertManager)
if !ok {
t.Fatalf("provider type = %T; want *ipCertManager", cp)
}
wantNext := net.ParseIP(tt.host) == nil
if gotNext := m.next != nil; gotNext != wantNext {
t.Errorf("has next provider = %v; want %v", gotNext, wantNext)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("certProviderByCertMode(%q, %q, ipCerts=%v) error = %v; want contains %q",
tt.mode, tt.host, tt.ipCerts, err, tt.wantErr)
}
})
}
}
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package derpserver
package main
import (
"bufio"
@@ -11,20 +11,14 @@ import (
"strings"
"github.com/coder/websocket"
"tailscale.com/derp/derpserver"
"tailscale.com/net/wsconn"
)
var counterWebSocketAccepts = expvar.NewInt("derp_websocket_accepts")
// AddWebSocketSupport returns an http.Handler wrapping base that adds
// WebSocket-DERP support. WebSocket-DERP requests (those with an Upgrade:
// websocket header and a "derp" Sec-WebSocket-Protocol value) are
// handled here; all other requests pass through to base.
//
// The browser-side Tailscale client (cmd/tsconnect/wasm) can only reach DERP
// via WebSocket, so any DERP server intended to be reachable from browsers
// must wrap derpserver.Handler with this function.
func AddWebSocketSupport(s *Server, base http.Handler) http.Handler {
// addWebSocketSupport returns a Handle wrapping base that adds WebSocket server support.
func addWebSocketSupport(s *derpserver.Server, base http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
up := strings.ToLower(r.Header.Get("Upgrade"))
-821
View File
@@ -1,821 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
// fbstatus is a Linux framebuffer status display for the Tailscale
// appliance. It draws the Tailscale logo, the tailscaled backend state,
// the device's tailnet IP addresses, and (when the device needs to be
// logged in) a QR code containing the login URL so a user can enroll
// the appliance into a tailnet by pointing their phone camera at the
// screen.
//
// fbstatus accesses the framebuffer via the Linux UAPI in
// include/uapi/linux/fb.h: FBIOGET_VSCREENINFO and FBIOGET_FSCREENINFO
// ioctls plus an mmap of /dev/fb0. Only 32-bit truecolor framebuffers
// (the Raspberry Pi default) are supported.
package main
import (
"bytes"
"context"
_ "embed"
"encoding/binary"
"flag"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"log"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
"unsafe"
"github.com/skip2/go-qrcode"
xdraw "golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
"golang.org/x/sys/unix"
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/util/cloudenv"
)
//go:embed tailscale.png
var tailscalePNG []byte
// Linux framebuffer ioctl numbers, from include/uapi/linux/fb.h.
const (
fbioGetVScreenInfo = 0x4600
fbioGetFScreenInfo = 0x4602
)
// Linux VT ioctl numbers and KD_* modes, from include/uapi/linux/kd.h
// and include/uapi/linux/vt.h.
const (
kdSetMode = 0x4B3A
kdGraphics = 1
kdText = 0
vtActivate = 0x5606
vtWaitActive = 0x5607
)
// Byte offsets into the raw fb_var_screeninfo struct returned by
// FBIOGET_VSCREENINFO. All fields we read are little-endian uint32.
const (
vsOffXres = 0
vsOffYres = 4
vsOffBitsPerPixel = 24
vsOffRedOffset = 32 // start of struct fb_bitfield red
vsOffGreenOffset = 44 // start of struct fb_bitfield green
vsOffBlueOffset = 56 // start of struct fb_bitfield blue
)
// Byte offsets into the raw fb_fix_screeninfo struct returned by
// FBIOGET_FSCREENINFO. Layout assumes a 64-bit kernel (the gokrazy
// appliance targets — arm64/amd64 — are both 64-bit). smem_start and
// mmio_start are "unsigned long", which is 8 bytes on 64-bit.
const (
fsOffSmemLen = 24
fsOffLineLength = 48
)
var flagFB = flag.String("fb", "/dev/fb0", "framebuffer device to draw to")
// noFramebufferReason reports whether this host lacks a usable Linux
// framebuffer, along with a short human-readable explanation. If the
// framebuffer device is missing, or we're running on a cloud (currently
// only AWS) whose instances don't expose one, we return true.
func noFramebufferReason(fbPath string) (string, bool) {
if cloudenv.Get() == cloudenv.AWS {
return "running on AWS (no framebuffer)", true
}
if _, err := os.Stat(fbPath); err != nil {
return fmt.Sprintf("no framebuffer at %s: %v", fbPath, err), true
}
return "", false
}
func main() {
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
// Bail out early on cloud VMs that don't ship a framebuffer. We
// still kick off breakglass once DHCP succeeds (otherwise the
// appliance is unreachable — breakglass declares DontStartOnBoot
// and only runs when fbstatus pokes the supervisor), then exit
// 125 so the gokrazy supervisor stops respawning us. See
// https://gokrazy.org/development/process-interface/.
if reason, ok := noFramebufferReason(*flagFB); ok {
log.Printf("%s; starting breakglass after DHCP then exiting 125", reason)
startBreakglassAfterDHCP(&uiState{})
log.Printf("breakglass started; exiting 125 so gokrazy won't respawn fbstatus")
os.Exit(125)
}
if restore, err := claimVTGraphics(); err != nil {
log.Printf("could not put VT into graphics mode (fbcon may overdraw): %v", err)
} else {
defer restore()
}
fb, err := openFramebuffer(*flagFB)
if err != nil {
return fmt.Errorf("open framebuffer: %w", err)
}
defer fb.Close()
log.Printf("framebuffer %s: %dx%d, %d bpp, line=%d, RGB offsets %d/%d/%d",
*flagFB, fb.width, fb.height, fb.bpp, fb.lineLength,
fb.redShift, fb.greenShift, fb.blueShift)
logo, err := png.Decode(bytes.NewReader(tailscalePNG))
if err != nil {
return fmt.Errorf("decoding embedded logo: %w", err)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
var lc local.Client
st := &uiState{fb: fb, logo: logo}
st.updateLAN()
st.render()
go st.pollLAN(ctx)
go startBreakglassAfterDHCP(st)
go watchKeyboardForConsole(ctx, st)
for ctx.Err() == nil {
if err := watchBusOnce(ctx, &lc, st); err != nil && ctx.Err() == nil {
log.Printf("ipn watch: %v; retrying in 2s", err)
select {
case <-ctx.Done():
case <-time.After(2 * time.Second):
}
}
}
return nil
}
func watchBusOnce(ctx context.Context, lc *local.Client, st *uiState) error {
w, err := lc.WatchIPNBus(ctx,
ipn.NotifyInitialState|ipn.NotifyInitialPrefs|ipn.NotifyInitialStatus)
if err != nil {
return err
}
defer w.Close()
loginRequested := false
for ctx.Err() == nil {
n, err := w.Next()
if err != nil {
return err
}
if n.State != nil {
st.state = *n.State
// On a fresh appliance, tailscaled enters NeedsLogin but
// does not generate a login URL until someone asks. Trigger
// an interactive login so the control server sends us a URL
// (and thus a QR code appears on the display).
if *n.State == ipn.NeedsLogin && !loginRequested {
loginRequested = true
go func() {
if err := lc.StartLoginInteractive(ctx); err != nil {
log.Printf("StartLoginInteractive: %v", err)
}
}()
}
}
if n.BrowseToURL != nil {
st.loginURL = *n.BrowseToURL
}
if n.InitialStatus != nil {
st.ips = append(st.ips[:0], n.InitialStatus.TailscaleIPs...)
}
if n.SelfChange != nil {
st.ips = st.ips[:0]
for _, p := range n.SelfChange.Addresses {
st.ips = append(st.ips, p.Addr())
}
}
st.render()
}
return ctx.Err()
}
// updateLAN scans network interfaces for a non-loopback interface with a
// hardware address, updating st.lanIP and st.lanMAC. Shows the MAC even
// if DHCP hasn't assigned an IP yet.
func (st *uiState) updateLAN() {
ifaces, err := net.Interfaces()
if err != nil {
return
}
var bestMAC string
var bestIP string
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if len(iface.HardwareAddr) == 0 {
continue
}
if bestMAC == "" {
bestMAC = iface.HardwareAddr.String()
}
if iface.Flags&net.FlagUp == 0 {
continue
}
// Prefer the first UP interface with a MAC.
if bestMAC != iface.HardwareAddr.String() && bestIP == "" {
bestMAC = iface.HardwareAddr.String()
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil {
bestMAC = iface.HardwareAddr.String()
bestIP = ipnet.IP.String()
}
}
}
st.lanMAC = bestMAC
st.lanIP = bestIP
}
// startBreakglassAfterDHCP waits until a LAN IP is assigned (meaning DHCP
// succeeded), then restarts breakglass. This ensures breakglass sees the
// real LAN address in PrivateInterfaceAddrs and binds to it, rather than
// only binding to 127.0.0.1.
func startBreakglassAfterDHCP(st *uiState) {
for {
st.updateLAN()
if st.lanIP != "" {
break
}
time.Sleep(time.Second)
}
startBreakglass()
}
// startBreakglass asks the gokrazy init HTTP API (over its unix socket) to
// restart the breakglass service so it actually runs. By default breakglass
// calls DontStartOnBoot and exits on the first launch attempt; this poke
// tells the supervisor to try again (without GOKRAZY_FIRST_START=1).
func startBreakglass() {
const sock = "/run/gokrazy-http.sock"
hc := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", sock)
},
},
}
form := url.Values{
"path": {"/user/breakglass"},
"xsrftoken": {"1"},
}
req, err := http.NewRequest("POST", "http://gokrazy/restart", strings.NewReader(form.Encode()))
if err != nil {
log.Printf("startBreakglass: %v", err)
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "gokrazy_xsrf", Value: "1"})
resp, err := hc.Do(req)
if err != nil {
log.Printf("startBreakglass: %v", err)
return
}
resp.Body.Close()
if resp.StatusCode < 300 || resp.StatusCode == http.StatusSeeOther {
log.Printf("startBreakglass: restarted (status %s)", resp.Status)
} else {
log.Printf("startBreakglass: unexpected status %s", resp.Status)
}
}
// watchKeyboardForConsole monitors keyboard input devices for VT-switching
// accelerators.
// - Ctrl-Alt-F2 (or plain Esc — easier to type in NoVNC where the
// Ctrl-Alt-Fn sequence doesn't always propagate) switches to VT2, a
// text-mode busybox shell. When that shell exits, we switch back
// automatically.
// - Ctrl-Alt-F1 switches back to VT1 (fbstatus graphics mode).
//
// This mirrors standard Linux VT switching conventions plus a NoVNC-
// friendly shortcut.
func watchKeyboardForConsole(ctx context.Context, st *uiState) {
kbdPath := findKeyboard()
if kbdPath == "" {
log.Printf("no keyboard found for VT switching")
return
}
kbd, err := os.Open(kbdPath)
if err != nil {
log.Printf("open keyboard %s: %v", kbdPath, err)
return
}
defer kbd.Close()
ttyFile, err := os.OpenFile("/dev/tty0", os.O_RDWR, 0)
if err != nil {
log.Printf("open /dev/tty0 for VT switch: %v", err)
return
}
defer ttyFile.Close()
ttyFd := int(ttyFile.Fd())
log.Printf("watching %s for Ctrl-Alt-F1/F2 and Esc (VT switching)", kbdPath)
// Linux input_event has the same layout on both arm64 and amd64
// (24 bytes: two uint64 timestamps + uint16 type + uint16 code +
// int32 value), so this parser handles both the Pi and Proxmox VM.
const evSize = 24
const evKey = 1 // EV_KEY
const keyEsc = 1 // KEY_ESC
const keyF1 = 59 // KEY_F1
const keyF2 = 60 // KEY_F2
const keyLeftCtrl = 29
const keyLeftAlt = 56
const keyRightCtrl = 97
const keyRightAlt = 100
const keyPress = 1
buf := make([]byte, evSize)
var ctrlHeld, altHeld bool
switchToFbstatus := func() {
st.paused.Store(false)
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 1)
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 1)
ioctlSetInt(ttyFile, kdSetMode, kdGraphics)
st.render()
}
switchToShell := func(reason string) {
st.paused.Store(true)
ioctlSetInt(ttyFile, kdSetMode, kdText)
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 2)
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 2)
go ensureShellOnVT2(switchToFbstatus)
log.Printf("%s: switched to text console", reason)
}
for ctx.Err() == nil {
n, err := kbd.Read(buf)
if err != nil || n < evSize {
continue
}
evType := binary.LittleEndian.Uint16(buf[16:18])
evCode := binary.LittleEndian.Uint16(buf[18:20])
evValue := int32(binary.LittleEndian.Uint32(buf[20:24]))
if evType != evKey {
continue
}
pressed := evValue == keyPress
released := evValue == 0
switch evCode {
case keyLeftCtrl, keyRightCtrl:
if pressed {
ctrlHeld = true
} else if released {
ctrlHeld = false
}
case keyLeftAlt, keyRightAlt:
if pressed {
altHeld = true
} else if released {
altHeld = false
}
case keyEsc:
if pressed && !ctrlHeld && !altHeld {
// Bare Esc: NoVNC-friendly shortcut to the shell.
switchToShell("Esc")
}
case keyF1:
if pressed && ctrlHeld && altHeld {
switchToFbstatus()
log.Printf("Ctrl-Alt-F1: switched to fbstatus")
}
case keyF2:
if pressed && ctrlHeld && altHeld {
switchToShell("Ctrl-Alt-F2")
}
}
}
}
// ensureShellOnVT2 spawns a busybox ash shell on /dev/tty2 if one isn't
// already running. The shell gets the VT2 tty as its controlling terminal
// so keyboard input on VT2 goes to it. When the shell exits, onExit is
// called (typically to switch back to VT1 / fbstatus graphics mode).
var shellOnVT2Running atomic.Bool
func ensureShellOnVT2(onExit func()) {
if !shellOnVT2Running.CompareAndSwap(false, true) {
return
}
go func() {
defer shellOnVT2Running.Store(false)
defer func() {
if onExit != nil {
onExit()
}
}()
shell := "/tmp/serial-busybox/ash"
if _, err := os.Stat(shell); err != nil {
log.Printf("no shell at %s for VT2", shell)
return
}
tty, err := os.OpenFile("/dev/tty2", os.O_RDWR, 0)
if err != nil {
log.Printf("open /dev/tty2: %v", err)
return
}
defer tty.Close()
cmd := exec.Command(shell)
cmd.Stdin = tty
cmd.Stdout = tty
cmd.Stderr = tty
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
Setctty: true,
Ctty: 0, // index into cmd's file descriptors (stdin = tty)
}
cmd.Env = append(os.Environ(), "TERM=linux", "HOME=/tmp", "PATH=/tmp/serial-busybox:/user:/gokrazy")
log.Printf("starting shell on VT2")
if err := cmd.Run(); err != nil {
log.Printf("shell on VT2 exited: %v", err)
}
}()
}
// findKeyboard looks for a keyboard among /dev/input/event* devices by
// checking that the device's key capability bitmap has KEY_ESC set. The
// alternative "any non-zero key bitmap" check picks up the ACPI power
// button (which advertises KEY_POWER but no Esc) and misses the real
// keyboard on amd64 Proxmox VMs, where the AT keyboard is event1 but
// event0 is Power Button.
func findKeyboard() string {
matches, _ := filepath.Glob("/dev/input/event*")
for _, path := range matches {
name := filepath.Base(path)
capData, err := os.ReadFile("/sys/class/input/" + name + "/device/capabilities/key")
if err != nil {
continue
}
fields := strings.Fields(strings.TrimSpace(string(capData)))
if len(fields) == 0 {
continue
}
// The kernel prints capability bitmaps as space-separated 64-bit
// hex chunks, most-significant chunk first. The last chunk holds
// bits 0..63. KEY_ESC = 1, so its bit-mask is 1<<1 == 0x2.
low, err := strconv.ParseUint(fields[len(fields)-1], 16, 64)
if err != nil {
continue
}
const keyEscBit = 1 << 1
if low&keyEscBit != 0 {
return path
}
}
return ""
}
// pollLAN periodically refreshes LAN info and re-renders.
func (st *uiState) pollLAN(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
st.updateLAN()
st.render()
}
}
}
// uiState is the most-recently-known view of the appliance state that
// gets rendered to the framebuffer on each notify.
type uiState struct {
fb *framebuffer
logo image.Image
state ipn.State
loginURL string
ips []netip.Addr
lanIP string // LAN IPv4 address (from DHCP)
lanMAC string // MAC address of the primary interface
paused atomic.Bool // when true, render() is a no-op (VT switched away)
}
var (
bgColor = color.RGBA{0x10, 0x12, 0x20, 0xff} // near-black slate
fgColor = color.RGBA{0xff, 0xff, 0xff, 0xff}
dimColor = color.RGBA{0xa0, 0xa6, 0xb8, 0xff}
stateOK = color.RGBA{0x4a, 0xc8, 0x82, 0xff} // green for Running
stateWait = color.RGBA{0xf0, 0xc8, 0x60, 0xff} // amber for NeedsLogin/Starting
)
// render composes the current state into an in-memory image and blits
// it to the framebuffer.
func (st *uiState) render() {
if st.paused.Load() {
return
}
w, h := st.fb.width, st.fb.height
img := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Draw(img, img.Bounds(), &image.Uniform{C: bgColor}, image.Point{}, draw.Src)
shortSide := min(w, h)
// Logo, scaled to ~25% of the shorter dimension, centered
// horizontally near the top.
logoSize := shortSide / 4
logoRect := image.Rect(0, 0, logoSize, logoSize).Add(image.Point{
X: (w - logoSize) / 2,
Y: shortSide / 16,
})
xdraw.ApproxBiLinear.Scale(img, logoRect, st.logo, st.logo.Bounds(), xdraw.Over, nil)
lineH := basicfont.Face7x13.Metrics().Height.Ceil()
textTop := logoRect.Max.Y + shortSide/24
// Hide the state line when the QR code is visible (the "Scan to
// enroll" label is clear enough context).
showState := !(st.state == ipn.NeedsLogin && st.loginURL != "")
if showState {
stateColor := dimColor
switch st.state {
case ipn.Running:
stateColor = stateOK
case ipn.NeedsLogin, ipn.Starting, ipn.NoState:
stateColor = stateWait
}
drawCenteredScaled(img, fmt.Sprintf("State: %s", stateLabel(st.state)),
stateColor, w/2, textTop, 3)
}
y := textTop + 3*lineH + shortSide/40
if len(st.ips) > 0 {
drawCenteredScaled(img, "Tailscale IPs:", dimColor, w/2, y, 2)
y += 2 * lineH
for _, a := range st.ips {
drawCenteredScaled(img, a.String(), fgColor, w/2, y, 2)
y += 2*lineH + 4
}
}
// QR code with the login URL when enrollment is needed.
if st.state == ipn.NeedsLogin && st.loginURL != "" {
qrSize := shortSide / 2
q, err := qrcode.New(st.loginURL, qrcode.Medium)
if err != nil {
log.Printf("qr encode %q: %v", st.loginURL, err)
} else {
q.DisableBorder = false
qrImg := q.Image(qrSize)
qrRect := qrImg.Bounds().Add(image.Point{
X: (w - qrSize) / 2,
Y: h - qrSize - shortSide/16,
})
draw.Draw(img, qrRect, qrImg, qrImg.Bounds().Min, draw.Src)
drawCenteredScaled(img, "Scan to enroll this device",
fgColor, w/2, qrRect.Min.Y-lineH*2-8, 2)
}
}
// LAN status pinned to the bottom-left corner.
{
lanY := h - lineH - 4
var lanText string
if st.lanIP != "" {
lanText = "LAN IP: " + st.lanIP
} else if st.lanMAC != "" {
lanText = "Waiting for DHCP (" + st.lanMAC + ")"
}
if lanText != "" {
face := basicfont.Face7x13
textW := font.MeasureString(face, lanText).Ceil()
small := image.NewRGBA(image.Rect(0, 0, textW, lineH))
d := font.Drawer{
Dst: small,
Src: &image.Uniform{C: dimColor},
Face: face,
Dot: fixed.P(0, face.Metrics().Ascent.Ceil()),
}
d.DrawString(lanText)
dstRect := image.Rect(4, lanY, 4+textW, lanY+lineH)
draw.Draw(img, dstRect, small, image.Point{}, draw.Over)
}
}
st.fb.blit(img)
}
// drawCenteredScaled draws s with basicfont.Face7x13 at the given
// integer pixel scale, centered horizontally on x at top y, in col.
func drawCenteredScaled(dst *image.RGBA, s string, col color.Color, x, y, scale int) {
if s == "" {
return
}
face := basicfont.Face7x13
width := font.MeasureString(face, s).Ceil()
height := face.Metrics().Height.Ceil()
small := image.NewRGBA(image.Rect(0, 0, width, height))
d := font.Drawer{
Dst: small,
Src: &image.Uniform{C: col},
Face: face,
Dot: fixed.P(0, face.Metrics().Ascent.Ceil()),
}
d.DrawString(s)
scaledW, scaledH := width*scale, height*scale
dstRect := image.Rect(0, 0, scaledW, scaledH).Add(image.Point{
X: x - scaledW/2,
Y: y,
})
xdraw.NearestNeighbor.Scale(dst, dstRect, small, small.Bounds(), xdraw.Over, nil)
}
func stateLabel(s ipn.State) string {
switch s {
case ipn.NoState, ipn.Starting:
return "starting"
case ipn.NeedsLogin:
return "needs login"
case ipn.NeedsMachineAuth:
return "needs machine auth"
case ipn.Stopped:
return "stopped"
case ipn.Running:
return "running"
}
return strings.ToLower(s.String())
}
// framebuffer is an mmap'd Linux framebuffer device.
type framebuffer struct {
f *os.File
mem []byte
width int
height int
bpp int
lineLength int
// Bit offsets into a 32-bit pixel for each channel, from the
// fb_bitfield values returned by FBIOGET_VSCREENINFO.
redShift uint32
greenShift uint32
blueShift uint32
}
// openFramebuffer opens path, queries dimensions and pixel format via
// the FBIOGET_* ioctls, and mmaps the framebuffer memory.
//
// Only 32-bits-per-pixel framebuffers are supported. Raspberry Pi 3/4/5
// default to that.
func openFramebuffer(path string) (*framebuffer, error) {
f, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return nil, err
}
var (
vbuf [160]byte // fb_var_screeninfo
fbuf [80]byte // fb_fix_screeninfo
)
if err := ioctlGet(f, fbioGetVScreenInfo, vbuf[:]); err != nil {
f.Close()
return nil, fmt.Errorf("FBIOGET_VSCREENINFO: %w", err)
}
if err := ioctlGet(f, fbioGetFScreenInfo, fbuf[:]); err != nil {
f.Close()
return nil, fmt.Errorf("FBIOGET_FSCREENINFO: %w", err)
}
fb := &framebuffer{
f: f,
width: int(binary.LittleEndian.Uint32(vbuf[vsOffXres:])),
height: int(binary.LittleEndian.Uint32(vbuf[vsOffYres:])),
bpp: int(binary.LittleEndian.Uint32(vbuf[vsOffBitsPerPixel:])),
lineLength: int(binary.LittleEndian.Uint32(fbuf[fsOffLineLength:])),
redShift: binary.LittleEndian.Uint32(vbuf[vsOffRedOffset:]),
greenShift: binary.LittleEndian.Uint32(vbuf[vsOffGreenOffset:]),
blueShift: binary.LittleEndian.Uint32(vbuf[vsOffBlueOffset:]),
}
if fb.bpp != 32 {
f.Close()
return nil, fmt.Errorf("unsupported framebuffer bpp %d (only 32 is supported)", fb.bpp)
}
memLen := int(binary.LittleEndian.Uint32(fbuf[fsOffSmemLen:]))
mem, err := unix.Mmap(int(f.Fd()), 0, memLen,
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
f.Close()
return nil, fmt.Errorf("mmap %s: %w", path, err)
}
fb.mem = mem
return fb, nil
}
func (fb *framebuffer) Close() error {
if fb.mem != nil {
unix.Munmap(fb.mem)
fb.mem = nil
}
return fb.f.Close()
}
// blit copies img into the mapped framebuffer, packing each
// image.RGBA pixel into the framebuffer's per-channel bit layout.
func (fb *framebuffer) blit(img *image.RGBA) {
srcStride := img.Stride
for y := 0; y < fb.height; y++ {
srcRow := img.Pix[y*srcStride : y*srcStride+fb.width*4]
dstRow := fb.mem[y*fb.lineLength:]
for x := 0; x < fb.width; x++ {
r := uint32(srcRow[x*4+0])
g := uint32(srcRow[x*4+1])
b := uint32(srcRow[x*4+2])
px := r<<fb.redShift | g<<fb.greenShift | b<<fb.blueShift
binary.LittleEndian.PutUint32(dstRow[x*4:], px)
}
}
}
// claimVTGraphics puts the active virtual terminal into KD_GRAPHICS so
// the kernel's framebuffer console (fbcon) stops drawing on /dev/fb0
// while fbstatus owns it. It returns a function that restores KD_TEXT.
//
// The Linux kernel applies VT mode to whatever VT is current; the open
// path /dev/tty0 always refers to the foreground VT, which on a
// headless gokrazy appliance is the only VT.
func claimVTGraphics() (restore func(), err error) {
f, err := os.OpenFile("/dev/tty0", os.O_RDWR, 0)
if err != nil {
return nil, err
}
if err := ioctlSetInt(f, kdSetMode, kdGraphics); err != nil {
f.Close()
return nil, fmt.Errorf("KDSETMODE KD_GRAPHICS: %w", err)
}
return func() {
if err := ioctlSetInt(f, kdSetMode, kdText); err != nil {
log.Printf("KDSETMODE KD_TEXT on shutdown: %v", err)
}
f.Close()
}, nil
}
// ioctlSetInt runs an ioctl with a single integer arg, like KDSETMODE.
func ioctlSetInt(f *os.File, req uintptr, arg uintptr) error {
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), req, arg)
if errno != 0 {
return errno
}
return nil
}
// ioctlGet runs an ioctl that fills a struct of len(buf) bytes in buf.
// Used for the FBIOGET_* ioctls; on success buf holds the kernel's
// fb_*_screeninfo struct.
func ioctlGet(f *os.File, req uintptr, buf []byte) error {
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), req,
uintptr(unsafe.Pointer(&buf[0])))
if errno != 0 {
return errno
}
return nil
}
-17
View File
@@ -1,17 +0,0 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux
package main
import (
"fmt"
"os"
"runtime"
)
func main() {
fmt.Fprintf(os.Stderr, "fbstatus is only supported on Linux (got %s)\n", runtime.GOOS)
os.Exit(1)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+2 -6
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"
)
@@ -255,11 +255,7 @@ func getCredentials() (*http.Client, string) {
} else if idok && idToken != "" && oiok && oauthId != "" {
if exchangeJWTForToken, ok := tailscale.HookExchangeJWTForTokenViaWIF.GetOk(); ok {
var err error
apiKeyEnv, err = exchangeJWTForToken(context.Background(), tailscale.ExchangeJWTForTokenWIFArgs{
BaseURL: fmt.Sprintf("https://%s", *apiServer),
ClientID: oauthId,
IDToken: idToken,
})
apiKeyEnv, err = exchangeJWTForToken(context.Background(), fmt.Sprintf("https://%s", *apiServer), oauthId, idToken)
if err != nil {
log.Fatal(err)
}
+201 -5
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;
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ import (
const (
// tsNetDomain is the domain that this DNS nameserver has registered a handler for.
tsNetDomain = "ts.net"
// addr is the address that the UDP and TCP listeners will listen on.
// addr is the the address that the UDP and TCP listeners will listen on.
addr = ":1053"
// defaultTTL is the default TTL for DNS records in seconds.
// Set to 0 to disable caching. Can be increased when usage patterns are better understood.
+3 -9
View File
@@ -436,16 +436,14 @@ func exclusiveOwnerAnnotations(pg *tsapi.ProxyGroup, operatorID string, svc *tai
}
if svc == nil {
c := ownerAnnotationValue{OwnerRefs: []OwnerRef{ref}}
data, err := json.Marshal(c)
json, err := json.Marshal(c)
if err != nil {
return nil, fmt.Errorf("failed to marshal Tailscale Service's owner annotation contents: %w", err)
return nil, fmt.Errorf("[unexpected] unable to marshal Tailscale Service's owner annotation contents: %w, please report this", err)
}
return map[string]string{
ownerAnnotation: string(data),
ownerAnnotation: string(json),
}, nil
}
o, err := parseOwnerAnnotation(svc)
if err != nil {
return nil, err
@@ -453,19 +451,15 @@ func exclusiveOwnerAnnotations(pg *tsapi.ProxyGroup, operatorID string, svc *tai
if o == nil || len(o.OwnerRefs) == 0 {
return nil, fmt.Errorf("Tailscale Service %s exists, but does not contain owner annotation with owner references; not proceeding as this is likely a resource created by something other than the Tailscale Kubernetes operator", svc.Name)
}
if len(o.OwnerRefs) > 1 || o.OwnerRefs[0].OperatorID != operatorID {
return nil, fmt.Errorf("Tailscale Service %s is already owned by other operator(s) and cannot be shared across multiple clusters; configure a difference Service name to continue", svc.Name)
}
if o.OwnerRefs[0].Resource == nil {
return nil, fmt.Errorf("Tailscale Service %s exists, but does not reference an owning resource; not proceeding as this is likely a Service already owned by an Ingress", svc.Name)
}
if o.OwnerRefs[0].Resource.Kind != "ProxyGroup" || o.OwnerRefs[0].Resource.UID != string(pg.UID) {
return nil, fmt.Errorf("Tailscale Service %s is already owned by another resource: %#v; configure a difference Service name to continue", svc.Name, o.OwnerRefs[0].Resource)
}
if o.OwnerRefs[0].Resource.Name != pg.Name {
// ProxyGroup name can be updated in place.
o.OwnerRefs[0].Resource.Name = pg.Name
-7
View File
@@ -29,8 +29,6 @@ import (
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/kube/kubetypes"
"tailscale.com/net/netutil"
"tailscale.com/net/tsaddr"
"tailscale.com/tstime"
"tailscale.com/util/clientmetric"
"tailscale.com/util/set"
@@ -358,11 +356,6 @@ func validateRoutes(routes tsapi.Routes) error {
if pfx.Masked() != pfx {
errs = append(errs, fmt.Errorf("route %s has non-address bits set; expected %s", pfx, pfx.Masked()))
}
if tsaddr.IsViaPrefix(pfx) {
if err := netutil.ValidateViaPrefix(pfx); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
-16
View File
@@ -145,22 +145,6 @@ func TestConnector(t *testing.T) {
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
// Set an invalid 4via6 route (site ID too large).
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"fd7a:115c:a1e0:b1a:1:0:a2c:0/116"}
})
expectReconciled(t, cr, "", "test")
// STS should still have the previous valid route, unchanged.
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
// Set a valid 4via6 route.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"fd7a:115c:a1e0:b1a:0:1:a2c:0/116"}
})
opts.subnetRoutes = "fd7a:115c:a1e0:b1a:0:1:a2c:0/116"
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs)
// Delete the Connector.
if err = fc.Delete(context.Background(), cn); err != nil {
t.Fatalf("error deleting Connector: %v", err)
+114 -47
View File
@@ -6,12 +6,84 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
github.com/aws/aws-sdk-go-v2/aws from github.com/aws/aws-sdk-go-v2/aws/defaults+
github.com/aws/aws-sdk-go-v2/aws/defaults from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/aws/middleware from github.com/aws/aws-sdk-go-v2/aws/retry+
github.com/aws/aws-sdk-go-v2/aws/protocol/query from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/aws/protocol/restjson from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/aws/protocol/xml from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/aws/ratelimit from github.com/aws/aws-sdk-go-v2/aws/retry
github.com/aws/aws-sdk-go-v2/aws/retry from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client+
github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4 from github.com/aws/aws-sdk-go-v2/aws/signer/v4
github.com/aws/aws-sdk-go-v2/aws/signer/v4 from github.com/aws/aws-sdk-go-v2/internal/auth/smithy+
github.com/aws/aws-sdk-go-v2/aws/transport/http from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/config from tailscale.com/wif
github.com/aws/aws-sdk-go-v2/credentials from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/endpointcreds/internal/client from github.com/aws/aws-sdk-go-v2/credentials/endpointcreds
github.com/aws/aws-sdk-go-v2/credentials/processcreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/ssocreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/credentials/stscreds from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/feature/ec2/imds from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds/internal/config from github.com/aws/aws-sdk-go-v2/feature/ec2/imds
github.com/aws/aws-sdk-go-v2/internal/auth from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
github.com/aws/aws-sdk-go-v2/internal/auth/smithy from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/configsources from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/context from github.com/aws/aws-sdk-go-v2/aws/retry+
github.com/aws/aws-sdk-go-v2/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/endpoints/awsrulesfn from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 from github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints+
github.com/aws/aws-sdk-go-v2/internal/ini from github.com/aws/aws-sdk-go-v2/config
github.com/aws/aws-sdk-go-v2/internal/middleware from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/aws-sdk-go-v2/internal/rand from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/aws-sdk-go-v2/internal/sdk from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/aws-sdk-go-v2/internal/sdkio from github.com/aws/aws-sdk-go-v2/credentials/processcreds
github.com/aws/aws-sdk-go-v2/internal/shareddefaults from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/internal/strings from github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4
github.com/aws/aws-sdk-go-v2/internal/sync/singleflight from github.com/aws/aws-sdk-go-v2/aws
github.com/aws/aws-sdk-go-v2/internal/timeconv from github.com/aws/aws-sdk-go-v2/aws/retry
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/service/sso from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/service/sso/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sso
github.com/aws/aws-sdk-go-v2/service/sso/types from github.com/aws/aws-sdk-go-v2/service/sso
github.com/aws/aws-sdk-go-v2/service/ssooidc from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/service/ssooidc/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/ssooidc
github.com/aws/aws-sdk-go-v2/service/ssooidc/types from github.com/aws/aws-sdk-go-v2/service/ssooidc
github.com/aws/aws-sdk-go-v2/service/sts from github.com/aws/aws-sdk-go-v2/config+
github.com/aws/aws-sdk-go-v2/service/sts/internal/endpoints from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/aws-sdk-go-v2/service/sts/types from github.com/aws/aws-sdk-go-v2/credentials/stscreds+
github.com/aws/smithy-go from github.com/aws/aws-sdk-go-v2/aws/protocol/restjson+
github.com/aws/smithy-go/auth from github.com/aws/aws-sdk-go-v2/internal/auth+
github.com/aws/smithy-go/auth/bearer from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/context from github.com/aws/smithy-go/auth/bearer
github.com/aws/smithy-go/document from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/smithy-go/encoding from github.com/aws/smithy-go/encoding/json+
github.com/aws/smithy-go/encoding/httpbinding from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
github.com/aws/smithy-go/encoding/json from github.com/aws/aws-sdk-go-v2/service/ssooidc
github.com/aws/smithy-go/encoding/xml from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/smithy-go/endpoints from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/smithy-go/endpoints/private/rulesfn from github.com/aws/aws-sdk-go-v2/service/sts
github.com/aws/smithy-go/internal/sync/singleflight from github.com/aws/smithy-go/auth/bearer
github.com/aws/smithy-go/io from github.com/aws/aws-sdk-go-v2/feature/ec2/imds+
github.com/aws/smithy-go/logging from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/metrics from github.com/aws/aws-sdk-go-v2/aws/retry+
github.com/aws/smithy-go/middleware from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/private/requestcompression from github.com/aws/aws-sdk-go-v2/config
github.com/aws/smithy-go/ptr from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/rand from github.com/aws/aws-sdk-go-v2/aws/middleware
github.com/aws/smithy-go/time from github.com/aws/aws-sdk-go-v2/service/sso+
github.com/aws/smithy-go/tracing from github.com/aws/aws-sdk-go-v2/aws/middleware+
github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws+
github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
github.com/blang/semver/v4 from k8s.io/component-base/metrics
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus+
github.com/coder/websocket from tailscale.com/util/eventbus
github.com/coder/websocket/internal/errd from github.com/coder/websocket
github.com/coder/websocket/internal/util from github.com/coder/websocket
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
github.com/creachadair/msync/trigger from tailscale.com/logtail
💣 github.com/davecgh/go-spew/spew from k8s.io/apimachinery/pkg/util/dump
W 💣 github.com/dblohm7/wingoes from tailscale.com/net/tshttpproxy+
@@ -41,7 +113,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+
github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck+
github.com/go-logr/logr from github.com/go-logr/logr/slogr+
github.com/go-logr/logr/slogr from github.com/go-logr/zapr
github.com/go-logr/zapr from sigs.k8s.io/controller-runtime/pkg/log/zap+
@@ -59,7 +130,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/google/gnostic-models/jsonschema from github.com/google/gnostic-models/compiler
github.com/google/gnostic-models/openapiv2 from k8s.io/client-go/discovery+
github.com/google/gnostic-models/openapiv3 from k8s.io/kube-openapi/pkg/handler3+
github.com/google/uuid from k8s.io/apimachinery/pkg/util/uuid+
github.com/google/uuid from github.com/prometheus-community/pro-bing+
github.com/hdevalence/ed25519consensus from tailscale.com/tka
github.com/huin/goupnp from github.com/huin/goupnp/dcps/internetgateway2+
github.com/huin/goupnp/dcps/internetgateway2 from tailscale.com/net/portmapper
@@ -93,6 +164,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/pires/go-proxyproto from tailscale.com/ipn/ipnlocal+
github.com/pkg/errors from github.com/evanphx/json-patch/v5+
github.com/pmezard/go-difflib/difflib from k8s.io/apimachinery/pkg/util/diff
D github.com/prometheus-community/pro-bing from tailscale.com/wgengine/netstack
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil from github.com/prometheus/client_golang/prometheus/promhttp
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil/header from github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil
💣 github.com/prometheus/client_golang/prometheus from github.com/prometheus/client_golang/prometheus/collectors+
@@ -108,7 +180,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
L 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf
github.com/spf13/pflag from k8s.io/client-go/tools/clientcmd+
DW 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket
W 💣 github.com/tailscale/go-winio/internal/fs from github.com/tailscale/go-winio
W 💣 github.com/tailscale/go-winio/internal/socket from github.com/tailscale/go-winio
@@ -730,23 +802,22 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web+
tailscale.com/feature from tailscale.com/ipn/ipnext+
tailscale.com/feature/acme from tailscale.com/tsnet
tailscale.com/feature/buildfeatures from tailscale.com/wgengine/magicsock+
tailscale.com/feature/c2n from tailscale.com/tsnet
tailscale.com/feature/condlite/expvar from tailscale.com/wgengine/magicsock
tailscale.com/feature/condregister/netlog from tailscale.com/tsnet
tailscale.com/feature/condregister/identityfederation from tailscale.com/tsnet
tailscale.com/feature/condregister/oauthkey from tailscale.com/tsnet
tailscale.com/feature/condregister/portmapper from tailscale.com/tsnet
tailscale.com/feature/condregister/useproxy from tailscale.com/tsnet
tailscale.com/feature/netlog from tailscale.com/feature/condregister/netlog
tailscale.com/feature/identityfederation from tailscale.com/feature/condregister/identityfederation
tailscale.com/feature/oauthkey from tailscale.com/feature/condregister/oauthkey
tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper
tailscale.com/feature/serviceclientprefs/serviceclient from tailscale.com/client/local
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
tailscale.com/feature/useproxy from tailscale.com/feature/condregister/useproxy
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
tailscale.com/hostinfo from tailscale.com/client/web+
tailscale.com/internal/client/tailscale from tailscale.com/feature/oauthkey+
tailscale.com/internal/client/tailscale from tailscale.com/feature/identityfederation+
tailscale.com/ipn from tailscale.com/client/local+
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
@@ -755,18 +826,16 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/ipn/ipnlocal/netmapcache from tailscale.com/ipn/ipnlocal
tailscale.com/ipn/ipnstate from tailscale.com/client/local+
tailscale.com/ipn/localapi from tailscale.com/tsnet
tailscale.com/ipn/store from tailscale.com/ipn/store/kubestore+
tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/store/kubestore from tailscale.com/cmd/k8s-operator
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
tailscale.com/k8s-operator from tailscale.com/cmd/k8s-operator+
tailscale.com/k8s-operator/api-proxy from tailscale.com/cmd/k8s-operator
tailscale.com/k8s-operator/apis from tailscale.com/k8s-operator/apis/v1alpha1
tailscale.com/k8s-operator/apis/v1alpha1 from tailscale.com/cmd/k8s-operator+
tailscale.com/k8s-operator/reconciler from tailscale.com/k8s-operator/reconciler/tailnet+
tailscale.com/k8s-operator/reconciler/peerrelay from tailscale.com/cmd/k8s-operator
tailscale.com/k8s-operator/reconciler from tailscale.com/k8s-operator/reconciler/tailnet
tailscale.com/k8s-operator/reconciler/proxygrouppolicy from tailscale.com/cmd/k8s-operator
tailscale.com/k8s-operator/reconciler/tailnet from tailscale.com/cmd/k8s-operator
tailscale.com/k8s-operator/reconciler/tailscaled from tailscale.com/k8s-operator/reconciler/peerrelay
tailscale.com/k8s-operator/sessionrecording from tailscale.com/k8s-operator/api-proxy
tailscale.com/k8s-operator/sessionrecording/spdy from tailscale.com/k8s-operator/sessionrecording
tailscale.com/k8s-operator/sessionrecording/tsrecorder from tailscale.com/k8s-operator/sessionrecording+
@@ -787,6 +856,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/metrics from tailscale.com/tsweb+
tailscale.com/net/bakedroots from tailscale.com/net/tlsdial+
💣 tailscale.com/net/batching from tailscale.com/wgengine/magicsock
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dns/publicdns from tailscale.com/net/dns+
tailscale.com/net/dns/resolvconffile from tailscale.com/cmd/k8s-operator+
@@ -797,7 +867,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+
tailscale.com/net/memnet from tailscale.com/tsnet
tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock
tailscale.com/net/netcheck from tailscale.com/ipn/ipnlocal+
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal
tailscale.com/net/netknob from tailscale.com/logpolicy+
@@ -811,16 +881,12 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/net/portmapper from tailscale.com/feature/portmapper
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
tailscale.com/net/proxymux from tailscale.com/tsnet
tailscale.com/net/routecheck from tailscale.com/client/local+
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
tailscale.com/net/socks5 from tailscale.com/tsnet
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
tailscale.com/net/stun from tailscale.com/ipn/localapi+
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
tailscale.com/net/traffic from tailscale.com/ipn/ipnlocal+
tailscale.com/net/tsaddr from tailscale.com/client/web+
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
@@ -834,7 +900,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/sessionrecording from tailscale.com/k8s-operator/sessionrecording+
tailscale.com/syncs from tailscale.com/control/controlknobs+
tailscale.com/tailcfg from tailscale.com/client/local+
tailscale.com/tempfork/acme from tailscale.com/feature/acme
tailscale.com/tempfork/acme from tailscale.com/ipn/ipnlocal
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
tailscale.com/tempfork/httprec from tailscale.com/feature/c2n
tailscale.com/tka from tailscale.com/client/local+
@@ -844,7 +910,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/tstime from tailscale.com/cmd/k8s-operator+
tailscale.com/tstime/mono from tailscale.com/net/tstun+
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
tailscale.com/tsweb from tailscale.com/util/eventbus+
tailscale.com/tsweb from tailscale.com/util/eventbus
tailscale.com/tsweb/varz from tailscale.com/util/usermetric+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/bools from tailscale.com/tsnet+
@@ -856,7 +922,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/types/lazy from tailscale.com/ipn/ipnlocal+
tailscale.com/types/logger from tailscale.com/appc+
tailscale.com/types/logid from tailscale.com/ipn/ipnlocal+
tailscale.com/types/mapx from tailscale.com/ipn/ipnext+
tailscale.com/types/mapx from tailscale.com/ipn/ipnext
tailscale.com/types/netlogfunc from tailscale.com/net/tstun+
tailscale.com/types/netlogtype from tailscale.com/wgengine/netlog
tailscale.com/types/netmap from tailscale.com/control/controlclient+
@@ -878,7 +944,6 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
LW tailscale.com/util/cmpver from tailscale.com/net/dns+
tailscale.com/util/ctxkey from tailscale.com/client/tailscale/apitype+
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
tailscale.com/util/def from tailscale.com/ipn/localapi
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/appc+
tailscale.com/util/eventbus from tailscale.com/tsd+
@@ -901,15 +966,16 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/util/set from tailscale.com/cmd/k8s-operator+
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
tailscale.com/util/slicesx from tailscale.com/appc+
tailscale.com/util/syspolicy from tailscale.com/feature/syspolicy
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
tailscale.com/util/syspolicy/pkey from tailscale.com/control/controlclient+
tailscale.com/util/syspolicy/policyclient from tailscale.com/control/controlclient+
tailscale.com/util/syspolicy/ptype from tailscale.com/ipn/ipnlocal+
tailscale.com/util/syspolicy/rsop from tailscale.com/ipn/localapi
tailscale.com/util/syspolicy/setting from tailscale.com/client/local+
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy/rsop
tailscale.com/util/syspolicy/ptype from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
tailscale.com/util/testenv from tailscale.com/control/controlclient+
tailscale.com/util/truncate from tailscale.com/logtail
tailscale.com/util/usermetric from tailscale.com/health+
@@ -926,13 +992,15 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/wgengine/filter from tailscale.com/control/controlclient+
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap+
💣 tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/netlog from tailscale.com/feature/netlog
tailscale.com/wgengine/netlog from tailscale.com/wgengine
tailscale.com/wgengine/netstack from tailscale.com/tsnet
tailscale.com/wgengine/netstack/gro from tailscale.com/net/tstun+
tailscale.com/wgengine/router from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine+
tailscale.com/wgengine/wglog from tailscale.com/wgengine
tailscale.com/wif from tailscale.com/feature/identityfederation
golang.org/x/crypto/argon2 from tailscale.com/tka
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
golang.org/x/crypto/blake2s from github.com/tailscale/wireguard-go/device+
@@ -955,20 +1023,19 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
golang.org/x/net/http/httpproxy from tailscale.com/net/tshttpproxy
golang.org/x/net/http2 from k8s.io/apimachinery/pkg/util/net+
golang.org/x/net/http2/hpack from golang.org/x/net/http2+
golang.org/x/net/icmp from tailscale.com/net/ping
golang.org/x/net/icmp from github.com/prometheus-community/pro-bing+
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
golang.org/x/net/internal/httpcommon from golang.org/x/net/http2
golang.org/x/net/internal/httpsfv from golang.org/x/net/http2
golang.org/x/net/internal/iana from golang.org/x/net/icmp+
golang.org/x/net/internal/socket from golang.org/x/net/ipv4+
golang.org/x/net/internal/socket from golang.org/x/net/icmp+
golang.org/x/net/internal/socks from golang.org/x/net/proxy
golang.org/x/net/ipv4 from github.com/tailscale/wireguard-go/conn+
golang.org/x/net/ipv6 from github.com/tailscale/wireguard-go/conn+
golang.org/x/net/ipv4 from github.com/prometheus-community/pro-bing+
golang.org/x/net/ipv6 from github.com/prometheus-community/pro-bing+
golang.org/x/net/proxy from tailscale.com/net/netns
D golang.org/x/net/route from tailscale.com/net/netmon+
golang.org/x/net/websocket from tailscale.com/k8s-operator/sessionrecording/ws
golang.org/x/oauth2 from golang.org/x/oauth2/clientcredentials+
golang.org/x/oauth2/clientcredentials from tailscale.com/client/tailscale/v2+
golang.org/x/oauth2/clientcredentials from tailscale.com/cmd/k8s-operator+
golang.org/x/oauth2/internal from golang.org/x/oauth2+
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
golang.org/x/sys/cpu from github.com/tailscale/certstore+
@@ -1025,22 +1092,22 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
crypto/internal/boring/bbig from crypto/ecdsa+
crypto/internal/boring/sig from crypto/internal/boring
crypto/internal/constanttime from crypto/internal/fips140/edwards25519+
crypto/internal/fips140 from crypto/fips140+
crypto/internal/fips140 from crypto/internal/fips140/aes+
crypto/internal/fips140/aes from crypto/aes+
crypto/internal/fips140/aes/gcm from crypto/cipher+
crypto/internal/fips140/alias from crypto/cipher+
crypto/internal/fips140/bigmod from crypto/internal/fips140/ecdsa+
crypto/internal/fips140/check from crypto/fips140+
crypto/internal/fips140/drbg from crypto/hpke+
crypto/internal/fips140/check from crypto/internal/fips140/aes+
crypto/internal/fips140/drbg from crypto/internal/fips140/aes/gcm+
crypto/internal/fips140/ecdh from crypto/ecdh
crypto/internal/fips140/ecdsa from crypto/ecdsa
crypto/internal/fips140/ed25519 from crypto/ed25519
crypto/internal/fips140/edwards25519 from crypto/internal/fips140/ed25519
crypto/internal/fips140/edwards25519/field from crypto/ecdh+
crypto/internal/fips140/hkdf from crypto/hkdf+
crypto/internal/fips140/hkdf from crypto/internal/fips140/tls13+
crypto/internal/fips140/hmac from crypto/hmac+
crypto/internal/fips140/mlkem from crypto/mlkem
crypto/internal/fips140/nistec from crypto/ecdsa+
crypto/internal/fips140/nistec from crypto/elliptic+
crypto/internal/fips140/nistec/fiat from crypto/internal/fips140/nistec
crypto/internal/fips140/rsa from crypto/rsa
crypto/internal/fips140/sha256 from crypto/internal/fips140/check+
@@ -1070,7 +1137,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
crypto/sha3 from crypto/internal/fips140hash+
crypto/sha512 from crypto/ecdsa+
crypto/subtle from crypto/cipher+
crypto/tls from github.com/prometheus/client_golang/prometheus/promhttp+
crypto/tls from github.com/prometheus-community/pro-bing+
crypto/tls/internal/fips140tls from crypto/tls
crypto/x509 from crypto/tls+
D crypto/x509/internal/macos from crypto/x509
@@ -1105,7 +1172,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
hash from compress/zlib+
hash/adler32 from compress/zlib
hash/crc32 from compress/gzip+
hash/fnv from google.golang.org/protobuf/internal/detrand+
hash/fnv from google.golang.org/protobuf/internal/detrand
hash/maphash from go4.org/mem
html from html/template+
html/template from tailscale.com/util/eventbus
@@ -1120,14 +1187,14 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
internal/filepathlite from os+
internal/fmtsort from fmt+
internal/goarch from crypto/internal/fips140deps/cpu+
internal/godebug from crypto/ed25519+
internal/godebug from crypto/internal/fips140deps/godebug+
internal/godebugs from internal/godebug+
internal/goexperiment from net/http/pprof+
internal/goos from crypto/x509+
internal/lazyregexp from go/doc
internal/msan from internal/runtime/maps+
internal/nettrace from net+
internal/oserror from internal/syscall/windows+
internal/oserror from io/fs+
internal/poll from net+
internal/profile from net/http/pprof
internal/profilerecord from runtime+
@@ -1137,9 +1204,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
internal/runtime/atomic from internal/runtime/exithook+
L internal/runtime/cgroup from runtime
internal/runtime/exithook from runtime
internal/runtime/gc from internal/runtime/gc/scan+
internal/runtime/gc from runtime+
internal/runtime/gc/scan from runtime
internal/runtime/maps from hash/maphash+
internal/runtime/maps from reflect+
internal/runtime/math from internal/runtime/maps+
internal/runtime/pprof/label from runtime+
internal/runtime/sys from crypto/subtle+
@@ -1153,7 +1220,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
internal/synctest from sync
internal/syscall/execenv from os+
LD internal/syscall/unix from crypto/internal/sysrand+
W internal/syscall/windows from crypto/internal/fips140deps/time+
W internal/syscall/windows from crypto/internal/sysrand+
W internal/syscall/windows/registry from mime+
W internal/syscall/windows/sysdll from internal/syscall/windows+
internal/testlog from os
@@ -1161,7 +1228,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
internal/unsafeheader from internal/reflectlite+
io from bufio+
io/fs from crypto/x509+
io/ioutil from github.com/google/gnostic-models/compiler+
io/ioutil from github.com/godbus/dbus/v5+
iter from go/ast+
log from expvar+
log/internal from log+
@@ -1179,7 +1246,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
mime/quotedprintable from mime/multipart
net from crypto/tls+
net/http from expvar+
net/http/httptrace from github.com/prometheus/client_golang/prometheus/promhttp+
net/http/httptrace from github.com/prometheus-community/pro-bing+
net/http/httputil from tailscale.com/client/web+
net/http/internal from net/http+
net/http/internal/ascii from net/http+
@@ -1198,7 +1265,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
regexp from github.com/davecgh/go-spew/spew+
regexp/syntax from regexp
runtime from crypto/internal/fips140+
runtime/debug from github.com/klauspost/compress/zstd+
runtime/debug from github.com/coder/websocket/internal/xsync+
runtime/metrics from github.com/prometheus/client_golang/prometheus+
runtime/pprof from net/http/pprof+
runtime/trace from net/http/pprof
@@ -10,4 +10,3 @@
/recorder.yaml
/tailnet.yaml
/proxygrouppolicy.yaml
/peerrelay.yaml
@@ -6,9 +6,6 @@ kind: Deployment
metadata:
name: operator
namespace: {{ .Release.Namespace }}
{{- if .Values.annotations }}
annotations: {{- toYaml .Values.annotations | nindent 4 }}
{{- end }}
spec:
replicas: 1
strategy:
@@ -81,10 +78,6 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: OPERATOR_SERVICE_ACCOUNT_NAME
valueFrom:
fieldRef:
fieldPath: spec.serviceAccountName
- name: OPERATOR_LOGIN_SERVER
value: {{ .Values.loginServer }}
- name: OPERATOR_INGRESS_CLASS_NAME
@@ -124,8 +117,6 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.uid
- name: OPERATOR_SHARED_ACME_ACCOUNT_KEY
value: {{ .Values.operatorConfig.sharedACMEAccountKey | quote }}
{{- with .Values.operatorConfig.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -155,6 +146,3 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.operatorConfig.priorityClassName }}
priorityClassName: {{ . }}
{{- end }}
@@ -40,9 +40,6 @@ rules:
- apiGroups: ["tailscale.com"]
resources: ["tailnets", "tailnets/status"]
verbs: ["get", "list", "watch", "update"]
- apiGroups: ["tailscale.com"]
resources: ["peerrelays", "peerrelays/status"]
verbs: ["get", "list", "watch", "update"]
- apiGroups: ["tailscale.com"]
resources: ["proxygrouppolicies", "proxygrouppolicies/status"]
verbs: ["get", "list", "watch", "update"]
@@ -79,10 +76,6 @@ rules:
- apiGroups: [""]
resources: ["secrets", "serviceaccounts", "configmaps"]
verbs: ["create","delete","deletecollection","get","list","patch","update","watch"]
- apiGroups: [""]
resources: ["serviceaccounts/token"]
resourceNames: ["operator"]
verbs: ["create"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get","list","watch", "update"]
-12
View File
@@ -62,9 +62,6 @@ operatorConfig:
resources: {}
# Specifies annotations for deployment
annotations: {}
podAnnotations: {}
podLabels: {}
@@ -75,8 +72,6 @@ operatorConfig:
affinity: {}
priorityClassName: ""
podSecurityContext: {}
securityContext: {}
@@ -87,13 +82,6 @@ operatorConfig:
# - name: EXTRA_VAR2
# value: "value2"
# Default for the tailscale.com/share-acme-account annotation on new
# ProxyGroups. When true, the operator provisions a shared per-tailnet
# ACME account key Secret and configures proxies to use it, preserving
# Let's Encrypt's ARI "replaces" renewal exemption across pod restarts
# and ProxyGroup recreation. See #18251.
sharedACMEAccountKey: false
# In the case that you already have a tailscale ingressclass in your cluster (or vcluster), you can disable the creation here
ingressClass:
# Allows for customization of the ingress class name used by the operator to identify ingresses to reconcile. This does
@@ -104,884 +104,6 @@ spec:
description: Pod configuration.
type: object
properties:
affinity:
description: If specified, applies affinity rules to the pods deployed by the DNSConfig resource.
type: object
properties:
nodeAffinity:
description: Describes node affinity scheduling rules for the pod.
type: object
properties:
preferredDuringSchedulingIgnoredDuringExecution:
description: |-
The scheduler will prefer to schedule pods to nodes that satisfy
the affinity expressions specified by this field, but it may choose
a node that violates one or more of the expressions. The node that is
most preferred is the one with the greatest sum of weights, i.e.
for each node that meets all of the scheduling requirements (resource
request, requiredDuringScheduling affinity expressions, etc.),
compute a sum by iterating through the elements of this field and adding
"weight" to the sum if the node matches the corresponding matchExpressions; the
node(s) with the highest sum are the most preferred.
type: array
items:
description: |-
An empty preferred scheduling term matches all objects with implicit weight 0
(i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).
type: object
required:
- preference
- weight
properties:
preference:
description: A node selector term, associated with the corresponding weight.
type: object
properties:
matchExpressions:
description: A list of node selector requirements by node's labels.
type: array
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchFields:
description: A list of node selector requirements by node's fields.
type: array
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
x-kubernetes-map-type: atomic
weight:
description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.
type: integer
format: int32
x-kubernetes-list-type: atomic
requiredDuringSchedulingIgnoredDuringExecution:
description: |-
If the affinity requirements specified by this field are not met at
scheduling time, the pod will not be scheduled onto the node.
If the affinity requirements specified by this field cease to be met
at some point during pod execution (e.g. due to an update), the system
may or may not try to eventually evict the pod from its node.
type: object
required:
- nodeSelectorTerms
properties:
nodeSelectorTerms:
description: Required. A list of node selector terms. The terms are ORed.
type: array
items:
description: |-
A null or empty node selector term matches no objects. The requirements of
them are ANDed.
The TopologySelectorTerm type implements a subset of the NodeSelectorTerm.
type: object
properties:
matchExpressions:
description: A list of node selector requirements by node's labels.
type: array
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchFields:
description: A list of node selector requirements by node's fields.
type: array
items:
description: |-
A node selector requirement is a selector that contains values, a key, and an operator
that relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: The label key that the selector applies to.
type: string
operator:
description: |-
Represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
type: string
values:
description: |-
An array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. If the operator is Gt or Lt, the values
array must have a single element, which will be interpreted as an integer.
This array is replaced during a strategic merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
x-kubernetes-map-type: atomic
x-kubernetes-list-type: atomic
x-kubernetes-map-type: atomic
podAffinity:
description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).
type: object
properties:
preferredDuringSchedulingIgnoredDuringExecution:
description: |-
The scheduler will prefer to schedule pods to nodes that satisfy
the affinity expressions specified by this field, but it may choose
a node that violates one or more of the expressions. The node that is
most preferred is the one with the greatest sum of weights, i.e.
for each node that meets all of the scheduling requirements (resource
request, requiredDuringScheduling affinity expressions, etc.),
compute a sum by iterating through the elements of this field and adding
"weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the
node(s) with the highest sum are the most preferred.
type: array
items:
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
type: object
required:
- podAffinityTerm
- weight
properties:
podAffinityTerm:
description: Required. A pod affinity term, associated with the corresponding weight.
type: object
required:
- topologyKey
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
type: array
items:
type: string
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
weight:
description: |-
weight associated with matching the corresponding podAffinityTerm,
in the range 1-100.
type: integer
format: int32
x-kubernetes-list-type: atomic
requiredDuringSchedulingIgnoredDuringExecution:
description: |-
If the affinity requirements specified by this field are not met at
scheduling time, the pod will not be scheduled onto the node.
If the affinity requirements specified by this field cease to be met
at some point during pod execution (e.g. due to a pod label update), the
system may or may not try to eventually evict the pod from its node.
When there are multiple elements, the lists of nodes corresponding to each
podAffinityTerm are intersected, i.e. all terms must be satisfied.
type: array
items:
description: |-
Defines a set of pods (namely those matching the labelSelector
relative to the given namespace(s)) that this pod should be
co-located (affinity) or not co-located (anti-affinity) with,
where co-located is defined as running on a node whose value of
the label with key <topologyKey> matches that of any node on which
a pod of the set of pods is running
type: object
required:
- topologyKey
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
type: array
items:
type: string
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
x-kubernetes-list-type: atomic
podAntiAffinity:
description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).
type: object
properties:
preferredDuringSchedulingIgnoredDuringExecution:
description: |-
The scheduler will prefer to schedule pods to nodes that satisfy
the anti-affinity expressions specified by this field, but it may choose
a node that violates one or more of the expressions. The node that is
most preferred is the one with the greatest sum of weights, i.e.
for each node that meets all of the scheduling requirements (resource
request, requiredDuringScheduling anti-affinity expressions, etc.),
compute a sum by iterating through the elements of this field and subtracting
"weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the
node(s) with the highest sum are the most preferred.
type: array
items:
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
type: object
required:
- podAffinityTerm
- weight
properties:
podAffinityTerm:
description: Required. A pod affinity term, associated with the corresponding weight.
type: object
required:
- topologyKey
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
type: array
items:
type: string
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
weight:
description: |-
weight associated with matching the corresponding podAffinityTerm,
in the range 1-100.
type: integer
format: int32
x-kubernetes-list-type: atomic
requiredDuringSchedulingIgnoredDuringExecution:
description: |-
If the anti-affinity requirements specified by this field are not met at
scheduling time, the pod will not be scheduled onto the node.
If the anti-affinity requirements specified by this field cease to be met
at some point during pod execution (e.g. due to a pod label update), the
system may or may not try to eventually evict the pod from its node.
When there are multiple elements, the lists of nodes corresponding to each
podAffinityTerm are intersected, i.e. all terms must be satisfied.
type: array
items:
description: |-
Defines a set of pods (namely those matching the labelSelector
relative to the given namespace(s)) that this pod should be
co-located (affinity) or not co-located (anti-affinity) with,
where co-located is defined as running on a node whose value of
the label with key <topologyKey> matches that of any node on which
a pod of the set of pods is running
type: object
required:
- topologyKey
properties:
labelSelector:
description: |-
A label query over a set of resources, in this case pods.
If it's null, this PodAffinityTerm matches with no Pods.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
matchLabelKeys:
description: |-
MatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both matchLabelKeys and labelSelector.
Also, matchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
mismatchLabelKeys:
description: |-
MismatchLabelKeys is a set of pod label keys to select which pods will
be taken into consideration. The keys are used to lookup values from the
incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`
to select the group of existing pods which pods will be taken into consideration
for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming
pod labels will be ignored. The default value is empty.
The same key is forbidden to exist in both mismatchLabelKeys and labelSelector.
Also, mismatchLabelKeys cannot be set when labelSelector isn't set.
type: array
items:
type: string
x-kubernetes-list-type: atomic
namespaceSelector:
description: |-
A label query over the set of namespaces that the term applies to.
The term is applied to the union of the namespaces selected by this field
and the ones listed in the namespaces field.
null selector and null or empty namespaces list means "this pod's namespace".
An empty selector ({}) matches all namespaces.
type: object
properties:
matchExpressions:
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
type: array
items:
description: |-
A label selector requirement is a selector that contains values, a key, and an operator that
relates the key and values.
type: object
required:
- key
- operator
properties:
key:
description: key is the label key that the selector applies to.
type: string
operator:
description: |-
operator represents a key's relationship to a set of values.
Valid operators are In, NotIn, Exists and DoesNotExist.
type: string
values:
description: |-
values is an array of string values. If the operator is In or NotIn,
the values array must be non-empty. If the operator is Exists or DoesNotExist,
the values array must be empty. This array is replaced during a strategic
merge patch.
type: array
items:
type: string
x-kubernetes-list-type: atomic
x-kubernetes-list-type: atomic
matchLabels:
description: |-
matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
map is equivalent to an element of matchExpressions, whose key field is "key", the
operator is "In", and the values array contains only "value". The requirements are ANDed.
type: object
additionalProperties:
type: string
x-kubernetes-map-type: atomic
namespaces:
description: |-
namespaces specifies a static list of namespace names that the term applies to.
The term is applied to the union of the namespaces listed in this field
and the ones selected by namespaceSelector.
null or empty namespaces list and null namespaceSelector means "this pod's namespace".
type: array
items:
type: string
x-kubernetes-list-type: atomic
topologyKey:
description: |-
This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching
the labelSelector in the specified namespaces, where co-located is defined as running on a node
whose value of the label with key topologyKey matches that of any node on which any of the
selected pods is running.
Empty topologyKey is not allowed.
type: string
x-kubernetes-list-type: atomic
nodeSelector:
description: If specified, applies node selector rules to the pods deployed by the DNSConfig resource.
type: object
additionalProperties:
type: string
tolerations:
description: If specified, applies tolerations to the pods deployed by the DNSConfig resource.
type: array
@@ -1,264 +0,0 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.17.0
name: peerrelays.tailscale.com
spec:
group: tailscale.com
names:
kind: PeerRelay
listKind: PeerRelayList
plural: peerrelays
shortNames:
- pr
singular: peerrelay
scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
- description: Status of the deployed PeerRelay resources.
jsonPath: .status.conditions[?(@.type == "PeerRelayReady")].reason
name: Status
type: string
- description: Public addresses the peer relay replicas are reachable on.
jsonPath: .status.endpoints[*].address
name: Endpoints
type: string
name: v1alpha1
schema:
openAPIV3Schema:
type: object
required:
- metadata
- spec
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: |-
Spec describes the desired state of the PeerRelay.
More info:
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
type: object
properties:
aws:
description: |-
AWS contains configuration for pinning each replica to a specific AWS Elastic IP and subnet. Only meaningful
when running on EKS with the AWS Load Balancer Controller. When set, the per-replica values override any
aws-load-balancer-eip-allocations or aws-load-balancer-subnets values supplied via spec.service.annotations.
type: object
required:
- elasticIPs
properties:
elasticIPs:
description: |-
ElasticIPs pins each replica to a specific AWS EIP allocation and subnet. Only meaningful when Network Load
Balancers are provisioned by the AWS Load Balancer Controller. ElasticIPs supplies one allocation-subnet pair
per replica: replica N uses ElasticIPs[N]. The list must be at least as long as spec.replicas so every replica
has a distinct EIP; extra entries are permitted so that scale-up doesn't immediately trip validation.
When set, the reconciler stamps
service.beta.kubernetes.io/aws-load-balancer-eip-allocations and
service.beta.kubernetes.io/aws-load-balancer-subnets on each per-replica Service, overriding any values in
spec.service.annotations.
type: array
minItems: 1
items:
description: PeerRelayAWSElasticIP pairs an EIP allocation with the subnet in the same AZ.
type: object
required:
- allocationID
- subnetID
properties:
allocationID:
description: |-
AllocationID is the AWS EIP allocation ID (e.g. eipalloc-0123abcd) whose public IP this replica is reachable
on. Stamped as service.beta.kubernetes.io/aws-load-balancer-eip-allocations on the replica's Service.
type: string
pattern: ^eipalloc-[0-9a-f]+$
subnetID:
description: |-
SubnetID is the AWS subnet in the same availability zone as AllocationID (e.g. subnet-0123abcd). Stamped as
service.beta.kubernetes.io/aws-load-balancer-subnets on the replica's Service so the NLB is provisioned in
the same AZ as the EIP.
type: string
pattern: ^subnet-[0-9a-f]+$
x-kubernetes-list-type: atomic
hostnamePrefix:
description: |-
HostnamePrefix specifies the hostname prefix for each
replica. Each device will have the integer number
from its StatefulSet pod appended to this prefix to form the full hostname.
HostnamePrefix can contain lower case letters, numbers and dashes, it
must not start with a dash and must be between 1 and 62 characters long.
type: string
pattern: ^[a-z0-9][a-z0-9-]{0,61}$
proxyClass:
description: |-
ProxyClass is the name of the ProxyClass custom resource that
contains configuration options that should be applied to the
resources created for this PeerRelay. If unset, the operator will
create resources with the default configuration.
type: string
replicas:
description: |-
Replicas specifies how many devices to create. Set this to enable
high availability for peer relays.
https://tailscale.com/kb/1115/high-availability. Defaults to 1.
type: integer
format: int32
default: 1
minimum: 0
service:
description: Service contains configuration values to modify the LoadBalancer service used to expose the peer relay.
type: object
properties:
annotations:
description: |-
Annotations to apply to the LoadBalancer service. Any annotations that conflict with those used by known
cloud providers to ensure IP addresses rather than DNS names are ignored.
type: object
additionalProperties:
type: string
tags:
description: |-
Tags that the Tailscale node will be tagged with.
Defaults to [tag:k8s].
To autoapprove the device defined by a PeerRelay,
you can configure Tailscale ACLs to give these tags the necessary
permissions.
See https://tailscale.com/kb/1337/acl-syntax#autoapprovers.
If you specify custom tags here, you must also make the operator an owner of these tags.
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
Tags cannot be changed once a PeerRelay node has been created.
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
type: array
items:
type: string
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
tailnet:
description: |-
Tailnet specifies the tailnet this PeerRelay should join. If blank, the default tailnet is used. When set, this
name must match that of a valid Tailnet resource. This field is immutable and cannot be changed once set.
type: string
x-kubernetes-validations:
- rule: self == oldSelf
message: PeerRelay tailnet is immutable
x-kubernetes-validations:
- rule: '!has(self.aws) || !has(self.aws.elasticIPs) || self.aws.elasticIPs.size() >= self.replicas'
message: spec.aws.elasticIPs must contain at least one entry per replica
status:
description: |-
Status describes the status of the PeerRelay. This is set
and managed by the Tailscale operator.
type: object
properties:
conditions:
type: array
items:
description: Condition contains details for one aspect of the current state of this API Resource.
type: object
required:
- lastTransitionTime
- message
- reason
- status
- type
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
type: string
format: date-time
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
type: string
maxLength: 32768
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
type: integer
format: int64
minimum: 0
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
type: string
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
status:
description: status of the condition, one of True, False, Unknown.
type: string
enum:
- "True"
- "False"
- Unknown
type:
description: type of condition in CamelCase or in foo.example.com/CamelCase.
type: string
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
endpoints:
description: |-
Endpoints lists the public address:port pairs each peer relay replica is reachable on. There is one entry
per replica whose LoadBalancer Service has been assigned a public address; entries appear as the underlying
cloud provisions each Service.
type: array
items:
type: object
required:
- address
- port
- replica
properties:
address:
description: |-
Address is the public IP or hostname the cloud has allocated for this replica's LoadBalancer Service.
Peers reach this relay by connecting to Address:Port over UDP.
type: string
port:
description: Port is the UDP port the peer relay listens on.
type: integer
format: int32
replica:
description: Replica is the zero-based index of the peer relay replica this endpoint targets.
type: integer
format: int32
x-kubernetes-list-map-keys:
- replica
x-kubernetes-list-type: map
served: true
storage: true
subresources:
status: {}
@@ -58,18 +58,15 @@ spec:
- credentials
properties:
credentials:
description: Denotes the location of the credentials to use for authenticating with this Tailnet.
description: Denotes the location of the OAuth credentials to use for authenticating with this Tailnet.
type: object
required:
- secretName
properties:
secretName:
description: |-
The name of the secret containing the credentials used to authenticate with this Tailnet. The secret must always
contain a "client_id" field. To authenticate with a static OAuth client, also set "client_secret". To authenticate
via workload identity federation, set "audience" to the audience value expected by the Tailscale OAuth
client; the operator will mint a ServiceAccount token for itself with that audience and exchange it for an API
token. "client_secret" and "audience" are mutually exclusive.
The name of the secret containing the OAuth credentials. This secret must contain two fields "client_id" and
"client_secret".
type: string
loginUrl:
description: URL of the control plane to be used by all resources managed by the operator using this Tailnet.
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More