This commit is contained in:
bluepython508 committed 2026-10-22 12:27:19 +01:00
1 parent 3530afb854
commit 6903fcff27
2146 files changed
+112256 -190703

No files matched your search

+60 -2
View File
@@ -1,2 +1,60 @@
go.mod filter=go-mod
*.go diff=golang
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
+9 -1
View File
@@ -1,12 +1,15 @@
# 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
@@ -52,3 +55,8 @@ client/web/build/assets
# Ignore personal IntelliJ settings
.idea/
# Ignore syncthing state directory.
/.stfolder
fbstatus
gafpush
+6
View File
@@ -10,9 +10,15 @@ linters:
enable:
- bidichk
- govet
- importas
- misspell
- revive
settings:
importas:
no-unaliased: true
alias:
- pkg: github.com/tailscale/gliderssh
alias: gliderssh
# Matches what we use in corp as of 2023-12-07
govet:
enable:
+258
View File
@@ -0,0 +1,258 @@
# 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
@@ -0,0 +1,84 @@
# 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"
-17
View File
@@ -1,17 +0,0 @@
# This is the official list of Tailscale
# authors for copyright purposes.
#
# Names should be added to this file as one of
# Organization's name
# Individual's name <submission email address>
# Individual's name <submission email address> <email2> <emailN>
#
# Please keep the list sorted.
#
# You do not need to add entries to this list, and we don't actively
# populate this list. If you do want to be acknowledged explicitly as
# a copyright holder, though, then please send a PR referencing your
# earlier contributions and clarifying whether it's you or your
# company that owns the rights to your contribution.
Tailscale Inc.
+7 -1
View File
@@ -1 +1,7 @@
/tailcfg/ @tailscale/control-protocol-owners
# 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.
+26 -2
View File
@@ -1,4 +1,4 @@
# Copyright (c) Tailscale Inc & AUTHORS
# Copyright (c) Tailscale Inc & contributors
# SPDX-License-Identifier: BSD-3-Clause
# Note that this Dockerfile is currently NOT used to build any of the published
@@ -36,10 +36,34 @@
# $ docker exec tailscaled tailscale status
FROM golang:1.25-alpine AS build-env
# Use Tailscale's Go toolchain (a fork of Go) as specified by the
# go.toolchain.rev file, matching how everything else in this repo is
# built, rather than the golang Docker image. This avoids this
# Dockerfile breaking for a day or two after each Go minor version
# bump, when go.mod requires a Go version that the official golang
# Docker image doesn't yet ship. See
# https://github.com/tailscale/tailscale/issues/21072
FROM alpine:3.22 AS build-env
WORKDIR /go/src/tailscale
RUN apk add --no-cache curl tar
COPY go.toolchain.rev ./
RUN read -r REV <go.toolchain.rev && \
case "$(apk --print-arch)" in \
x86_64) ARCH=amd64 ;; \
aarch64) ARCH=arm64 ;; \
*) echo "unsupported architecture" >&2; exit 1 ;; \
esac && \
curl --retry 3 -f -L -o /tmp/go.tar.gz "https://github.com/tailscale/go/releases/download/build-${REV}/linux-${ARCH}.tar.gz" && \
mkdir -p /usr/local/go && \
tar -C /usr/local/go --strip-components=1 -xzf /tmp/go.tar.gz && \
rm /tmp/go.tar.gz
ENV GOPATH=/go
ENV PATH=/usr/local/go/bin:$GOPATH/bin:$PATH
COPY go.mod go.sum ./
RUN go mod download
+1 -1
View File
@@ -1,4 +1,4 @@
# Copyright (c) Tailscale Inc & AUTHORS
# Copyright (c) Tailscale Inc & contributors
# SPDX-License-Identifier: BSD-3-Clause
FROM alpine:3.22
+1 -1
View File
@@ -1,6 +1,6 @@
BSD 3-Clause License
Copyright (c) 2020 Tailscale Inc & AUTHORS.
Copyright (c) 2020 Tailscale Inc & contributors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
+57 -6
View File
@@ -10,10 +10,10 @@ vet: ## Run go vet
tidy: ## Run go mod tidy and update nix flake hashes
./tool/go mod tidy
./update-flake.sh
./tool/go run ./tool/updateflakes
lint: ## Run golangci-lint
./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run
./tool/go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint run
updatedeps: ## Update depaware deps
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
@@ -137,15 +137,66 @@ 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" && 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
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
.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
@@ -37,7 +37,7 @@ not open source.
## Building
We always require the latest Go release, currently Go 1.25. (While we build
We always require the latest Go release, currently Go 1.27. (While we build
releases with our [Go fork](https://github.com/tailscale/go/), its use is not
required.)
+1 -1
View File
@@ -1 +1 @@
1.94.1
1.104.1
+14 -5
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package appc implements App Connectors.
@@ -26,6 +26,7 @@ import (
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus"
"tailscale.com/util/execqueue"
"tailscale.com/util/set"
"tailscale.com/util/slicesx"
)
@@ -364,18 +365,20 @@ func (e *AppConnector) updateRoutes(routes []netip.Prefix) {
toRemove = routesWithout(e.controlRoutes, routes)
}
nextRoute:
obsoleted := set.Set[netip.Addr]{}
for _, r := range routes {
for _, addr := range e.domains {
for _, a := range addr {
if r.Contains(a) && netip.PrefixFrom(a, a.BitLen()) != r {
pfx := netip.PrefixFrom(a, a.BitLen())
toRemove = append(toRemove, pfx)
continue nextRoute
obsoleted.Add(a)
}
}
}
}
for a := range obsoleted.All() {
pfx := netip.PrefixFrom(a, a.BitLen())
toRemove = append(toRemove, pfx)
}
if e.routeAdvertiser != nil {
e.queue.Add(func() {
@@ -425,6 +428,7 @@ func (e *AppConnector) DomainRoutes() map[string][]netip.Addr {
// e.mu must be held.
func (e *AppConnector) findRoutedDomainLocked(domain string, cnameChain map[string]string) (string, bool) {
var isRouted bool
var seen set.Set[string]
for {
_, isRouted = e.domains[domain]
if isRouted {
@@ -444,7 +448,12 @@ func (e *AppConnector) findRoutedDomainLocked(domain string, cnameChain map[stri
if !ok {
break
}
if seen.Contains(next) {
break
}
domain = next
seen.Make()
seen.Add(domain)
}
return domain, isRouted
}
+31 -95
View File
@@ -1,110 +1,46 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package appc
import (
"net/netip"
"sync"
"fmt"
"strings"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/types/appctype"
"tailscale.com/types/dnstype"
)
// Conn25 holds the developing state for the as yet nascent next generation app connector.
// There is currently (2025-12-08) no actual app connecting functionality.
type Conn25 struct {
mu sync.Mutex
transitIPs map[tailcfg.NodeID]map[netip.Addr]netip.Addr
}
const AppConnectorsExperimentalAttrName = "tailscale.com/app-connectors-experimental"
const dupeTransitIPMessage = "Duplicate transit address in ConnectorTransitIPRequest"
// 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"
// HandleConnectorTransitIPRequest creates a ConnectorTransitIPResponse in response to a ConnectorTransitIPRequest.
// It updates the connectors mapping of TransitIP->DestinationIP per peer (tailcfg.NodeID).
// If a peer has stored this mapping in the connector Conn25 will route traffic to TransitIPs to DestinationIPs for that peer.
func (c *Conn25) HandleConnectorTransitIPRequest(nid tailcfg.NodeID, ctipr ConnectorTransitIPRequest) ConnectorTransitIPResponse {
resp := ConnectorTransitIPResponse{}
seen := map[netip.Addr]bool{}
for _, each := range ctipr.TransitIPs {
if seen[each.TransitIP] {
resp.TransitIPs = append(resp.TransitIPs, TransitIPResponse{
Code: OtherFailure,
Message: dupeTransitIPMessage,
})
continue
func AppDNSRoutes(hasCap func(c nodecap.Cap) bool, self tailcfg.NodeView) map[string][]*dnstype.Resolver {
if !hasCap(AppConnectorsExperimentalAttrName) {
return nil
}
apps, err := tailcfg.UnmarshalNodeCapViewJSON[appctype.AppConnectorAttr](self.CapMap(), AppConnectorsExperimentalAttrName)
if err != nil {
return nil
}
appNamesByDomain := 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
}
tipresp := c.handleTransitIPRequest(nid, each)
seen[each.TransitIP] = true
resp.TransitIPs = append(resp.TransitIPs, tipresp)
}
return resp
}
func (c *Conn25) handleTransitIPRequest(nid tailcfg.NodeID, tipr TransitIPRequest) TransitIPResponse {
c.mu.Lock()
defer c.mu.Unlock()
if c.transitIPs == nil {
c.transitIPs = make(map[tailcfg.NodeID]map[netip.Addr]netip.Addr)
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}}
}
peerMap, ok := c.transitIPs[nid]
if !ok {
peerMap = make(map[netip.Addr]netip.Addr)
c.transitIPs[nid] = peerMap
}
peerMap[tipr.TransitIP] = tipr.DestinationIP
return TransitIPResponse{}
}
func (c *Conn25) transitIPTarget(nid tailcfg.NodeID, tip netip.Addr) netip.Addr {
c.mu.Lock()
defer c.mu.Unlock()
return c.transitIPs[nid][tip]
}
// TransitIPRequest details a single TransitIP allocation request from a client to a
// connector.
type TransitIPRequest struct {
// TransitIP is the intermediate destination IP that will be received at this
// connector and will be replaced by DestinationIP when performing DNAT.
TransitIP netip.Addr `json:"transitIP,omitzero"`
// DestinationIP is the final destination IP that connections to the TransitIP
// should be mapped to when performing DNAT.
DestinationIP netip.Addr `json:"destinationIP,omitzero"`
}
// ConnectorTransitIPRequest is the request body for a PeerAPI request to
// /connector/transit-ip and can include zero or more TransitIP allocation requests.
type ConnectorTransitIPRequest struct {
// TransitIPs is the list of requested mappings.
TransitIPs []TransitIPRequest `json:"transitIPs,omitempty"`
}
// TransitIPResponseCode appears in TransitIPResponse and signifies success or failure status.
type TransitIPResponseCode int
const (
// OK indicates that the mapping was created as requested.
OK TransitIPResponseCode = 0
// OtherFailure indicates that the mapping failed for a reason that does not have
// another relevant [TransitIPResponsecode].
OtherFailure TransitIPResponseCode = 1
)
// TransitIPResponse is the response to a TransitIPRequest
type TransitIPResponse struct {
// Code is an error code indicating success or failure of the [TransitIPRequest].
Code TransitIPResponseCode `json:"code,omitzero"`
// Message is an error message explaining what happened, suitable for logging but
// not necessarily suitable for displaying in a UI to non-technical users. It
// should be empty when [Code] is [OK].
Message string `json:"message,omitzero"`
}
// ConnectorTransitIPResponse is the response to a ConnectorTransitIPRequest
type ConnectorTransitIPResponse struct {
// TransitIPs is the list of outcomes for each requested mapping. Elements
// correspond to the order of [ConnectorTransitIPRequest.TransitIPs].
TransitIPs []TransitIPResponse `json:"transitIPs,omitempty"`
return m
}
-61
View File
@@ -1,61 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package appc
import (
"errors"
"net/netip"
"go4.org/netipx"
)
// errPoolExhausted is returned when there are no more addresses to iterate over.
var errPoolExhausted = errors.New("ip pool exhausted")
// ippool allows for iteration over all the addresses within a netipx.IPSet.
// netipx.IPSet has a Ranges call that returns the "minimum and sorted set of IP ranges that covers [the set]".
// netipx.IPRange is "an inclusive range of IP addresses from the same address family.". So we can iterate over
// all the addresses in the set by keeping a track of the last address we returned, calling Next on the last address
// to get the new one, and if we run off the edge of the current range, starting on the next one.
type ippool struct {
// ranges defines the addresses in the pool
ranges []netipx.IPRange
// last is internal tracking of which the last address provided was.
last netip.Addr
// rangeIdx is internal tracking of which netipx.IPRange from the IPSet we are currently on.
rangeIdx int
}
func newIPPool(ipset *netipx.IPSet) *ippool {
if ipset == nil {
return &ippool{}
}
return &ippool{ranges: ipset.Ranges()}
}
// next returns the next address from the set, or errPoolExhausted if we have
// iterated over the whole set.
func (ipp *ippool) next() (netip.Addr, error) {
if ipp.rangeIdx >= len(ipp.ranges) {
// ipset is empty or we have iterated off the end
return netip.Addr{}, errPoolExhausted
}
if !ipp.last.IsValid() {
// not initialized yet
ipp.last = ipp.ranges[0].From()
return ipp.last, nil
}
currRange := ipp.ranges[ipp.rangeIdx]
if ipp.last == currRange.To() {
// then we need to move to the next range
ipp.rangeIdx++
if ipp.rangeIdx >= len(ipp.ranges) {
return netip.Addr{}, errPoolExhausted
}
ipp.last = ipp.ranges[ipp.rangeIdx].From()
return ipp.last, nil
}
ipp.last = ipp.last.Next()
return ipp.last, nil
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_appconnectors
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_appconnectors
+5 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build tailscale_go
@@ -17,7 +17,10 @@ func init() {
panic("binary built with tailscale_go build tag but failed to read build info or find tailscale.toolchain.rev in build info")
}
want := strings.TrimSpace(GoToolchainRev)
if tsRev != want {
// Also permit the "next" toolchain rev, which is used in the main branch and will eventually become the new "current" rev.
// This allows building with TS_GO_NEXT=1 and then running the resulting binary without TS_GO_NEXT=1.
wantAlt := strings.TrimSpace(GoToolchainNextRev)
if tsRev != want && tsRev != wantAlt {
if os.Getenv("TS_PERMIT_TOOLCHAIN_MISMATCH") == "1" {
fmt.Fprintf(os.Stderr, "tailscale.toolchain.rev = %q, want %q; but ignoring due to TS_PERMIT_TOOLCHAIN_MISMATCH=1\n", tsRev, want)
return
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package atomicfile contains code related to writing to filesystems
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package atomicfile
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package atomicfile
+8 -1
View File
@@ -41,7 +41,7 @@ while [ "$#" -gt 1 ]; do
fi
shift
ldflags="$ldflags -w -s"
tags="${tags:+$tags,},$(GOOS= GOARCH= $go run ./cmd/featuretags --min --add=osrouter)"
tags="${tags:+$tags,},$(GOOS= GOARCH= $go run ./cmd/featuretags --min --add=osrouter,nattraversal)"
;;
--min)
# --min is like --extra-small but even smaller, removing all features,
@@ -51,6 +51,13 @@ 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"
+7 -1
View File
@@ -4,7 +4,7 @@
# github.com/tailscale/mkctr.
# By default the images will be tagged with the current version and git
# hash of this repository as produced by ./cmd/mkversion.
# This is the image build mechanim used to build the official Tailscale
# This is the image build mechanism used to build the official Tailscale
# container images.
#
# If you want to build local images for testing, you can use make, which provides few convenience wrappers around this script.
@@ -38,6 +38,7 @@ TARGET="${TARGET:-${DEFAULT_TARGET}}"
TAGS="${TAGS:-${DEFAULT_TAGS}}"
BASE="${BASE:-${DEFAULT_BASE}}"
PLATFORM="${PLATFORM:-}" # default to all platforms
GOARCH="${GOARCH:-arm,arm64,amd64,386,riscv64}"
FILES="${FILES:-}" # default to no extra files
# OCI annotations that will be added to the image.
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
@@ -62,6 +63,7 @@ case "$TARGET" in
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
--goarch="${GOARCH}" \
--annotations="${ANNOTATIONS}" \
--files="${FILES}" \
/usr/local/bin/containerboot
@@ -81,6 +83,7 @@ case "$TARGET" in
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
--goarch="${GOARCH}" \
--annotations="${ANNOTATIONS}" \
--files="${FILES}" \
/usr/local/bin/operator
@@ -100,6 +103,7 @@ case "$TARGET" in
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
--goarch="${GOARCH}" \
--annotations="${ANNOTATIONS}" \
--files="${FILES}" \
/usr/local/bin/k8s-nameserver
@@ -119,6 +123,7 @@ case "$TARGET" in
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
--goarch="${GOARCH}" \
--annotations="${ANNOTATIONS}" \
--files="${FILES}" \
/usr/local/bin/tsidp
@@ -138,6 +143,7 @@ case "$TARGET" in
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
--goarch="${GOARCH}" \
--annotations="${ANNOTATIONS}" \
--files="${FILES}" \
/usr/local/bin/k8s-proxy
+55 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !ts_omit_acme
@@ -10,13 +10,55 @@ 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.
@@ -43,6 +85,8 @@ 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)
@@ -52,6 +96,8 @@ 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)
@@ -65,10 +111,18 @@ 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
+18
View File
@@ -0,0 +1,18 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package local
import "context"
// DebugRejects returns the raw JSON body served by the debug-rejects
// LocalAPI endpoint. Callers that want a typed response can decode the
// returned bytes into [tailscale.com/net/connreject.DebugRejectsResponse].
//
// The response body is returned as opaque bytes so this method does
// not import tailscale.com/net/connreject; that lets builds which
// don't need the typed struct (such as the tailscale CLI) keep
// connreject out of their dependency graph.
func (lc *Client) DebugRejects(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/debug-rejects")
}
+4 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_debugportmapper
@@ -50,6 +50,9 @@ 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 {
+343 -34
View File
@@ -1,7 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// 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 (
@@ -14,6 +20,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"iter"
"net"
"net/http"
@@ -123,7 +130,28 @@ func (lc *Client) defaultDialer(ctx context.Context, network, addr string) (net.
return d.DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
}
}
return safesocket.ConnectContext(ctx, lc.socket())
sock := lc.socket()
c, err := safesocket.ConnectContext(ctx, sock)
if runtime.GOOS == "windows" && err != nil && sock == paths.DefaultTailscaledSocket() && errors.Is(err, fs.ErrNotExist) {
// Nothing is listening on the default pipe. A tailscaled the
// current user started with --windows-mode=dev listens on a
// per-user pipe instead; try that before giving up. That pipe
// is not under the administrators-only prefix, so insist that
// it is owned by the current user and not some other user's
// process squatting on the name.
if dev := paths.WindowsDevTailscaledSocket(); dev != "" {
c, err2 := safesocket.ConnectCurrentUserContext(ctx, dev)
if err2 == nil {
return c, nil
}
if !errors.Is(err2, fs.ErrNotExist) {
// Something is listening there but we won't use it,
// which is worth more than the default pipe's absence.
return nil, fmt.Errorf("connecting to %s: %w", dev, err2)
}
}
}
return c, err
}
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
@@ -135,6 +163,9 @@ 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() {
@@ -192,8 +223,8 @@ func (e *AccessDeniedError) Unwrap() error { return e.err }
// IsAccessDeniedError reports whether err is or wraps an AccessDeniedError.
func IsAccessDeniedError(err error) bool {
var ae *AccessDeniedError
return errors.As(err, &ae)
_, ok := errors.AsType[*AccessDeniedError](err)
return ok
}
// PreconditionsFailedError is returned when the server responds
@@ -210,8 +241,8 @@ func (e *PreconditionsFailedError) Unwrap() error { return e.err }
// IsPreconditionsFailedError reports whether err is or wraps an PreconditionsFailedError.
func IsPreconditionsFailedError(err error) bool {
var ae *PreconditionsFailedError
return errors.As(err, &ae)
_, ok := errors.AsType[*PreconditionsFailedError](err)
return ok
}
// bestError returns either err, or if body contains a valid JSON
@@ -280,7 +311,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}
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode, res.Header}
}
return slurp, res.Header, nil
}
@@ -288,6 +319,7 @@ func (lc *Client) sendWithHeaders(
type httpStatusError struct {
error
HTTPStatus int
Header http.Header
}
func (lc *Client) get200(ctx context.Context, path string) ([]byte, error) {
@@ -316,6 +348,8 @@ 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 {
@@ -327,6 +361,39 @@ 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")
@@ -334,6 +401,8 @@ 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 {
@@ -349,6 +418,8 @@ 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 {
@@ -425,6 +496,9 @@ 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 {
@@ -441,15 +515,29 @@ 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]
// as a [eventbus.DebugTopics].
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
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")
}
// StreamBusEvents returns an iterator of Tailscale bus events as they arrive.
// 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",
@@ -518,6 +606,8 @@ 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 != "" {
@@ -563,12 +653,17 @@ 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 and subject to change or removal over time.
// These are development tools.
//
// API maturity: this method is not considered a stable API and is
// subject to change or removal between releases.
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 {
@@ -579,7 +674,10 @@ 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 and subject to change or removal over time.
// These are development tools.
//
// API maturity: this method is not considered a stable API and is
// subject to change or removal between releases.
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 {
@@ -589,7 +687,10 @@ 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 and subject to change or removal over time.
// These are development tools.
//
// API maturity: this method is not considered a stable API and is
// subject to change or removal between releases.
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 {
@@ -602,6 +703,27 @@ 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)
@@ -631,6 +753,9 @@ 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
@@ -659,6 +784,8 @@ 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, "")
}
@@ -669,6 +796,8 @@ 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")
}
@@ -773,6 +902,9 @@ 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
@@ -796,6 +928,9 @@ 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 {
@@ -813,25 +948,6 @@ func (lc *Client) CheckUDPGROForwarding(ctx context.Context) error {
return nil
}
// CheckReversePathFiltering asks the local Tailscale daemon whether strict
// reverse path filtering is enabled, which would break exit node usage on Linux.
func (lc *Client) CheckReversePathFiltering(ctx context.Context) error {
body, err := lc.get200(ctx, "/localapi/v0/check-reverse-path-filtering")
if err != nil {
return err
}
var jres struct {
Warning string
}
if err := json.Unmarshal(body, &jres); err != nil {
return fmt.Errorf("invalid JSON from check-reverse-path-filtering: %w", err)
}
if jres.Warning != "" {
return errors.New(jres.Warning)
}
return nil
}
// SetUDPGROForwarding enables UDP GRO forwarding for the main interface of this
// node. This can be done to improve performance of tailnet nodes acting as exit
// nodes or subnet routers.
@@ -864,6 +980,9 @@ 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 {
@@ -881,6 +1000,8 @@ 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 {
@@ -891,6 +1012,9 @@ 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
@@ -924,7 +1048,26 @@ func (lc *Client) QueryDNS(ctx context.Context, name string, queryType string) (
return res.Bytes, res.Resolvers, nil
}
// StartLoginInteractive starts an interactive login.
// 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.
func (lc *Client) StartLoginInteractive(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil)
return err
@@ -949,6 +1092,8 @@ 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)
}
@@ -960,6 +1105,8 @@ 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{
@@ -986,6 +1133,19 @@ 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
@@ -1011,6 +1171,10 @@ 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)
@@ -1023,6 +1187,66 @@ 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.
@@ -1059,6 +1283,8 @@ 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 {
@@ -1085,7 +1311,7 @@ func tailscaledConnectHint() string {
// ActiveState=inactive
// SubState=dead
st := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
for line := range strings.SplitSeq(string(out), "\n") {
if k, v, ok := strings.Cut(line, "="); ok {
st[k] = strings.TrimSpace(v)
}
@@ -1154,13 +1380,18 @@ 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 LoginInteractive.
// In order to login to the new profile, the user must call
// [Client.StartLoginInteractive].
//
// API maturity: this is considered a stable API.
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
@@ -1195,6 +1426,11 @@ 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)
@@ -1205,6 +1441,9 @@ 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 {
@@ -1216,6 +1455,9 @@ 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)
@@ -1224,6 +1466,9 @@ 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 {
@@ -1236,6 +1481,9 @@ 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 {
@@ -1262,9 +1510,16 @@ 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="+fmt.Sprint(mask),
"http://"+apitype.LocalAPIHost+"/localapi/v0/watch-ipn-bus?mask="+string(m),
nil)
if err != nil {
return nil, err
@@ -1288,6 +1543,8 @@ 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 {
@@ -1304,6 +1561,8 @@ 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
@@ -1312,6 +1571,9 @@ 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
@@ -1320,6 +1582,9 @@ 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
@@ -1327,6 +1592,9 @@ 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,
@@ -1338,6 +1606,9 @@ 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,
@@ -1350,6 +1621,9 @@ 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 {
@@ -1406,8 +1680,25 @@ 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 {
@@ -1424,11 +1715,19 @@ 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 {
@@ -1436,3 +1735,13 @@ 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)
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_favorites
package local
import (
"context"
"net/http"
"tailscale.com/feature/favorites/pintype"
)
// GetPins returns the current profile's locally-pinned favorites.
//
// Pins are not currently announced over the IPN notification bus, including when they change
// because the backend switched to a different profile. The client is responsible for re-fetching
// pins on IPN state changes (e.g. profile switches) rather than assuming a previously fetched
// [pintype.Set] still applies.
//
// API maturity: this method is not considered a stable API and is subject to change between releases.
func (lc *Client) GetPins(ctx context.Context) (pintype.Set, error) {
body, err := lc.get200(ctx, "/localapi/v0/pins")
if err != nil {
return pintype.Set{}, err
}
return decodeJSON[pintype.Set](body)
}
// SetPins replaces the categories named in req for the current profile and returns the full updated
// pinned favorites.
//
// API maturity: this method is not considered a stable API and is subject to change between releases.
func (lc *Client) SetPins(ctx context.Context, req pintype.SetRequest) (pintype.Set, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/pins", http.StatusOK, jsonBody(req))
if err != nil {
return pintype.Set{}, err
}
return decodeJSON[pintype.Set](body)
}
+43
View File
@@ -0,0 +1,43 @@
// 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)
}
+4 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serve
@@ -17,6 +17,9 @@ 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
@@ -0,0 +1,37 @@
// 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)
}
+4 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_syspolicy
@@ -13,6 +13,9 @@ 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 {
+95 -32
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tailnetlock
@@ -18,19 +18,22 @@ import (
"tailscale.com/types/tkatype"
)
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
// TailnetLockStatus fetches information about the tailnet key authority, if one is configured.
func (lc *Client) TailnetLockStatus(ctx context.Context) (*ipnstate.TailnetLockStatus, 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.NetworkLockStatus](body)
return decodeJSON[*ipnstate.TailnetLockStatus](body)
}
// NetworkLockInit initializes the tailnet key authority.
//
// TODO(tom): Plumb through disablement secrets.
func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte, supportDisablement []byte) (*ipnstate.NetworkLockStatus, error) {
// 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) {
var b bytes.Buffer
type initRequest struct {
Keys []tka.Key
@@ -46,12 +49,17 @@ func (lc *Client) NetworkLockInit(ctx context.Context, keys []tka.Key, disableme
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
return decodeJSON[*ipnstate.NetworkLockStatus](body)
return decodeJSON[*ipnstate.TailnetLockStatus](body)
}
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
// 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
// enable unattended bringup in the locked tailnet.
func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
func (lc *Client) TailnetLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
encodedPrivate, err := tkaKey.MarshalText()
if err != nil {
return "", err
@@ -73,8 +81,13 @@ func (lc *Client) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey stri
return string(body), nil
}
// NetworkLockModify adds and/or removes key(s) to the tailnet key authority.
func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
// 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 {
var b bytes.Buffer
type modifyRequest struct {
AddKeys []tka.Key
@@ -91,9 +104,14 @@ func (lc *Client) NetworkLockModify(ctx context.Context, addKeys, removeKeys []t
return nil
}
// NetworkLockSign signs the specified node-key and transmits that signature to the control plane.
// 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.
// rotationPublic, if specified, must be an ed25519 public key.
func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
func (lc *Client) TailnetLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
var b bytes.Buffer
type signRequest struct {
NodeKey key.NodePublic
@@ -110,8 +128,13 @@ func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, r
return nil
}
// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
// 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) {
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)
@@ -119,19 +142,29 @@ func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.Key
return decodeJSON[[]tkatype.MarshaledSignature](body)
}
// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
func (lc *Client) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
// 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) {
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.NetworkLockUpdate](body)
return decodeJSON[[]ipnstate.TailnetLockUpdate](body)
}
// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
// 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 {
// This endpoint expects an empty JSON stanza as the payload.
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil {
@@ -144,9 +177,14 @@ func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
return nil
}
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
// Deprecated: use [Client.TailnetLockForceLocalDisable] instead.
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
return lc.TailnetLockForceLocalDisable(ctx)
}
// TailnetLockVerifySigningDeeplink verifies the tailnet lock deeplink contained
// in url and returns information extracted from it.
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
func (lc *Client) TailnetLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
vr := struct {
URL string
}{url}
@@ -159,8 +197,13 @@ func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url stri
return decodeJSON[*tka.DeeplinkValidationResult](body)
}
// 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) {
// 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) {
vr := struct {
Keys []tkatype.KeyID
ForkFrom string
@@ -174,8 +217,13 @@ func (lc *Client) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tk
return body, nil
}
// 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) {
// 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) {
r := bytes.NewReader(aum.Serialize())
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
if err != nil {
@@ -185,8 +233,13 @@ func (lc *Client) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM)
return body, nil
}
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
// 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 {
r := bytes.NewReader(aum.Serialize())
_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
if err != nil {
@@ -195,10 +248,20 @@ func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM)
return nil
}
// NetworkLockDisable shuts down network-lock across the tailnet.
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
// 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 {
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)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
+25 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package apitype contains types for the Tailscale LocalAPI and control plane API.
@@ -76,7 +76,7 @@ type ReloadConfigResponse struct {
type ExitNodeSuggestionResponse struct {
ID tailcfg.StableNodeID
Name string
Location tailcfg.LocationView `json:",omitempty"`
Location tailcfg.LocationView `json:",omitzero"`
}
// DNSOSConfig mimics dns.OSConfig without forcing us to import the entire dns package
@@ -103,4 +103,27 @@ type OptionalFeatures struct {
// Disabled features may be absent from the map. (That is, false values
// are not guaranteed to be present.)
Features map[string]bool
// Disabled is the list of features disabled at runtime via the
// TS_DISABLE_FEATURE environment variable, whether or not this build
// contains them. A name listed here that is absent from Features
// either named an unknown feature or one not linked into this build.
Disabled []string `json:",omitempty"`
}
// 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
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package apitype
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !ts_omit_acme
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tailscale
@@ -22,7 +22,7 @@ type Key struct {
// KeyCapabilities are the capabilities of a Key.
type KeyCapabilities struct {
Devices KeyDeviceCapabilities `json:"devices,omitempty"`
Devices KeyDeviceCapabilities `json:"devices"`
}
// KeyDeviceCapabilities are the device-related capabilities of a Key.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tailscale
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !go1.23
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
+28 -36
View File
@@ -1,10 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
"io"
"errors"
"io/fs"
"log"
"net/http"
@@ -14,13 +14,12 @@ import (
"os/exec"
"path/filepath"
"strings"
"time"
prebuilt "github.com/tailscale/web-client-prebuilt"
"tailscale.com/tsweb/compserve"
"tailscale.com/tsweb/vcstime"
)
var start = time.Now()
func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
if devMode {
// When in dev mode, proxy asset requests to the Vite dev server.
@@ -28,49 +27,42 @@ func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
return devServerProxy(), cleanup
}
fsys := prebuilt.FS()
// vcstime supplies the VCS commit time as mod time for the embedded
// assets, enabling conditional requests.
fsys := vcstime.FS(prebuilt.FS())
opts := compserve.Options{}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
f, err := openPrecompressedFile(w, r, path, fsys)
if err != nil {
// Rewrite request to just fetch index.html and let
// the frontend router handle it.
r = r.Clone(r.Context())
if path == "" {
// The root path is the SPA entrypoint.
path = "index.html"
f, err = openPrecompressedFile(w, r, path, fsys)
}
if f == nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
defer f.Close()
// fs.File does not claim to implement Seeker, but in practice it does.
fSeeker, ok := f.(io.ReadSeeker)
if !ok {
http.Error(w, "Not seekable", http.StatusInternalServerError)
return
}
if strings.HasPrefix(path, "assets/") {
// Aggressively cache static assets, since we cache-bust our assets with
// hashed filenames.
w.Header().Set("Cache-Control", "public, max-age=31535996")
w.Header().Set("Vary", "Accept-Encoding")
}
http.ServeContent(w, r, path, start, fSeeker)
err := compserve.ServeFile(w, r, fsys, path, opts)
if errors.Is(err, fs.ErrNotExist) {
// Rewrite request to just fetch index.html and let
// the frontend router handle it. The fallback must not inherit
// cache headers of the original path; nothing has been written
// yet, so they can still be adjusted.
w.Header().Del("Cache-Control")
err = compserve.ServeFile(w, r, fsys, "index.html", opts)
}
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
http.NotFound(w, r)
return
}
// Errors may embed the request path; log rather than reflect it.
log.Printf("web client: serving %q: %v", path, err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}), nil
}
func openPrecompressedFile(w http.ResponseWriter, r *http.Request, path string, fs fs.FS) (fs.File, error) {
if f, err := fs.Open(path + ".gz"); err == nil {
w.Header().Set("Content-Encoding", "gzip")
return f, nil
}
return fs.Open(path) // fallback
}
// startDevServer starts the JS dev server that does on-demand rebuilding
// and serving of web client JS and CSS resources.
func startDevServer() (cleanup func()) {
+19 -6
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package web
@@ -18,6 +18,7 @@ import (
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/peercap"
)
const (
@@ -37,6 +38,7 @@ type browserSession struct {
AuthURL string // from tailcfg.WebClientAuthResponse
Created time.Time
Authenticated bool
PendingAuth bool
}
// isAuthorized reports true if the given session is authorized
@@ -172,12 +174,14 @@ func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*b
}
session.AuthID = a.ID
session.AuthURL = a.URL
session.PendingAuth = true
} else {
// control does not support check mode, so there is no additional auth we can do.
session.Authenticated = true
}
s.browserSessions.Store(sid, session)
return session, nil
}
@@ -196,7 +200,8 @@ func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
if err != nil {
return true
}
return strings.HasSuffix(controlURL.Host, ".tailscale.com")
return strings.HasSuffix(controlURL.Host, ".tailscale.com") ||
controlURL.Host == "control.tailscale" // for natlab tests
}
// awaitUserAuth blocks until the given session auth has been completed
@@ -206,16 +211,24 @@ func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) err
if session.isAuthorized(s.timeNow()) {
return nil // already authorized
}
a, err := s.waitAuthURL(ctx, session.AuthID, session.SrcNode)
if err != nil {
// Clean up the session. Doing this on any error from control
// server to avoid the user getting stuck with a bad session
// cookie.
// Don't delete the session on context cancellation, as this is expected
// when users navigate away or refresh the page.
if errors.Is(err, context.Canceled) {
return err
}
// Clean up the session for non-cancellation errors from control server
// to avoid the user getting stuck with a bad session cookie.
s.browserSessions.Delete(session.ID)
return err
}
if a.Complete {
session.Authenticated = a.Complete
session.PendingAuth = false
s.browserSessions.Store(session.ID, session)
}
return nil
@@ -323,7 +336,7 @@ func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (
// For tagged nodes, we actually look at the granted capabilities.
caps := peerCapabilities{}
rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI)
rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, peercap.WebUI)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal capability: %v", err)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// qnap.go contains handlers and logic, such as authentication,
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// synology.go contains handlers and logic, such as authentication,
+93 -121
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package web provides the Tailscale client for web.
@@ -35,8 +35,10 @@ 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"
@@ -527,45 +529,40 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
}
}
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,
// 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)
}
}
// 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 }
var contextKeyPeer = ctxkey.New("peer-capabilities", peerCapabilities{})
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
func (s *Server) setPeer(r *http.Request) (*http.Request, error) {
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
// 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 := a.s.lc.StatusWithoutPeers(a.r.Context())
status, err := s.lc.StatusWithoutPeers(r.Context())
if err != nil {
return nil, err
}
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
if err != nil {
return nil, err
}
@@ -573,56 +570,11 @@ func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
if err != nil {
return nil, err
}
return peer, nil
return r.WithContext(contextKeyPeer.WithValue(r.Context(), peer)), nil
}
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
// 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)
func (s *Server) getPeer(ctx context.Context) peerCapabilities {
return contextKeyPeer.Value(ctx)
}
// serveAPI serves requests for the web client api.
@@ -637,67 +589,44 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
}
}
var err error
r, err = s.setPeer(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api")
switch {
case path == "/data" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveGetNodeData)
s.serveGetNodeData(w, r)
return
case path == "/exit-nodes" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveGetExitNodes)
s.serveGetExitNodes(w, r)
return
case path == "/routes" && r.Method == httpm.POST:
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)
handleJSON[postRoutesRequest](s.servePostRoutes)(w, r)
return
case path == "/device-details-click" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveDeviceDetailsClick)
s.serveDeviceDetailsClick(w, r)
return
case path == "/local/v0/logout" && r.Method == httpm.POST:
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
return peer.canEdit(capFeatureAccount)
}
newHandler[noBodyData](s, w, r, peerAllowed).
handle(s.proxyRequestToLocalAPI)
s.proxyRequestToLocalAPI(w, r)
return
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
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)
handleJSON[maskedPrefs](s.serveUpdatePrefs)(w, r)
return
case path == "/local/v0/update/check" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
s.proxyRequestToLocalAPI(w, r)
return
case path == "/local/v0/update/check" && r.Method == httpm.POST:
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
return peer.canEdit(capFeatureAccount)
}
newHandler[noBodyData](s, w, r, peerAllowed).
handle(s.proxyRequestToLocalAPI)
s.proxyRequestToLocalAPI(w, r)
return
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
s.proxyRequestToLocalAPI(w, r)
return
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
s.proxyRequestToLocalAPI(w, r)
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
@@ -771,6 +700,19 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
}
}
// We might have a session for which we haven't awaited the result yet.
// This can happen when the AuthURL opens in the same browser tab instead
// of a new one due to browser settings.
// (See https://github.com/tailscale/tailscale/issues/11905)
// We therefore set a PendingAuth flag when creating a new session, check
// it here and call awaitUserAuth if we find it to be true. Once the auth
// wait completes, awaitUserAuth will set PendingAuth to false.
if sErr == nil && session.PendingAuth == true {
if err := s.awaitUserAuth(r.Context(), session); err != nil {
sErr = err
}
}
switch {
case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
@@ -1109,6 +1051,11 @@ 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{
@@ -1127,6 +1074,17 @@ 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
@@ -1140,13 +1098,14 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
}
currNonExitRoutes = append(currNonExitRoutes, r.String())
}
// Set non-edited fields to their current values.
if data.SetExitNode {
data.AdvertiseRoutes = currNonExitRoutes
} else if data.SetRoutes {
// For each group of fields not being set, preserve the current prefs.
if !data.SetExitNode {
data.AdvertiseExitNode = currAdvertisingExitNode
data.UseExitNode = prefs.ExitNodeID
}
if !data.SetRoutes {
data.AdvertiseRoutes = currNonExitRoutes
}
// Calculate routes.
routesStr := strings.Join(data.AdvertiseRoutes, ",")
@@ -1323,6 +1282,19 @@ 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 {
+3 -3
View File
@@ -4088,9 +4088,9 @@ lodash.merge@^4.6.2:
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
version "4.17.23"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0"
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package controlbase implements the base transport of the Tailscale
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlbase
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlbase
+169 -31
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlclient
@@ -20,10 +20,12 @@ import (
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
"tailscale.com/types/structs"
"tailscale.com/types/views"
"tailscale.com/util/backoff"
"tailscale.com/util/clientmetric"
"tailscale.com/util/execqueue"
"tailscale.com/util/testenv"
"tailscale.com/wgengine/filter"
)
type LoginGoal struct {
@@ -34,6 +36,10 @@ type LoginGoal struct {
var _ Client = (*Auto)(nil)
// maxRetryWindow defines the upper bound on how long control is allowed
// to tell the client to wait before retrying a request.
const maxRetryWindow = 5 * time.Minute
// waitUnpause waits until either the client is unpaused or the Auto client is
// shut down. It reports whether the client should keep running (i.e. it's not
// closed).
@@ -88,10 +94,14 @@ func (c *Auto) updateRoutine() {
if ctx.Err() == nil {
c.direct.logf("lite map update error after %v: %v", d, err)
}
bo.BackOff(ctx, err)
if rle, rateLimited := errors.AsType[*rateLimitError](err); rateLimited {
c.waitRetryAfter(ctx, "updateRoutine", rle)
} else {
bo.BackOff(ctx, err)
}
continue
}
bo.BackOff(ctx, nil)
bo.Reset()
c.direct.logf("[v1] successful lite map update in %v", d)
lastUpdateGenInformed = gen
@@ -356,7 +366,11 @@ func (c *Auto) authRoutine() {
if err != nil {
c.direct.health.SetAuthRoutineInError(err)
report(err, f)
bo.BackOff(ctx, err)
if rle, ok := errors.AsType[*rateLimitError](err); ok {
c.waitRetryAfter(ctx, "authRoutine", rle)
} else {
bo.BackOff(ctx, err)
}
continue
}
if url != "" {
@@ -370,9 +384,15 @@ func (c *Auto) authRoutine() {
}
c.mu.Lock()
c.urlToVisit = url
c.loginGoal = &LoginGoal{
flags: LoginDefault,
url: url,
// Only store the URL follow-up goal if no concurrent Login() call has
// replaced the goal we were processing while our control plane request
// was in flight. Otherwise, the intent from the more recent goal gets
// lost.
if c.loginGoal == goal {
c.loginGoal = &LoginGoal{
flags: LoginDefault,
url: url,
}
}
c.mu.Unlock()
@@ -382,7 +402,7 @@ func (c *Auto) authRoutine() {
// backoff to avoid a busy loop.
bo.BackOff(ctx, errors.New("login URL not changing"))
} else {
bo.BackOff(ctx, nil)
bo.Reset()
}
continue
}
@@ -390,14 +410,26 @@ func (c *Auto) authRoutine() {
// success
c.direct.health.SetAuthRoutineInError(nil)
c.mu.Lock()
c.urlToVisit = ""
c.loggedIn = true
c.loginGoal = nil
// Only commit the login success if no concurrent Login()
// call has reset the goal and no Logout() has moved on
// while our control plane request was in flight. In the
// first case, clearing the goal would prevent the next
// iteration from picking it up and running with it. In
// the second case, we would record that we're loggedIn
// even though we're logged out.
goalStillCurrentGoal := c.loginGoal == goal
if goalStillCurrentGoal {
c.urlToVisit = ""
c.loggedIn = true
c.loginGoal = nil
}
c.mu.Unlock()
c.sendStatus("authRoutine-success", nil, "", nil)
c.restartMap()
bo.BackOff(ctx, nil)
if goalStillCurrentGoal {
c.sendStatus("authRoutine-success", nil, "", nil)
c.restartMap()
}
bo.Reset()
}
}
@@ -446,13 +478,14 @@ func (mrs mapRoutineState) UpdateFullNetmap(nm *netmap.NetworkMap) {
c.expiry = nm.SelfKeyExpiry()
stillAuthed := c.loggedIn
c.logf("[v1] mapRoutine: netmap received: loggedIn=%v inMapPoll=true", stillAuthed)
// Reset the backoff timer if we got a netmap.
mrs.bo.Reset()
c.mu.Unlock()
if stillAuthed {
c.sendStatus("mapRoutine-got-netmap", nil, "", nm)
}
// Reset the backoff timer if we got a netmap.
mrs.bo.Reset()
}
func (mrs mapRoutineState) UpdateNetmapDelta(muts []netmap.NodeMutation) bool {
@@ -461,20 +494,89 @@ func (mrs mapRoutineState) UpdateNetmapDelta(muts []netmap.NodeMutation) bool {
c.mu.Lock()
goodState := c.loggedIn && c.inMapPoll
ndu, canDelta := c.observer.(NetmapDeltaUpdater)
mapCtx := c.mapCtx
c.mu.Unlock()
if !goodState || !canDelta {
return false
}
ctx, cancel := context.WithTimeout(c.mapCtx, 2*time.Second)
ctx, cancel := context.WithTimeout(mapCtx, 2*time.Second)
defer cancel()
var ok bool
err := c.observerQueue.RunSync(ctx, func() {
ok = ndu.UpdateNetmapDelta(muts)
ch := make(chan bool, 1)
c.observerQueue.Add(func() {
ch <- ndu.UpdateNetmapDelta(muts)
})
return err == nil && ok
select {
case ok := <-ch:
return ok
case <-ctx.Done():
return false
}
}
var (
_ PacketFilterUpdater = mapRoutineState{}
_ UserProfileUpdater = mapRoutineState{}
)
// UpdatePacketFilter implements [PacketFilterUpdater] by forwarding to
// [Auto.observer] if it implements [PacketFilterUpdater]. It returns
// false (signaling fall back to a full netmap rebuild) if the
// downstream observer doesn't implement [PacketFilterUpdater] or isn't
// in a state to accept updates.
func (mrs mapRoutineState) UpdatePacketFilter(rules views.Slice[tailcfg.FilterRule], parsed []filter.Match) bool {
c := mrs.c
c.mu.Lock()
goodState := c.loggedIn && c.inMapPoll
pfu, ok := c.observer.(PacketFilterUpdater)
mapCtx := c.mapCtx
c.mu.Unlock()
if !goodState || !ok {
return false
}
ctx, cancel := context.WithTimeout(mapCtx, 2*time.Second)
defer cancel()
ch := make(chan bool, 1)
c.observerQueue.Add(func() {
ch <- pfu.UpdatePacketFilter(rules, parsed)
})
select {
case applied := <-ch:
return applied
case <-ctx.Done():
return false
}
}
// UpdateUserProfiles implements [UserProfileUpdater] by forwarding to
// [Auto.observer] if it implements [UserProfileUpdater]. It returns
// false (signaling fall back to a full netmap rebuild) if the
// downstream observer doesn't implement [UserProfileUpdater] or isn't
// in a state to accept updates.
func (mrs mapRoutineState) UpdateUserProfiles(profiles map[tailcfg.UserID]tailcfg.UserProfileView) bool {
c := mrs.c
c.mu.Lock()
goodState := c.loggedIn && c.inMapPoll
upu, ok := c.observer.(UserProfileUpdater)
mapCtx := c.mapCtx
c.mu.Unlock()
if !goodState || !ok {
return false
}
ctx, cancel := context.WithTimeout(mapCtx, 2*time.Second)
defer cancel()
ch := make(chan bool, 1)
c.observerQueue.Add(func() {
ch <- upu.UpdateUserProfiles(profiles)
})
select {
case applied := <-ch:
return applied
case <-ctx.Done():
return false
}
}
// mapRoutine is responsible for keeping a read-only streaming connection to the
@@ -528,13 +630,41 @@ func (c *Auto) mapRoutine() {
paused := c.paused
c.mu.Unlock()
rle, rateLimited := errors.AsType[*rateLimitError](err)
if paused {
mrs.bo.Reset()
} else if !rateLimited {
mrs.bo.BackOff(ctx, err)
}
if paused {
mrs.bo.BackOff(ctx, nil)
c.logf("mapRoutine: paused")
} else {
mrs.bo.BackOff(ctx, err)
report(err, "PollNetMap")
}
if rateLimited {
c.waitRetryAfter(ctx, "mapRoutine", rle)
}
}
}
// waitRetryAfter sleeps for the delay the server requested in rle, capped at
// [maxRetryWindow] or until ctx is done, whichever comes first.
func (c *Auto) waitRetryAfter(ctx context.Context, routine string, rle *rateLimitError) {
if rle.retryAfter > maxRetryWindow {
rle.retryAfter = maxRetryWindow
}
c.logf("%s: %s", routine, rle)
t, ch := c.clock.NewTimer(rle.retryAfter)
defer t.Stop()
select {
case <-ctx.Done():
case <-ch:
}
}
@@ -674,17 +804,16 @@ func canSkipStatus(s1, s2 *Status) bool {
// we can't skip it.
return false
}
if s1.Err != nil || s1.URL != "" || s1.LoggedIn {
// If s1 has an error, a URL, or LoginFinished set, we shouldn't skip it,
// lest the error go away in s2 or in-between. We want to make sure all
// the subsystems see it. Plus there aren't many of these, so not worth
// skipping.
if s1.Err != nil || s1.URL != "" {
// If s1 has an error or an URL, we shouldn't skip it, lest the error go
// away in s2 or in-between. We want to make sure all the subsystems see
// it. Plus there aren't many of these, so not worth skipping.
return false
}
if !s1.Persist.Equals(s2.Persist) || s1.LoggedIn != s2.LoggedIn || s1.InMapPoll != s2.InMapPoll || s1.URL != s2.URL {
// If s1 has a different Persist, LoginFinished, Synced, or URL than s2,
// don't skip it. We only care about skipping the typical
// entries where the only difference is the NetMap.
// If s1 has a different Persist, has changed login state, changed map
// poll state, or has a new login URL, don't skip it. We only care about
// skipping the typical entries where the only difference is the NetMap.
return false
}
// If nothing above precludes it, and both s1 and s2 have NetMaps, then
@@ -774,6 +903,15 @@ func (c *Auto) SetDiscoPublicKey(key key.DiscoPublic) {
c.updateControl()
}
// SetIPForwardingBroken updates the IP forwarding broken state and sends
// a control update if the value changed.
func (c *Auto) SetIPForwardingBroken(v bool) {
if !c.direct.SetIPForwardingBroken(v) {
return
}
c.updateControl()
}
func (c *Auto) Shutdown() {
c.mu.Lock()
if c.closed {
+4 -7
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package controlclient implements the client for the Tailscale
@@ -87,13 +87,10 @@ type Client interface {
// future map requests. This should be called after rotating the discovery key.
// Note: the auto client uploads the new key to control immediately.
SetDiscoPublicKey(key.DiscoPublic)
// SetIPForwardingBroken updates the IP forwarding broken state
// and sends a control update if the value changed.
SetIPForwardingBroken(bool)
// ClientID returns the ClientID of a client. This ID is meant to
// distinguish one client from another.
ClientID() int64
}
// UserVisibleError is an error that should be shown to users.
type UserVisibleError string
func (e UserVisibleError) Error() string { return string(e) }
func (e UserVisibleError) UserVisibleError() string { return string(e) }
+268 -98
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlclient
@@ -9,12 +9,15 @@ import (
"context"
"crypto"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand/v2"
"net"
"net/http"
"net/netip"
@@ -22,6 +25,7 @@ import (
"reflect"
"runtime"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -45,49 +49,53 @@ import (
"tailscale.com/net/tsdial"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/tka"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
"tailscale.com/types/ptr"
"tailscale.com/types/tkatype"
"tailscale.com/types/views"
"tailscale.com/util/clientmetric"
"tailscale.com/util/eventbus"
"tailscale.com/util/httpbody"
"tailscale.com/util/singleflight"
"tailscale.com/util/syspolicy/pkey"
"tailscale.com/util/syspolicy/policyclient"
"tailscale.com/util/testenv"
"tailscale.com/util/vizerror"
"tailscale.com/util/zstdframe"
"tailscale.com/wgengine/filter"
)
// Direct is the client that connects to a tailcontrol server for a node.
type Direct struct {
httpc *http.Client // HTTP client used to do TLS requests to control (just https://controlplane.tailscale.com/key?v=123)
interceptedDial *atomic.Bool // if non-nil, pointer to bool whether ScreenTime intercepted our dial
dialer *tsdial.Dialer
dnsCache *dnscache.Resolver
controlKnobs *controlknobs.Knobs // always non-nil
serverURL string // URL of the tailcontrol server
clock tstime.Clock
logf logger.Logf
netMon *netmon.Monitor // non-nil
health *health.Tracker
busClient *eventbus.Client
clientVersionPub *eventbus.Publisher[tailcfg.ClientVersion]
autoUpdatePub *eventbus.Publisher[AutoUpdate]
controlTimePub *eventbus.Publisher[ControlTime]
getMachinePrivKey func() (key.MachinePrivate, error)
debugFlags []string
skipIPForwardingCheck bool
pinger Pinger
popBrowser func(url string) // or nil
polc policyclient.Client // always non-nil
c2nHandler http.Handler // or nil
panicOnUse bool // if true, panic if client is used (for testing)
closedCtx context.Context // alive until Direct.Close is called
closeCtx context.CancelFunc // cancels closedCtx
httpc *http.Client // HTTP client used to do TLS requests to control (just https://controlplane.tailscale.com/key?v=123)
interceptedDial *atomic.Bool // if non-nil, pointer to bool whether ScreenTime intercepted our dial
dialer *tsdial.Dialer
dnsCache *dnscache.Resolver
controlKnobs *controlknobs.Knobs // always non-nil
serverURL string // URL of the tailcontrol server
clock tstime.Clock
logf logger.Logf
netMon *netmon.Monitor // non-nil
health *health.Tracker
extraRootCAs *x509.CertPool // additional trusted root CAs; or nil
busClient *eventbus.Client
clientVersionPub *eventbus.Publisher[tailcfg.ClientVersion]
autoUpdatePub *eventbus.Publisher[AutoUpdate]
controlTimePub *eventbus.Publisher[ControlTime]
getMachinePrivKey func() (key.MachinePrivate, error)
debugFlags []string
pinger Pinger
popBrowser func(url string) // or nil
polc policyclient.Client // always non-nil
c2nHandler http.Handler // or nil
panicOnUse bool // if true, panic if client is used (for testing)
closedCtx context.Context // alive until Direct.Close is called
closeCtx context.CancelFunc // cancels closedCtx
dialPlan ControlDialPlanner // can be nil
@@ -95,6 +103,7 @@ type Direct struct {
serverLegacyKey key.MachinePublic // original ("legacy") nacl crypto_box-based public key; only used for signRegisterRequest on Windows now
serverNoiseKey key.MachinePublic
discoPubKey key.DiscoPublic // protected by mu; can be updated via [SetDiscoPublicKey]
ipForwardBroken bool // protected by mu; can be updated via [SetIPForwardingBroken]
sfGroup singleflight.Group[struct{}, *ts2021.Client] // protects noiseClient creation.
noiseClient *ts2021.Client // also protected by mu
@@ -140,6 +149,7 @@ type Options struct {
NoiseTestClient *http.Client // optional HTTP client to use for noise RPCs (tests only)
DebugFlags []string // debug settings to send to control
HealthTracker *health.Tracker
ExtraRootCAs *x509.CertPool // additional trusted root CAs; or nil
PopBrowserURL func(url string) // optional func to open browser
Dialer *tsdial.Dialer // non-nil
C2NHandler http.Handler // or nil
@@ -159,11 +169,6 @@ type Options struct {
// If nil, no status updates are reported.
Observer Observer
// SkipIPForwardingCheck declares that the host's IP
// forwarding works and should not be double-checked by the
// controlclient package.
SkipIPForwardingCheck bool
// Pinger optionally specifies the Pinger to use to satisfy
// MapResponse.PingRequest queries from the control plane.
// If nil, PingRequest queries are not answered.
@@ -224,6 +229,9 @@ type NetmapUpdater interface {
// rather than just full updates.
type NetmapDeltaUpdater interface {
// UpdateNetmapDelta is called with discrete changes to the network map.
// The mutation slice may contain [netmap.NodeMutationUpsert] entries when
// peers are inserted or replaced, and [netmap.NodeMutationRemove] entries
// when peers are removed, alongside per-field patches.
//
// The ok result is whether the implementation was able to apply the
// mutations. It might return false if its internal state doesn't
@@ -232,6 +240,52 @@ type NetmapDeltaUpdater interface {
UpdateNetmapDelta([]netmap.NodeMutation) (ok bool)
}
// PacketFilterUpdater is an optional interface that can be implemented by
// NetmapUpdater implementations to receive incremental packet-filter updates
// without a full netmap rebuild.
//
// It exists because the packet filter currently changes on every peer
// addition, so a MapResponse carrying PeersChanged almost always also carries
// PacketFilter (or PacketFilters). Handling the filter narrowly keeps peer
// churn O(1) on the controlclient side.
type PacketFilterUpdater interface {
// UpdatePacketFilter is called when a MapResponse's PacketFilter (or
// PacketFilters) changed. rules is the already-merged concatenation of
// the session's named packet filter chunks; parsed is the parsed form.
//
// It returns false to signal the caller to fall back to a full
// netmap rebuild. Proxy/forwarder implementations return false when
// their downstream destination doesn't implement
// [PacketFilterUpdater]; concrete implementations return true on
// successful apply.
UpdatePacketFilter(rules views.Slice[tailcfg.FilterRule], parsed []filter.Match) bool
}
// UserProfileUpdater is an optional interface that can be implemented by
// NetmapUpdater implementations to receive incremental UserProfile updates
// without a full netmap rebuild.
//
// It exists so consumers of [ipn.Notify.UserProfiles] can be told about
// new or updated UserProfiles before (or with) the [ipn.Notify.PeersChanged]
// or [ipn.Notify.PeerChangedPatch] entry that references the corresponding
// UserID.
type UserProfileUpdater interface {
// UpdateUserProfiles is called when a MapResponse carries UserProfiles
// entries. profiles is the new/updated subset (NOT the full map);
// implementations should merge with whatever they already know.
//
// The values are [tailcfg.UserProfileView]s sharing backing memory
// with the caller's tracking map; implementations may store them
// directly without copying.
//
// It returns false to signal the caller to fall back to a full
// netmap rebuild. Proxy/forwarder implementations return false when
// their downstream destination doesn't implement
// [UserProfileUpdater]; concrete implementations return true on
// successful apply.
UpdateUserProfiles(profiles map[tailcfg.UserID]tailcfg.UserProfileView) bool
}
var nextControlClientID atomic.Int64
// NewDirect returns a new Direct client.
@@ -285,13 +339,19 @@ func NewDirect(opts Options) (*Direct, error) {
}
var interceptedDial *atomic.Bool
if httpc == nil {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr := netutil.NewDefaultTransport()
if buildfeatures.HasUseProxy {
tr.Proxy = feature.HookProxyFromEnvironment.GetOrNil()
if f, ok := feature.HookProxySetTransportGetProxyConnectHeader.GetOk(); ok {
f(tr)
}
}
if opts.ExtraRootCAs != nil {
if tr.TLSClientConfig == nil {
tr.TLSClientConfig = &tls.Config{}
}
tr.TLSClientConfig.RootCAs = opts.ExtraRootCAs
}
tr.TLSClientConfig = tlsdial.Config(opts.HealthTracker, tr.TLSClientConfig)
var dialFunc netx.DialFunc
dialFunc, interceptedDial = makeScreenTimeDetectingDialFunc(opts.Dialer.SystemDial)
@@ -307,26 +367,26 @@ func NewDirect(opts Options) (*Direct, error) {
}
c := &Direct{
httpc: httpc,
interceptedDial: interceptedDial,
controlKnobs: opts.ControlKnobs,
getMachinePrivKey: opts.GetMachinePrivateKey,
serverURL: opts.ServerURL,
clock: opts.Clock,
logf: opts.Logf,
persist: opts.Persist.View(),
authKey: opts.AuthKey,
debugFlags: opts.DebugFlags,
netMon: netMon,
health: opts.HealthTracker,
skipIPForwardingCheck: opts.SkipIPForwardingCheck,
pinger: opts.Pinger,
polc: cmp.Or(opts.PolicyClient, policyclient.Client(policyclient.NoPolicyClient{})),
popBrowser: opts.PopBrowserURL,
c2nHandler: opts.C2NHandler,
dialer: opts.Dialer,
dnsCache: dnsCache,
dialPlan: opts.DialPlan,
httpc: httpc,
interceptedDial: interceptedDial,
controlKnobs: opts.ControlKnobs,
getMachinePrivKey: opts.GetMachinePrivateKey,
serverURL: opts.ServerURL,
clock: opts.Clock,
logf: opts.Logf,
persist: opts.Persist.View(),
authKey: opts.AuthKey,
debugFlags: opts.DebugFlags,
netMon: netMon,
health: opts.HealthTracker,
extraRootCAs: opts.ExtraRootCAs,
pinger: opts.Pinger,
polc: cmp.Or(opts.PolicyClient, policyclient.Client(policyclient.NoPolicyClient{})),
popBrowser: opts.PopBrowserURL,
c2nHandler: opts.C2NHandler,
dialer: opts.Dialer,
dnsCache: dnsCache,
dialPlan: opts.DialPlan,
}
c.discoPubKey = opts.DiscoPublicKey
c.closedCtx, c.closeCtx = context.WithCancel(context.Background())
@@ -382,7 +442,7 @@ func (c *Direct) SetHostinfo(hi *tailcfg.Hostinfo) bool {
if hi == nil {
panic("nil Hostinfo")
}
hi = ptr.To(*hi)
hi = new(*hi)
hi.NetInfo = nil
c.mu.Lock()
defer c.mu.Unlock()
@@ -528,6 +588,49 @@ var macOSScreenTime = health.Register(&health.Warnable{
ImpactsConnectivity: true,
})
type rateLimitError struct {
msg string
retryAfter time.Duration
}
func (e *rateLimitError) Error() string {
return fmt.Sprintf("rate limited: %s (retry after %v)", e.msg, e.retryAfter)
}
func parseRateLimitError(res *http.Response) *rateLimitError {
msg, _ := io.ReadAll(res.Body)
res.Body.Close()
ret := &rateLimitError{
msg: strings.TrimSpace(string(msg)),
}
v := res.Header.Get("Retry-After")
if i, err := strconv.Atoi(v); err == nil {
ret.retryAfter = time.Duration(i) * time.Second
} else if t, err := http.ParseTime(v); err == nil {
ret.retryAfter = time.Until(t)
}
// If the server didn't give us a valid Retry-After, default to 10s.
if ret.retryAfter <= 0 || ret.retryAfter > time.Hour {
ret.retryAfter = 5*time.Second + rand.N(5*time.Second)
}
return ret
}
// isRateLimitedResponse reports whether a non-200 response should be
// treated as a rate limit rather than a generic failure subject to backoff.
func isRateLimitedResponse(res *http.Response) bool {
switch res.StatusCode {
case http.StatusTooManyRequests:
return true
case http.StatusServiceUnavailable:
return res.Header.Get("Retry-After") != ""
}
return false
}
func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, newURL string, nks tkatype.MarshaledSignature, err error) {
if c.panicOnUse {
panic("tainted client")
@@ -722,6 +825,12 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
if err != nil {
return regen, opt.URL, nil, fmt.Errorf("register request: %w", err)
}
// Handle 429 Too Many Requests with a specific error type that includes the retry-after duration.
if isRateLimitedResponse(res) {
rle := parseRateLimitError(res)
msg := fmt.Sprintf("node registration rate limited; will retry after %v", rle.retryAfter)
return false, "", nil, vizerror.WrapWithMessage(rle, msg)
}
if res.StatusCode != 200 {
msg, _ := io.ReadAll(res.Body)
res.Body.Close()
@@ -743,7 +852,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
resp.NodeKeyExpired, resp.MachineAuthorized, resp.AuthURL != "")
if resp.Error != "" {
return false, "", nil, UserVisibleError(resp.Error)
return false, "", nil, vizerror.New(resp.Error)
}
if len(resp.NodeKeySignature) > 0 {
return true, "", resp.NodeKeySignature, nil
@@ -828,21 +937,34 @@ func (c *Direct) PollNetMap(ctx context.Context, nu NetmapUpdater) error {
return c.sendMapRequest(ctx, true, nu)
}
// rememberLastNetmapUpdater is a container that remembers the last netmap
// update it observed. It is used by tests and [NetmapFromMapResponseForDebug].
// It will report only the first netmap seen.
type rememberLastNetmapUpdater struct {
last *netmap.NetworkMap
done chan any
}
func (nu *rememberLastNetmapUpdater) UpdateFullNetmap(nm *netmap.NetworkMap) {
nu.last = nm
select {
case nu.done <- nil:
default:
}
}
// FetchNetMapForTest fetches the netmap once.
func (c *Direct) FetchNetMapForTest(ctx context.Context) (*netmap.NetworkMap, error) {
var nu rememberLastNetmapUpdater
nu.done = make(chan any, 1)
err := c.sendMapRequest(ctx, false, &nu)
if err == nil && nu.last == nil {
if err != nil {
return nil, err
}
if nu.last == nil {
return nil, errors.New("[unexpected] sendMapRequest success without callback")
}
<-nu.done
return nu.last, err
}
@@ -861,6 +983,18 @@ func (c *Direct) SetDiscoPublicKey(key key.DiscoPublic) {
c.discoPubKey = key
}
// SetIPForwardingBroken updates the IP forwarding broken state.
// It reports whether the value changed.
func (c *Direct) SetIPForwardingBroken(v bool) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.ipForwardBroken == v {
return false
}
c.ipForwardBroken = v
return true
}
// ClientID returns the controlClientID of the controlClient.
func (c *Direct) ClientID() int64 {
return c.controlClientID
@@ -991,10 +1125,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
}
var extraDebugFlags []string
if buildfeatures.HasAdvertiseRoutes && hi != nil && c.netMon != nil && !c.skipIPForwardingCheck &&
ipForwardingBroken(hi.RoutableIPs, c.netMon.InterfaceState()) {
extraDebugFlags = append(extraDebugFlags, "warn-ip-forwarding-off")
}
if c.health.RouterHealth() != nil {
extraDebugFlags = append(extraDebugFlags, "warn-router-unhealthy")
}
@@ -1017,6 +1147,15 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// The map response body is a stream of size-prefixed messages. Each
// message is capped by maxCompressedMapResponseSize and
// maxDecodedMapResponseSize, but a streaming session can carry an
// unbounded number of messages over one body, so the body itself must
// not be subject to ts2021.Client.Do's default response size cap.
// This must happen before the watchdog goroutine below captures ctx:
// reassigning ctx after the goroutine exists is a data race.
ctx = httpbody.WithMaxSize(ctx, 0)
machinePubKey := machinePrivKey.Public()
t0 := c.clock.Now()
@@ -1064,6 +1203,13 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
}
vlogf("netmap: Do = %v after %v", res.StatusCode, time.Since(t0).Round(time.Millisecond))
if res.StatusCode != 200 {
// The body is an error message rather than a message stream, so
// cap it: the map body itself is uncapped per the comment above.
httpbody.LimitSizeTo(res, httpbody.DefaultMaxSize)
if isRateLimitedResponse(res) {
rle := parseRateLimitError(res)
return fmt.Errorf("initial fetch failed %d: %w", res.StatusCode, rle)
}
msg, _ := io.ReadAll(res.Body)
res.Body.Close()
return fmt.Errorf("initial fetch failed %d: %.200s",
@@ -1120,19 +1266,22 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
for mapResIdx := 0; mapResIdx == 0 || isStreaming; mapResIdx++ {
watchdogTimer.Reset(watchdogTimeout)
vlogf("netmap: starting size read after %v (poll %v)", time.Since(t0).Round(time.Millisecond), mapResIdx)
var siz [4]byte
if _, err := io.ReadFull(res.Body, siz[:]); err != nil {
vlogf("netmap: size read error after %v: %v", time.Since(t0).Round(time.Millisecond), err)
msg, err = readMapResponseMessage(res.Body, msg)
if err != nil {
// If the read failed because the poll's context was
// canceled, report that instead of the underlying
// transport error. Which error the transport returns for
// a read interrupted by cancellation varies by transport
// and Go version (Go 1.27's http2 returns the underlying
// "use of closed network connection" where earlier
// versions returned the context error).
if ctx.Err() != nil {
err = ctx.Err()
}
vlogf("netmap: message read error after %v: %v", time.Since(t0).Round(time.Millisecond), err)
return err
}
size := binary.LittleEndian.Uint32(siz[:])
vlogf("netmap: read size %v after %v", size, time.Since(t0).Round(time.Millisecond))
msg = append(msg[:0], make([]byte, size)...)
if _, err := io.ReadFull(res.Body, msg); err != nil {
vlogf("netmap: body read error: %v", err)
return err
}
vlogf("netmap: read body after %v", time.Since(t0).Round(time.Millisecond))
vlogf("netmap: read message of %d bytes after %v", len(msg), time.Since(t0).Round(time.Millisecond))
var resp tailcfg.MapResponse
if err := sess.decodeMsg(msg, &resp); err != nil {
@@ -1187,12 +1336,12 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
// DefaultAutoUpdate in its CapMap and deprecated top-level field forms.
if self := resp.Node; self != nil {
for _, v := range self.CapMap[tailcfg.NodeAttrDefaultAutoUpdate] {
for _, v := range self.CapMap[nodecap.DefaultAutoUpdate] {
switch v {
case "true", "false":
c.autoUpdatePub.Publish(AutoUpdate{c.controlClientID, v == "true"})
default:
c.logf("netmap: [unexpected] unknown %s in CapMap: %q", tailcfg.NodeAttrDefaultAutoUpdate, v)
c.logf("netmap: [unexpected] unknown %s in CapMap: %q", nodecap.DefaultAutoUpdate, v)
}
}
}
@@ -1230,8 +1379,11 @@ func NetmapFromMapResponseForDebug(ctx context.Context, pr persist.PersistView,
if resp.Node == nil {
return nil, errors.New("MapResponse lacks Node")
}
if !pr.Valid() {
return nil, errors.New("PersistView invalid")
}
nu := &rememberLastNetmapUpdater{}
nu := &rememberLastNetmapUpdater{done: make(chan any, 1)}
sess := newMapSession(pr.PrivateNodeKey(), nu, nil)
defer sess.Close()
@@ -1239,6 +1391,7 @@ func NetmapFromMapResponseForDebug(ctx context.Context, pr persist.PersistView,
return nil, fmt.Errorf("HandleNonKeepAliveMapResponse: %w", err)
}
<-nu.done
return sess.netmap(), nil
}
@@ -1298,20 +1451,53 @@ var jsonEscapedZero = []byte(`\u0000`)
const justKeepAliveStr = `{"KeepAlive":true}`
// maxCompressedMapResponseSize bounds the on-the-wire size of a single map
// response message. The size prefix is a little-endian uint32 the control
// server chooses, so without a cap a malicious control server can make us
// allocate up to 4 GiB before reading any body bytes. Real production traffic
// has hit a 16 MB cap before, so the cap stays far above any plausible
// legitimate size.
const maxCompressedMapResponseSize = 256 << 20
// maxDecodedMapResponseSize bounds the decompressed size of a single map
// response message, so a malicious control server can't expand a small zstd
// frame into an unbounded amount of JSON.
const maxDecodedMapResponseSize = 1 << 30
// readMapResponseMessage reads a single map response message from r: a
// little-endian uint32 size followed by that many bytes. The returned message
// reuses msg's backing storage. Sizes beyond [maxCompressedMapResponseSize]
// are rejected before any allocation.
func readMapResponseMessage(r io.Reader, msg []byte) ([]byte, error) {
var siz [4]byte
if _, err := io.ReadFull(r, siz[:]); err != nil {
return msg, err
}
size := binary.LittleEndian.Uint32(siz[:])
if size > maxCompressedMapResponseSize {
return msg, fmt.Errorf("map response message size %d exceeds max %d", size, maxCompressedMapResponseSize)
}
msg = append(msg[:0], make([]byte, size)...)
if _, err := io.ReadFull(r, msg); err != nil {
return msg, err
}
return msg, nil
}
// decodeMsg is responsible for uncompressing msg and unmarshaling into v.
func (sess *mapSession) decodeMsg(compressedMsg []byte, v *tailcfg.MapResponse) error {
func (ms *mapSession) decodeMsg(compressedMsg []byte, v *tailcfg.MapResponse) error {
// Fast path for common case of keep-alive message.
// See tailscale/tailscale#17343.
if sess.keepAliveZ != nil && bytes.Equal(compressedMsg, sess.keepAliveZ) {
if ms.keepAliveZ != nil && bytes.Equal(compressedMsg, ms.keepAliveZ) {
v.KeepAlive = true
return nil
}
b, err := zstdframe.AppendDecode(nil, compressedMsg)
b, err := zstdframe.AppendDecode(nil, compressedMsg, zstdframe.MaxDecodedSize(maxDecodedMapResponseSize))
if err != nil {
return err
}
sess.ztdDecodesForTest++
ms.ztdDecodesForTest++
if DevKnob.DumpNetMaps() {
var buf bytes.Buffer
@@ -1326,7 +1512,7 @@ func (sess *mapSession) decodeMsg(compressedMsg []byte, v *tailcfg.MapResponse)
return fmt.Errorf("response: %v", err)
}
if v.KeepAlive && string(b) == justKeepAliveStr {
sess.keepAliveZ = compressedMsg
ms.keepAliveZ = compressedMsg
}
return nil
}
@@ -1357,7 +1543,8 @@ func loadServerPubKeys(ctx context.Context, httpc *http.Client, serverURL string
return nil, fmt.Errorf("fetch control key: %v", err)
}
defer res.Body.Close()
b, err := io.ReadAll(io.LimitReader(res.Body, 64<<10))
httpbody.LimitSizeTo(res, 64<<10)
b, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("fetch control key response: %v", err)
}
@@ -1410,24 +1597,6 @@ func initDevKnob() devKnobs {
var clock tstime.Clock = tstime.StdClock{}
// ipForwardingBroken reports whether the system's IP forwarding is disabled
// and will definitely not work for the routes provided.
//
// It should not return false positives.
//
// TODO(bradfitz): Change controlclient.Options.SkipIPForwardingCheck into a
// func([]netip.Prefix) error signature instead.
func ipForwardingBroken(routes []netip.Prefix, state *netmon.State) bool {
warn, err := netutil.CheckIPForwarding(routes, state)
if err != nil {
// Oh well, we tried. This is just for debugging.
// We don't want false positives.
// TODO: maybe we want a different warning for inability to check?
return false
}
return warn != nil
}
// isUniquePingRequest reports whether pr contains a new PingRequest.URL
// not already handled, noting its value when returning true.
func (c *Direct) isUniquePingRequest(pr *tailcfg.PingRequest) bool {
@@ -1481,7 +1650,7 @@ func (c *Direct) answerPing(pr *tailcfg.PingRequest) {
}
return
}
for _, t := range strings.Split(pr.Types, ",") {
for t := range strings.SplitSeq(pr.Types, ",") {
switch pt := tailcfg.PingType(t); pt {
case tailcfg.PingTSMP, tailcfg.PingDisco, tailcfg.PingICMP, tailcfg.PingPeerAPI:
go doPingerPing(c.logf, httpc, pr, c.pinger, pt)
@@ -1567,6 +1736,7 @@ func (c *Direct) getNoiseClient() (*ts2021.Client, error) {
Logf: c.logf,
NetMon: c.netMon,
HealthTracker: c.health,
ExtraRootCAs: c.extraRootCAs,
DialPlan: dp,
})
if err != nil {
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlclient
+187 -30
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlclient
@@ -9,9 +9,11 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"maps"
"net"
"net/netip"
"reflect"
"runtime"
"runtime/debug"
@@ -24,11 +26,11 @@ import (
"tailscale.com/envknob"
"tailscale.com/hostinfo"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/ptr"
"tailscale.com/types/views"
"tailscale.com/util/clientmetric"
"tailscale.com/util/mak"
@@ -80,8 +82,7 @@ type mapSession struct {
// Fields storing state over the course of multiple MapResponses.
lastPrintMap time.Time
lastNode tailcfg.NodeView
lastCapSet set.Set[tailcfg.NodeCapability]
peers map[tailcfg.NodeID]tailcfg.NodeView
lastCapSet set.Set[nodecap.Cap]
lastDNSConfig *tailcfg.DNSConfig
lastDERPMap *tailcfg.DERPMap
lastUserProfile map[tailcfg.UserID]tailcfg.UserProfileView
@@ -97,6 +98,10 @@ type mapSession struct {
lastPopBrowserURL string
lastTKAInfo *tailcfg.TKAInfo
lastNetmapSummary string // from NetworkMap.VeryConcise
// mu protects the peers map.
peersMu sync.RWMutex
peers map[tailcfg.NodeID]tailcfg.NodeView
}
// newMapSession returns a mostly unconfigured new mapSession.
@@ -146,12 +151,17 @@ func (ms *mapSession) Close() {
ms.sessionAliveCtxClose()
}
var ErrChangeQueueClosed = errors.New("change queue closed")
// HandleNonKeepAliveMapResponse handles a non-KeepAlive MapResponse (full or
// incremental).
//
// All fields that are valid on a KeepAlive MapResponse have already been
// handled.
//
// Debug messages are handled first, followed by pushing the response onto a
// queue for new updates handled sequentially.
//
// TODO(bradfitz): make this handle all fields later. For now (2023-08-20) this
// is [re]factoring progress enough.
func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *tailcfg.MapResponse) error {
@@ -160,7 +170,6 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
return err
}
}
if DevKnob.StripEndpoints() {
for _, p := range resp.Peers {
p.Endpoints = nil
@@ -203,9 +212,11 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
ms.updateStateFromResponse(resp)
if ms.tryHandleIncrementally(resp) {
metricMapResponseHandledIncrementally.Add(1)
ms.occasionallyPrintSummary(ms.lastNetmapSummary)
return nil
}
metricMapResponseHandledFullRebuild.Add(1)
// We have to rebuild the whole netmap (lots of garbage & work downstream of
// our UpdateFullNetmap call). This is the part we tried to avoid but
@@ -242,7 +253,7 @@ func upgradeNode(n *tailcfg.Node) {
if ip == tailcfg.DerpMagicIP && err == nil {
port, err := strconv.Atoi(portStr)
if err == nil {
n.HomeDERP = port
n.HomeDERP = tailcfg.DERPRegionID(port)
}
}
}
@@ -255,6 +266,12 @@ func upgradeNode(n *tailcfg.Node) {
if n.AllowedIPs == nil {
n.AllowedIPs = slices.Clone(n.Addresses)
}
// Unsigned peers aren't covered by tailnet lock, so a (possibly malicious)
// control server must not grant them network access via advertised routes.
// Strip any AllowedIPs beyond their own addresses.
if n.UnsignedPeerAPIOnly && !slices.Equal(n.AllowedIPs, n.Addresses) {
n.AllowedIPs = slices.Clone(n.Addresses)
}
}
func (ms *mapSession) tryHandleIncrementally(res *tailcfg.MapResponse) bool {
@@ -265,11 +282,76 @@ func (ms *mapSession) tryHandleIncrementally(res *tailcfg.MapResponse) bool {
if !ok {
return false
}
mutations, ok := netmap.MutationsFromMapResponse(res, time.Now())
if ok && len(mutations) > 0 {
// If the response carries a new packet filter, the updater must
// support pushing it narrowly; otherwise fall back to a full netmap
// rebuild. PacketFilter/PacketFilters are no longer in
// mapResponseContainsNonPatchFields, so MutationsFromMapResponse will
// happily return mutations alongside a filter change — we need to
// deliver the filter separately before those mutations land.
if res.PacketFilter != nil || res.PacketFilters != nil {
pfu, ok := ms.netmapUpdater.(PacketFilterUpdater)
if !ok {
return false
}
if !pfu.UpdatePacketFilter(ms.lastPacketFilterRules, ms.lastParsedPacketFilter) {
return false
}
}
mutations, mutationsOK := netmap.MutationsFromMapResponse(res, time.Now())
// Same shape for UserProfiles: deliver profiles before the peer
// mutations that may reference them, so bus consumers never see a
// UserID for which a profile hasn't been published.
//
// Besides the new/updated profiles carried by the response, also
// replay the profiles of upserted peers' users from
// ms.lastUserProfile. A full netmap keeps only the profiles of users
// with a currently visible peer (see [mapSession.addUserProfile]), so
// a peer returning via delta upsert can reference a user the updater
// has since dropped, and control (mapver 5+) does not resend
// unchanged profiles. Without the replay, WhoIs on the returned peer
// fails at the user profile lookup until the next full netmap.
//
// The values are read from ms.lastUserProfile (just populated by
// updateStateFromResponse) so views are shared with mapSession's
// store; downstream consumers can use [UserProfileView.Equal] for
// dedup without copying.
var profiles map[tailcfg.UserID]tailcfg.UserProfileView
addProfile := func(id tailcfg.UserID) {
if id == 0 {
return
}
if up, ok := ms.lastUserProfile[id]; ok {
mak.Set(&profiles, id, up)
}
}
for _, up := range res.UserProfiles {
addProfile(up.ID)
}
if mutationsOK {
for _, m := range mutations {
if up, ok := m.(netmap.NodeMutationUpsert); ok {
addProfile(up.Node.User())
addProfile(up.Node.Sharer())
}
}
}
if len(profiles) > 0 {
upu, ok := ms.netmapUpdater.(UserProfileUpdater)
if !ok {
return false
}
if !upu.UpdateUserProfiles(profiles) {
return false
}
}
if !mutationsOK {
return false
}
if len(mutations) > 0 {
return nud.UpdateNetmapDelta(mutations)
}
return ok
return true
}
// updateStats are some stats from updateStateFromResponse, primarily for
@@ -289,7 +371,7 @@ func (ms *mapSession) updateStateFromResponse(resp *tailcfg.MapResponse) {
if resp.Node != nil {
ms.lastNode = resp.Node.View()
capSet := set.Set[tailcfg.NodeCapability]{}
capSet := set.Set[nodecap.Cap]{}
for _, c := range resp.Node.Capabilities {
capSet.Add(c)
}
@@ -323,7 +405,7 @@ func (ms *mapSession) updateStateFromResponse(resp *tailcfg.MapResponse) {
// really the control plane should pick this. This is only a fallback.
if hostinfo.IsInVM86() {
numCanMeasure := 0
lowest := 0
var lowest tailcfg.DERPRegionID
for rid, r := range dm.Regions {
if !r.NoMeasureNoHome {
numCanMeasure++
@@ -450,11 +532,25 @@ var (
patchifiedPeer = clientmetric.NewCounter("controlclient_patchified_peer")
patchifiedPeerEqual = clientmetric.NewCounter("controlclient_patchified_peer_equal")
// metricMapResponseHandledIncrementally counts non-keepalive MapResponses
// that were processed via [mapSession.tryHandleIncrementally] (i.e. the
// "fast" delta path that avoids rebuilding the full netmap).
metricMapResponseHandledIncrementally = clientmetric.NewCounter("controlclient_map_response_handled_incrementally")
// metricMapResponseHandledFullRebuild counts non-keepalive MapResponses
// that fell through to the full netmap rebuild path because they
// carried a field that the incremental path can't handle. See
// [netmap.mapResponseContainsNonPatchFields].
metricMapResponseHandledFullRebuild = clientmetric.NewCounter("controlclient_map_response_handled_full_rebuild")
)
// updatePeersStateFromResponseres updates ms.peers from resp.
// It takes ownership of resp.
func (ms *mapSession) updatePeersStateFromResponse(resp *tailcfg.MapResponse) (stats updateStats) {
ms.peersMu.Lock()
defer ms.peersMu.Unlock()
if ms.peers == nil {
ms.peers = make(map[tailcfg.NodeID]tailcfg.NodeView)
}
@@ -504,7 +600,7 @@ func (ms *mapSession) updatePeersStateFromResponse(resp *tailcfg.MapResponse) (s
if vp, ok := ms.peers[nodeID]; ok {
mut := vp.AsStruct()
if seen {
mut.LastSeen = ptr.To(clock.Now())
mut.LastSeen = new(clock.Now())
} else {
mut.LastSeen = nil
}
@@ -516,7 +612,7 @@ func (ms *mapSession) updatePeersStateFromResponse(resp *tailcfg.MapResponse) (s
for nodeID, online := range resp.OnlineChange {
if vp, ok := ms.peers[nodeID]; ok {
mut := vp.AsStruct()
mut.Online = ptr.To(online)
mut.Online = new(online)
ms.peers[nodeID] = mut.View()
stats.changed++
}
@@ -550,11 +646,11 @@ func (ms *mapSession) updatePeersStateFromResponse(resp *tailcfg.MapResponse) (s
patchDiscoKey.Add(1)
}
if v := pc.Online; v != nil {
mut.Online = ptr.To(*v)
mut.Online = new(*v)
patchOnline.Add(1)
}
if v := pc.LastSeen; v != nil {
mut.LastSeen = ptr.To(*v)
mut.LastSeen = new(*v)
patchLastSeen.Add(1)
}
if v := pc.KeyExpiry; v != nil {
@@ -589,13 +685,22 @@ func (ms *mapSession) addUserProfile(nm *netmap.NetworkMap, userID tailcfg.UserI
}
var debugPatchifyPeer = envknob.RegisterBool("TS_DEBUG_PATCHIFY_PEER")
var debugPatchifyPeerMiss = envknob.RegisterBool("TS_DEBUG_PATCHIFY_PEER_MISS")
// patchifyMissOnFalse, if non-nil, is called with the field name when
// patchifyPeer fails. It is set by an init func in map_debug.go.
var patchifyMissOnFalse func(string)
// patchifyPeersChanged mutates resp to promote PeersChanged entries to PeersChangedPatch
// when possible.
func (ms *mapSession) patchifyPeersChanged(resp *tailcfg.MapResponse) {
var onFalse func(string)
if debugPatchifyPeerMiss() {
onFalse = patchifyMissOnFalse
}
filtered := resp.PeersChanged[:0]
for _, n := range resp.PeersChanged {
if p, ok := ms.patchifyPeer(n); ok {
if p, ok := ms.patchifyPeer(n, onFalse); ok {
patchifiedPeer.Add(1)
if debugPatchifyPeer() {
patchj, _ := json.Marshal(p)
@@ -618,12 +723,12 @@ func (ms *mapSession) patchifyPeersChanged(resp *tailcfg.MapResponse) {
var nodeFields = sync.OnceValue(getNodeFields)
// getNodeFields returns the fails of tailcfg.Node.
// getNodeFields returns the fields of tailcfg.Node.
func getNodeFields() []string {
rt := reflect.TypeFor[tailcfg.Node]()
ret := make([]string, rt.NumField())
for i := range rt.NumField() {
ret[i] = rt.Field(i).Name
ret := make([]string, 0, rt.NumField())
for f := range rt.Fields() {
ret = append(ret, f.Name)
}
return ret
}
@@ -633,18 +738,27 @@ func getNodeFields() []string {
//
// It returns ok=false if a patch can't be made, (V, ok) on a delta, or (nil,
// true) if all the fields were identical (a zero change).
func (ms *mapSession) patchifyPeer(n *tailcfg.Node) (_ *tailcfg.PeerChange, ok bool) {
func (ms *mapSession) patchifyPeer(n *tailcfg.Node, onFalse func(string)) (_ *tailcfg.PeerChange, ok bool) {
ms.peersMu.RLock()
defer ms.peersMu.RUnlock()
was, ok := ms.peers[n.ID]
if !ok {
if onFalse != nil {
onFalse("peer_not_found")
}
return nil, false
}
return peerChangeDiff(was, n)
return peerChangeDiff(was, n, onFalse)
}
// peerChangeDiff returns the difference from 'was' to 'n', if possible.
//
// It returns (nil, true) if the fields were identical.
func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChange, ok bool) {
func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node, onFalse func(string)) (_ *tailcfg.PeerChange, ok bool) {
if onFalse == nil {
onFalse = func(string) {}
}
var ret *tailcfg.PeerChange
pc := func() *tailcfg.PeerChange {
if ret == nil {
@@ -661,38 +775,43 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
case "computedHostIfDifferent", "ComputedName", "ComputedNameWithHost":
// Caller's responsibility to have populated these.
continue
case "DataPlaneAuditLogID":
// Not sent for peers.
case "DataPlaneAuditLogID", "StableTailnetID":
// Not sent for peers.
case "Capabilities":
// Deprecated; see https://github.com/tailscale/tailscale/issues/11508
// And it was never sent by any known control server.
case "ID":
if was.ID() != n.ID {
onFalse(field)
return nil, false
}
case "StableID":
if was.StableID() != n.StableID {
onFalse(field)
return nil, false
}
case "Name":
if was.Name() != n.Name {
onFalse(field)
return nil, false
}
case "User":
if was.User() != n.User {
onFalse(field)
return nil, false
}
case "Sharer":
if was.Sharer() != n.Sharer {
onFalse(field)
return nil, false
}
case "Key":
if was.Key() != n.Key {
pc().Key = ptr.To(n.Key)
pc().Key = new(n.Key)
}
case "KeyExpiry":
if !was.KeyExpiry().Equal(n.KeyExpiry) {
pc().KeyExpiry = ptr.To(n.KeyExpiry)
pc().KeyExpiry = new(n.KeyExpiry)
}
case "KeySignature":
if !was.KeySignature().Equal(n.KeySignature) {
@@ -700,18 +819,21 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
}
case "Machine":
if was.Machine() != n.Machine {
onFalse(field)
return nil, false
}
case "DiscoKey":
if was.DiscoKey() != n.DiscoKey {
pc().DiscoKey = ptr.To(n.DiscoKey)
pc().DiscoKey = new(n.DiscoKey)
}
case "Addresses":
if !views.SliceEqual(was.Addresses(), views.SliceOf(n.Addresses)) {
onFalse(field)
return nil, false
}
case "AllowedIPs":
if !views.SliceEqual(was.AllowedIPs(), views.SliceOf(n.AllowedIPs)) {
onFalse(field)
return nil, false
}
case "Endpoints":
@@ -731,13 +853,16 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
continue
}
if !was.Hostinfo().Valid() || !n.Hostinfo.Valid() {
onFalse(field)
return nil, false
}
if !was.Hostinfo().Equal(n.Hostinfo) {
onFalse(field)
return nil, false
}
case "Created":
if !was.Created().Equal(n.Created) {
onFalse(field)
return nil, false
}
case "Cap":
@@ -765,38 +890,45 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
}
case "Tags":
if !views.SliceEqual(was.Tags(), views.SliceOf(n.Tags)) {
onFalse(field)
return nil, false
}
case "PrimaryRoutes":
if !views.SliceEqual(was.PrimaryRoutes(), views.SliceOf(n.PrimaryRoutes)) {
onFalse(field)
return nil, false
}
case "Online":
if wasOnline, ok := was.Online().GetOk(); ok && n.Online != nil && *n.Online != wasOnline {
pc().Online = ptr.To(*n.Online)
pc().Online = new(*n.Online)
}
case "LastSeen":
if wasSeen, ok := was.LastSeen().GetOk(); ok && n.LastSeen != nil && !wasSeen.Equal(*n.LastSeen) {
pc().LastSeen = ptr.To(*n.LastSeen)
pc().LastSeen = new(*n.LastSeen)
}
case "MachineAuthorized":
if was.MachineAuthorized() != n.MachineAuthorized {
onFalse(field)
return nil, false
}
case "UnsignedPeerAPIOnly":
if was.UnsignedPeerAPIOnly() != n.UnsignedPeerAPIOnly {
onFalse(field)
return nil, false
}
case "IsWireGuardOnly":
if was.IsWireGuardOnly() != n.IsWireGuardOnly {
onFalse(field)
return nil, false
}
case "IsJailed":
if was.IsJailed() != n.IsJailed {
onFalse(field)
return nil, false
}
case "Expired":
if was.Expired() != n.Expired {
onFalse(field)
return nil, false
}
case "SelfNodeV4MasqAddrForThisPeer":
@@ -805,6 +937,7 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
continue
}
if va, ok := va.GetOk(); !ok || vb == nil || va != *vb {
onFalse(field)
return nil, false
}
case "SelfNodeV6MasqAddrForThisPeer":
@@ -813,17 +946,20 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
continue
}
if va, ok := va.GetOk(); !ok || vb == nil || va != *vb {
onFalse(field)
return nil, false
}
case "ExitNodeDNSResolvers":
va, vb := was.ExitNodeDNSResolvers(), views.SliceOfViews(n.ExitNodeDNSResolvers)
if va.Len() != vb.Len() {
onFalse(field)
return nil, false
}
for i := range va.Len() {
if !va.At(i).Equal(vb.At(i)) {
onFalse(field)
return nil, false
}
}
@@ -836,7 +972,28 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
return ret, true
}
// PeerIDAndKeyByTailscaleIP returns the node ID and node Key from the peers
// map without touching the netmap itself. The implementation mirrors the
// implementation of [netmap.PeerByTailscaleIP].
func (ms *mapSession) PeerIDAndKeyByTailscaleIP(ip netip.Addr) (tailcfg.NodeID, key.NodePublic, bool) {
ms.peersMu.RLock()
defer ms.peersMu.RUnlock()
for _, n := range ms.peers {
ad := n.Addresses()
for i := range ad.Len() {
a := ad.At(i)
if a.Addr() == ip {
return n.ID(), n.Key(), true
}
}
}
return 0, key.NodePublic{}, false
}
func (ms *mapSession) sortedPeers() []tailcfg.NodeView {
ms.peersMu.RLock()
defer ms.peersMu.RUnlock()
ret := slicesx.MapValues(ms.peers)
slices.SortFunc(ret, func(a, b tailcfg.NodeView) int {
return cmp.Compare(a.ID(), b.ID())
+16
View File
@@ -0,0 +1,16 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_debug
package controlclient
import "tailscale.com/metrics"
var patchifyMissStats = metrics.NewLabelMap("counter_patchify_miss", "why")
func init() {
patchifyMissOnFalse = func(field string) {
patchifyMissStats.Add(field, 1)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlclient
+2 -4
View File
@@ -1,9 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build windows
// darwin,cgo is also supported by certstore but untested, so it is not enabled.
//go:build windows || (darwin && !ios && cgo)
package controlclient
+2 -2
View File
@@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows
//go:build (!windows && !(darwin && cgo)) || ios
package controlclient
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlclient
+5 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js
@@ -459,7 +459,7 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, optAddr netip.Ad
}()
}
tr := http.DefaultTransport.(*http.Transport).Clone()
tr := netutil.NewDefaultTransport()
defer tr.CloseIdleConnections()
if optACEHost != "" {
// If using ACE, we don't want to use any HTTP proxy.
@@ -479,6 +479,9 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, optAddr netip.Ad
// Disable HTTP2, since h2 can't do protocol switching.
tr.TLSClientConfig.NextProtos = []string{}
tr.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
if a.ExtraRootCAs != nil {
tr.TLSClientConfig.RootCAs = a.ExtraRootCAs
}
tr.TLSClientConfig = tlsdial.Config(a.HealthTracker, tr.TLSClientConfig)
if !tr.TLSClientConfig.InsecureSkipVerify {
panic("unexpected") // should be set by tlsdial.Config
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlhttp
+4 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlhttp
@@ -32,7 +32,9 @@ func (d *Dialer) Dial(ctx context.Context) (*ClientConn, error) {
host := d.Hostname
// If using a custom control server (on a non-standard port), prefer that.
// This mirrors the port selection in newNoiseClient from noise.go.
if d.HTTPPort != "" && d.HTTPPort != "80" && d.HTTPSPort == "443" {
// Also use ws:// when HTTPS is explicitly disabled (NoPort), which happens
// for http:// URLs with private hostnames (e.g. http://localhost:31544).
if d.HTTPPort != "" && d.HTTPPort != "80" && (d.HTTPSPort == "443" || d.HTTPSPort == NoPort) {
wsScheme = "ws"
host = net.JoinHostPort(host, d.HTTPPort)
}
+5 -1
View File
@@ -1,9 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package controlhttp
import (
"crypto/x509"
"net/http"
"net/url"
"sync/atomic"
@@ -85,6 +86,9 @@ type Dialer struct {
// HealthTracker, if non-nil, is the health tracker to use.
HealthTracker *health.Tracker
// ExtraRootCAs, if non-nil, specifies additional trusted root CAs for TLS.
ExtraRootCAs *x509.CertPool
// DialPlan, if set, contains instructions from the control server on
// how to connect to it. If present, we will try the methods in this
// plan before falling back to DNS.
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package controlhttpcommon contains common constants for used
+103 -57
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package controlknobs contains client options configurable from control which can be turned on
@@ -12,6 +12,7 @@ import (
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tailcfg/nodecap"
"tailscale.com/types/opt"
)
@@ -21,16 +22,11 @@ type Knobs struct {
// DisableUPnP indicates whether to attempt UPnP mapping.
DisableUPnP atomic.Bool
// KeepFullWGConfig is whether we should disable the lazy wireguard
// programming and instead give WireGuard the full netmap always, even for
// idle peers.
KeepFullWGConfig atomic.Bool
// RandomizeClientPort is whether control says we should randomize
// the client port.
RandomizeClientPort atomic.Bool
// OneCGNAT is whether the the node should make one big CGNAT route
// OneCGNAT is whether the node should make one big CGNAT route
// in the OS rather than one /32 per peer.
OneCGNAT syncs.AtomicValue[opt.Bool]
@@ -62,12 +58,6 @@ type Knobs struct {
// netfiltering, unless overridden by the user.
LinuxForceNfTables atomic.Bool
// SeamlessKeyRenewal is whether to renew node keys without breaking connections.
// This is enabled by default in 1.90 and later, but we but we can remotely disable
// it from the control plane if there's a problem.
// http://go/seamless-key-renewal
SeamlessKeyRenewal atomic.Bool
// ProbeUDPLifetime is whether the node should probe UDP path lifetime on
// the tail end of an active direct connection in magicsock.
ProbeUDPLifetime atomic.Bool
@@ -107,6 +97,57 @@ type Knobs struct {
// of queued netmap.NetworkMap between the controlclient and LocalBackend.
// See tailscale/tailscale#14768.
DisableSkipStatusQueue atomic.Bool
// DisableHostsFileUpdates indicates that the node's DNS manager should not create
// hosts file entries when it normally would, such as when we're not the primary
// resolver on Windows or when the host is domain-joined and its primary domain
// takes precedence over MagicDNS. As of 2026-02-13, it is only used on Windows.
DisableHostsFileUpdates atomic.Bool
// ForceRegisterMagicDNSIPv4Only is whether the node should only register
// its IPv4 MagicDNS service IP and not its IPv6 one. The IPv6 one,
// tsaddr.TailscaleServiceIPv6String, still works in either case. This knob
// controls only whether we tell systemd/etc about the IPv6 one.
// See https://github.com/tailscale/tailscale/issues/15404.
// TODO(bradfitz): remove this a few releases after 2026-02-16.
ForceRegisterMagicDNSIPv4Only atomic.Bool
// EmitRuntimeMetrics is whether the node should poll and emit [runtime/metrics]
// as [tailscale.com/util/clientmetric]'s.
EmitRuntimeMetrics atomic.Bool
// DisableUDPGRO disables UDP GRO on the magicsock UDP socket. See
// [tailcfg.NodeAttrDisableUDPGRO].
DisableUDPGRO atomic.Bool
// DisableUDPGSO disables UDP GSO on the magicsock UDP socket. See
// [tailcfg.NodeAttrDisableUDPGSO].
DisableUDPGSO atomic.Bool
// DisableTUNUDPGRO disables UDP GRO on the Tailscale TUN device. See
// [tailcfg.NodeAttrDisableTUNUDPGRO].
DisableTUNUDPGRO atomic.Bool
// DisableTUNTCPGRO disables TCP GRO on the Tailscale TUN device. See
// [tailcfg.NodeAttrDisableTUNTCPGRO].
DisableTUNTCPGRO atomic.Bool
// NeverGSOEqualTail enables a UDP GSO sentinel-tail workaround in the
// underlay UDP packet TX path on Linux. Applies to magicsock and peer relay
// UDP sockets. See [tailcfg.NodeAttrNeverGSOEqualTail].
NeverGSOEqualTail atomic.Bool
// CacheNetworkMaps is whether the node should persistently cache network
// maps and use them to establish peer connectivity on start, if doing so
// is supported by the client and storage is available.
CacheNetworkMaps atomic.Bool
// ScopeQuad100OnMacOS lets sandboxed macOS scope quad-100 for simple DNS
// configs, so public queries can use the OS resolver (including DoH).
// Custom split resolvers or unrouted forward/PTR records still require
// quad-100 as primary. Off by default; has no effect on other platforms.
// See tailscale/corp#45534 and tailscale/corp#48693.
ScopeQuad100OnMacOS atomic.Bool
}
// UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
@@ -117,35 +158,41 @@ func (k *Knobs) UpdateFromNodeAttributes(capMap tailcfg.NodeCapMap) {
}
has := capMap.Contains
var (
keepFullWG = has(tailcfg.NodeAttrDebugDisableWGTrim)
disableUPnP = has(tailcfg.NodeAttrDisableUPnP)
randomizeClientPort = has(tailcfg.NodeAttrRandomizeClientPort)
disableDeltaUpdates = has(tailcfg.NodeAttrDisableDeltaUpdates)
disableUPnP = has(nodecap.DisableUPnP)
randomizeClientPort = has(nodecap.RandomizeClientPort)
disableDeltaUpdates = has(nodecap.DisableDeltaUpdates)
oneCGNAT opt.Bool
forceBackgroundSTUN = has(tailcfg.NodeAttrDebugForceBackgroundSTUN)
peerMTUEnable = has(tailcfg.NodeAttrPeerMTUEnable)
dnsForwarderDisableTCPRetries = has(tailcfg.NodeAttrDNSForwarderDisableTCPRetries)
silentDisco = has(tailcfg.NodeAttrSilentDisco)
forceIPTables = has(tailcfg.NodeAttrLinuxMustUseIPTables)
forceNfTables = has(tailcfg.NodeAttrLinuxMustUseNfTables)
seamlessKeyRenewal = has(tailcfg.NodeAttrSeamlessKeyRenewal)
disableSeamlessKeyRenewal = has(tailcfg.NodeAttrDisableSeamlessKeyRenewal)
probeUDPLifetime = has(tailcfg.NodeAttrProbeUDPLifetime)
appCStoreRoutes = has(tailcfg.NodeAttrStoreAppCRoutes)
userDialUseRoutes = has(tailcfg.NodeAttrUserDialUseRoutes)
disableSplitDNSWhenNoCustomResolvers = has(tailcfg.NodeAttrDisableSplitDNSWhenNoCustomResolvers)
disableLocalDNSOverrideViaNRPT = has(tailcfg.NodeAttrDisableLocalDNSOverrideViaNRPT)
disableCaptivePortalDetection = has(tailcfg.NodeAttrDisableCaptivePortalDetection)
disableSkipStatusQueue = has(tailcfg.NodeAttrDisableSkipStatusQueue)
forceBackgroundSTUN = has(nodecap.DebugForceBackgroundSTUN)
peerMTUEnable = has(nodecap.PeerMTUEnable)
dnsForwarderDisableTCPRetries = has(nodecap.DNSForwarderDisableTCPRetries)
silentDisco = has(nodecap.SilentDisco)
forceIPTables = has(nodecap.LinuxMustUseIPTables)
forceNfTables = has(nodecap.LinuxMustUseNfTables)
probeUDPLifetime = has(nodecap.ProbeUDPLifetime)
appCStoreRoutes = has(nodecap.StoreAppCRoutes)
userDialUseRoutes = has(nodecap.UserDialUseRoutes)
disableSplitDNSWhenNoCustomResolvers = has(nodecap.DisableSplitDNSWhenNoCustomResolvers)
disableLocalDNSOverrideViaNRPT = has(nodecap.DisableLocalDNSOverrideViaNRPT)
disableCaptivePortalDetection = has(nodecap.DisableCaptivePortalDetection)
disableSkipStatusQueue = has(nodecap.DisableSkipStatusQueue)
disableHostsFileUpdates = has(nodecap.DisableHostsFileUpdates)
forceRegisterMagicDNSIPv4Only = has(nodecap.ForceRegisterMagicDNSIPv4Only)
emitRuntimeMetrics = has(nodecap.EmitRuntimeMetrics)
disableUDPGRO = has(nodecap.DisableUDPGRO)
disableUDPGSO = has(nodecap.DisableUDPGSO)
disableTUNUDPGRO = has(nodecap.DisableTUNUDPGRO)
disableTUNTCPGRO = has(nodecap.DisableTUNTCPGRO)
neverGSOEqualTail = has(nodecap.NeverGSOEqualTail)
cacheNetworkMaps = has(nodecap.CacheNetworkMaps)
scopeQuad100OnMacOS = has(nodecap.ScopeQuad100OnMacOS)
)
if has(tailcfg.NodeAttrOneCGNATEnable) {
if has(nodecap.OneCGNATEnable) {
oneCGNAT.Set(true)
} else if has(tailcfg.NodeAttrOneCGNATDisable) {
} else if has(nodecap.OneCGNATDisable) {
oneCGNAT.Set(false)
}
k.KeepFullWGConfig.Store(keepFullWG)
k.DisableUPnP.Store(disableUPnP)
k.RandomizeClientPort.Store(randomizeClientPort)
k.OneCGNAT.Store(oneCGNAT)
@@ -163,21 +210,16 @@ func (k *Knobs) UpdateFromNodeAttributes(capMap tailcfg.NodeCapMap) {
k.DisableLocalDNSOverrideViaNRPT.Store(disableLocalDNSOverrideViaNRPT)
k.DisableCaptivePortalDetection.Store(disableCaptivePortalDetection)
k.DisableSkipStatusQueue.Store(disableSkipStatusQueue)
// If both attributes are present, then "enable" should win. This reflects
// the history of seamless key renewal.
//
// Before 1.90, seamless was a private alpha, opt-in feature. Devices would
// only seamless do if customers opted in using the seamless renewal attr.
//
// In 1.90 and later, seamless is the default behaviour, and devices will use
// seamless unless explicitly told not to by control (e.g. if we discover
// a bug and want clients to use the prior behaviour).
//
// If a customer has opted in to the pre-1.90 seamless implementation, we
// don't want to switch it off for them -- we only want to switch it off for
// devices that haven't opted in.
k.SeamlessKeyRenewal.Store(seamlessKeyRenewal || !disableSeamlessKeyRenewal)
k.DisableHostsFileUpdates.Store(disableHostsFileUpdates)
k.ForceRegisterMagicDNSIPv4Only.Store(forceRegisterMagicDNSIPv4Only)
k.EmitRuntimeMetrics.Store(emitRuntimeMetrics)
k.DisableUDPGRO.Store(disableUDPGRO)
k.DisableUDPGSO.Store(disableUDPGSO)
k.DisableTUNUDPGRO.Store(disableTUNUDPGRO)
k.DisableTUNTCPGRO.Store(disableTUNTCPGRO)
k.NeverGSOEqualTail.Store(neverGSOEqualTail)
k.CacheNetworkMaps.Store(cacheNetworkMaps)
k.ScopeQuad100OnMacOS.Store(scopeQuad100OnMacOS)
}
// AsDebugJSON returns k as something that can be marshalled with json.Marshal
@@ -187,18 +229,22 @@ func (k *Knobs) AsDebugJSON() map[string]any {
return nil
}
ret := map[string]any{}
rt := reflect.TypeFor[Knobs]()
rv := reflect.ValueOf(k).Elem() // of *k
for i := 0; i < rt.NumField(); i++ {
name := rt.Field(i).Name
switch v := rv.Field(i).Addr().Interface().(type) {
for sf, fv := range rv.Fields() {
switch v := fv.Addr().Interface().(type) {
case *atomic.Bool:
ret[name] = v.Load()
ret[sf.Name] = v.Load()
case *syncs.AtomicValue[opt.Bool]:
ret[name] = v.Load()
ret[sf.Name] = v.Load()
default:
panic(fmt.Sprintf("unknown field type %T for %v", v, name))
panic(fmt.Sprintf("unknown field type %T for %v", v, sf.Name))
}
}
return ret
}
// ShouldForceRegisterMagicDNSIPv4Only reports the value of
// ForceRegisterMagicDNSIPv4Only, or false if k is nil.
func (k *Knobs) ShouldForceRegisterMagicDNSIPv4Only() bool {
return k != nil && k.ForceRegisterMagicDNSIPv4Only.Load()
}
+26 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ts2021
@@ -7,6 +7,7 @@ import (
"bytes"
"cmp"
"context"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
@@ -28,6 +29,7 @@ import (
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/util/httpbody"
"tailscale.com/util/mak"
"tailscale.com/util/set"
)
@@ -86,6 +88,9 @@ type ClientOpts struct {
// HealthTracker, if non-nil, is the health tracker to use.
HealthTracker *health.Tracker
// ExtraRootCAs, if non-nil, specifies additional trusted root CAs for TLS.
ExtraRootCAs *x509.CertPool
// DialPlan, if set, is a function that should return an explicit plan
// on how to connect to the server.
DialPlan func() *tailcfg.ControlDialPlan
@@ -252,6 +257,7 @@ func (nc *Client) dial(ctx context.Context) (*Conn, error) {
Logf: nc.logf,
NetMon: nc.opts.NetMon,
HealthTracker: nc.opts.HealthTracker,
ExtraRootCAs: nc.opts.ExtraRootCAs,
Clock: tstime.StdClock{},
}
clientConn, err := chd.Dial(ctx)
@@ -310,3 +316,22 @@ func AddLBHeader(req *http.Request, nodeKey key.NodePublic) {
req.Header.Add(tailcfg.LBHeader, nodeKey.String())
}
}
// Do does req against the control server and caps the response body with
// httpbody.LimitSize, using the size limit in the request's context per
// [httpbody.WithMaxSize], [httpbody.DefaultMaxSize] by default, so a
// malicious or buggy control server cannot make us buffer an unbounded
// response body. Reads past the cap fail with an error wrapping
// [httpbody.ErrTooLarge] rather than silently truncating.
//
// It shadows the embedded http.Client's Do method, so all noise requests,
// including those made with [Client.Post] and [Client.DoWithBody], get the
// cap.
func (nc *Client) Do(req *http.Request) (*http.Response, error) {
res, err := nc.Client.Do(req)
if err != nil {
return nil, err
}
httpbody.LimitSize(res)
return res, nil
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package ts2021 handles the details of the Tailscale 2021 control protocol
+1 -1
View File
@@ -39,7 +39,7 @@ geographic regions to make sure users have low latency to their DERP home.
Regions generally have multiple nodes per region "meshed" (routing to each
other) together for redundancy: it allows for cloud failures or upgrades without
kicking users out to a higher latency region. Instead, clients will reconnect to
the next node in the region. Each node in the region is required to to be meshed
the next node in the region. Each node in the region is required to be meshed
with every other node in the region and forward packets to the other nodes in
the region. Packets are forwarded only one hop within the region. There is no
routing between regions. The assumption is that the mesh TCP connections are
+50 -50
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package derp implements the Designated Encrypted Relay for Packets (DERP)
@@ -27,7 +27,7 @@ import (
// including its on-wire framing overhead)
const MaxPacketSize = 64 << 10
// Magic is the DERP Magic number, sent in the frameServerKey frame
// Magic is the DERP Magic number, sent in the FrameServerKey frame
// upon initial connection.
const Magic = "DERP🔑" // 8 bytes: 0x44 45 52 50 f0 9f 94 91
@@ -58,15 +58,15 @@ Protocol flow:
Login:
* client connects
* server sends frameServerKey
* client sends frameClientInfo
* server sends frameServerInfo
* server sends FrameServerKey
* client sends FrameClientInfo
* server sends FrameServerInfo
Steady state:
* server occasionally sends frameKeepAlive (or framePing)
* client responds to any framePing with a framePong
* client sends frameSendPacket
* server then sends frameRecvPacket to recipient
* server occasionally sends FrameKeepAlive (or FramePing)
* client responds to any FramePing with a FramePong
* client sends FrameSendPacket
* server then sends FrameRecvPacket to recipient
*/
const (
FrameServerKey = FrameType(0x01) // 8B magic + 32B public key + (0+ bytes future use)
@@ -78,35 +78,38 @@ const (
FrameKeepAlive = FrameType(0x06) // no payload, no-op (to be replaced with ping/pong)
FrameNotePreferred = FrameType(0x07) // 1 byte payload: 0x01 or 0x00 for whether this is client's home node
// framePeerGone is sent from server to client to signal that
// FramePeerGone is sent from server to client to signal that
// a previous sender is no longer connected. That is, if A
// sent to B, and then if A disconnects, the server sends
// framePeerGone to B so B can forget that a reverse path
// FramePeerGone to B so B can forget that a reverse path
// exists on that connection to get back to A. It is also sent
// if A tries to send a CallMeMaybe to B and the server has no
// record of B
FramePeerGone = FrameType(0x08) // 32B pub key of peer that's gone + 1 byte reason
// framePeerPresent is like framePeerGone, but for other members of the DERP
// FramePeerPresent is like FramePeerGone, but for other members of the DERP
// region when they're meshed up together.
//
// The message is at least 32 bytes (the public key of the peer that's
// connected). If there are at least 18 bytes remaining after that, it's the
// 16 byte IP + 2 byte BE uint16 port of the client. If there's another byte
// remaining after that, it's a PeerPresentFlags byte.
// While current servers send 41 bytes, old servers will send fewer, and newer
// servers might send more.
// remaining after that, it's a PeerPresentFlags byte. If there's another
// byte remaining after that, it's the length of the client's advertised
// app name (see ClientInfo.AppName), followed by that many bytes of app
// name.
// While current servers send at least 42 bytes, old servers will send
// fewer, and newer servers might send more.
FramePeerPresent = FrameType(0x09)
// frameWatchConns is how one DERP node in a regional mesh
// FrameWatchConns is how one DERP node in a regional mesh
// subscribes to the others in the region.
// There's no payload. If the sender doesn't have permission, the connection
// is closed. Otherwise, the client is initially flooded with
// framePeerPresent for all connected nodes, and then a stream of
// framePeerPresent & framePeerGone has peers connect and disconnect.
// FramePeerPresent for all connected nodes, and then a stream of
// FramePeerPresent & FramePeerGone has peers connect and disconnect.
FrameWatchConns = FrameType(0x10)
// frameClosePeer is a privileged frame type (requires the
// FrameClosePeer is a privileged frame type (requires the
// mesh key for now) that closes the provided peer's
// connection. (To be used for cluster load balancing
// purposes, when clients end up on a non-ideal node)
@@ -115,14 +118,14 @@ const (
FramePing = FrameType(0x12) // 8 byte ping payload, to be echoed back in framePong
FramePong = FrameType(0x13) // 8 byte payload, the contents of the ping being replied to
// frameHealth is sent from server to client to tell the client
// FrameHealth is sent from server to client to tell the client
// if their connection is unhealthy somehow. Currently the only unhealthy state
// is whether the connection is detected as a duplicate.
// The entire frame body is the text of the error message. An empty message
// clears the error state.
FrameHealth = FrameType(0x14)
// frameRestarting is sent from server to client for the
// FrameRestarting is sent from server to client for the
// server to declare that it's restarting. Payload is two big
// endian uint32 durations in milliseconds: when to reconnect,
// and how long to try total. See ServerRestartingMessage docs for
@@ -140,7 +143,7 @@ const (
PeerGoneReasonMeshConnBroke = PeerGoneReasonType(0xf0) // invented by Client.RunWatchConnectionLoop on disconnect; not sent on the wire
)
// PeerPresentFlags is an optional byte of bit flags sent after a framePeerPresent message.
// PeerPresentFlags is an optional byte of bit flags sent after a FramePeerPresent message.
//
// For a modern server, the value should always be non-zero. If the value is zero,
// that means the server doesn't support this field.
@@ -168,6 +171,8 @@ const FastStartHeader = "Derp-Fast-Start"
var bin = binary.BigEndian
// writeUint32 writes v to bw one byte at a time
// as a big-endian uint32.
func writeUint32(bw *bufio.Writer, v uint32) error {
var b [4]byte
bin.PutUint32(b[:], v)
@@ -183,21 +188,6 @@ func writeUint32(bw *bufio.Writer, v uint32) error {
return nil
}
func readUint32(br *bufio.Reader) (uint32, error) {
var b [4]byte
// Reading a byte at a time is a bit silly,
// but it causes b not to escape,
// which more than pays for the silliness.
for i := range &b {
c, err := br.ReadByte()
if err != nil {
return 0, err
}
b[i] = c
}
return bin.Uint32(b[:]), nil
}
// ReadFrameTypeHeader reads a frame header from br and
// verifies that the frame type matches wantType.
//
@@ -213,18 +203,16 @@ func ReadFrameTypeHeader(br *bufio.Reader, wantType FrameType) (frameLen uint32,
return frameLen, err
}
// ReadFrameHeader reads the header of a DERP frame,
// reading 5 bytes from br.
// ReadFrameHeader reads a DERP frame header ([FrameHeaderLen] bytes) from br.
// It uses Peek+Discard to read directly from bufio's internal buffer
// without copying or allocating.
func ReadFrameHeader(br *bufio.Reader) (t FrameType, frameLen uint32, err error) {
tb, err := br.ReadByte()
hdr, err := br.Peek(FrameHeaderLen)
if err != nil {
return 0, 0, err
}
frameLen, err = readUint32(br)
if err != nil {
return 0, 0, err
}
return FrameType(tb), frameLen, nil
defer br.Discard(FrameHeaderLen)
return FrameType(hdr[0]), bin.Uint32(hdr[1:FrameHeaderLen]), nil
}
// readFrame reads a frame header and then reads its payload into
@@ -260,14 +248,26 @@ func readFrame(br *bufio.Reader, maxSize uint32, b []byte) (t FrameType, frameLe
return t, frameLen, err
}
// WriteFrameHeader writes a frame header to bw.
//
// The frame header is 5 bytes: a one byte frame type
// followed by a big-endian uint32 length of the
// remaining frame (not including the 5 byte header).
// WriteFrameHeader writes a DERP frame header to bw: a one-byte frame
// type followed by a big-endian uint32 frame length.
//
// It uses AvailableBuffer to append the header directly into bufio's
// internal buffer without allocation, falling back to WriteByte when
// the buffer has insufficient space.
// It does not flush bw.
func WriteFrameHeader(bw *bufio.Writer, t FrameType, frameLen uint32) error {
// Fast path: enough space in the buffer to append the header
// directly without allocation via AvailableBuffer.
if bw.Available() >= FrameHeaderLen {
buf := bw.AvailableBuffer()
buf = append(buf, byte(t))
buf = bin.AppendUint32(buf, frameLen)
_, err := bw.Write(buf)
return err
}
// Slow path: buffer nearly full. Write byte-at-a-time to let
// bufio flush as needed, avoiding a heap allocation from append
// growing past AvailableBuffer's capacity.
if err := bw.WriteByte(byte(t)); err != nil {
return err
}
+71 -11
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package derp
@@ -33,6 +33,7 @@ type Client struct {
meshKey key.DERPMesh
canAckPings bool
isProber bool
appName string
wmu sync.Mutex // hold while writing to bw
bw *bufio.Writer
@@ -60,6 +61,7 @@ type clientOpt struct {
ServerPub key.NodePublic
CanAckPings bool
IsProber bool
AppName string
}
// MeshKey returns a ClientOpt to pass to the DERP server during connect to get
@@ -69,7 +71,7 @@ type clientOpt struct {
func MeshKey(k key.DERPMesh) ClientOpt { return clientOptFunc(func(o *clientOpt) { o.MeshKey = k }) }
// IsProber returns a ClientOpt to pass to the DERP server during connect to
// declare that this client is a a prober.
// declare that this client is a prober.
func IsProber(v bool) ClientOpt { return clientOptFunc(func(o *clientOpt) { o.IsProber = v }) }
// ServerPublicKey returns a ClientOpt to declare that the server's DERP public key is known.
@@ -84,6 +86,31 @@ func CanAckPings(v bool) ClientOpt {
return clientOptFunc(func(o *clientOpt) { o.CanAckPings = v })
}
// AppName returns a ClientOpt to set an opaque app name string to
// advertise to the DERP server for stats purposes. It is sent to the
// server in the ClientInfo. The name must be valid per [ValidAppName]
// or NewClient returns an error.
func AppName(name string) ClientOpt {
return clientOptFunc(func(o *clientOpt) { o.AppName = name })
}
// MaxAppNameLen is the maximum length in bytes of a [ClientInfo.AppName].
const MaxAppNameLen = 32
// ValidAppName reports whether name is a valid app name: at most
// [MaxAppNameLen] bytes of printable ASCII. The empty string is valid.
func ValidAppName(name string) bool {
if len(name) > MaxAppNameLen {
return false
}
for i := range len(name) {
if b := name[i]; b < ' ' || b > '~' {
return false
}
}
return true
}
func NewClient(privateKey key.NodePrivate, nc Conn, brw *bufio.ReadWriter, logf logger.Logf, opts ...ClientOpt) (*Client, error) {
var opt clientOpt
for _, o := range opts {
@@ -92,6 +119,9 @@ func NewClient(privateKey key.NodePrivate, nc Conn, brw *bufio.ReadWriter, logf
}
o.update(&opt)
}
if !ValidAppName(opt.AppName) {
return nil, fmt.Errorf("invalid AppName %.40q", opt.AppName)
}
return newClient(privateKey, nc, brw, logf, opt)
}
@@ -106,6 +136,7 @@ func newClient(privateKey key.NodePrivate, nc Conn, brw *bufio.ReadWriter, logf
meshKey: opt.MeshKey,
canAckPings: opt.CanAckPings,
isProber: opt.IsProber,
appName: opt.AppName,
clock: tstime.StdClock{},
}
if opt.ServerPub.IsZero() {
@@ -167,7 +198,7 @@ type ClientInfo struct {
// trusted clients. It's required to subscribe to the
// connection list & forward packets. It's empty for regular
// users.
MeshKey key.DERPMesh `json:"meshKey,omitempty,omitzero"`
MeshKey key.DERPMesh `json:"meshKey,omitzero"`
// Version is the DERP protocol version that the client was built with.
// See the ProtocolVersion const.
@@ -179,6 +210,12 @@ type ClientInfo struct {
// IsProber is whether this client is a prober.
IsProber bool `json:",omitempty"`
// AppName is an optional opaque app name string the client
// advertises to the server for stats purposes. It must be
// valid per [ValidAppName] or the server rejects the
// connection.
AppName string `json:",omitempty"`
}
// Equal reports if two clientInfo values are equal.
@@ -186,7 +223,7 @@ func (c *ClientInfo) Equal(other *ClientInfo) bool {
if c == nil || other == nil {
return c == other
}
if c.Version != other.Version || c.CanAckPings != other.CanAckPings || c.IsProber != other.IsProber {
if c.Version != other.Version || c.CanAckPings != other.CanAckPings || c.IsProber != other.IsProber || c.AppName != other.AppName {
return false
}
return c.MeshKey.Equal(other.MeshKey)
@@ -198,6 +235,7 @@ func (c *Client) sendClientKey() error {
MeshKey: c.meshKey,
CanAckPings: c.canAckPings,
IsProber: c.isProber,
AppName: c.appName,
})
if err != nil {
return err
@@ -249,15 +287,18 @@ func (c *Client) send(dstKey key.NodePublic, pkt []byte) (ret error) {
return c.bw.Flush()
}
func (c *Client) ForwardPacket(srcKey, dstKey key.NodePublic, pkt []byte) (err error) {
// ForwardPacket sends a forward packet frame carrying pkt from srcKey
// to dstKey. It writes and flushes pkt before returning and does not
// retain it.
func (c *Client) ForwardPacket(srcKey, dstKey key.NodePublic, pkt LoanedBytes) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("derp.ForwardPacket: %w", err)
}
}()
if len(pkt) > MaxPacketSize {
return fmt.Errorf("packet too big: %d", len(pkt))
if pkt.Len() > MaxPacketSize {
return fmt.Errorf("packet too big: %d", pkt.Len())
}
c.wmu.Lock()
@@ -266,7 +307,7 @@ func (c *Client) ForwardPacket(srcKey, dstKey key.NodePublic, pkt []byte) (err e
timer := c.clock.AfterFunc(5*time.Second, c.writeTimeoutFired)
defer timer.Stop()
if err := WriteFrameHeader(c.bw, FrameForwardPacket, uint32(KeyLen*2+len(pkt))); err != nil {
if err := WriteFrameHeader(c.bw, FrameForwardPacket, uint32(KeyLen*2+pkt.Len())); err != nil {
return err
}
if _, err := c.bw.Write(srcKey.AppendTo(nil)); err != nil {
@@ -275,7 +316,7 @@ func (c *Client) ForwardPacket(srcKey, dstKey key.NodePublic, pkt []byte) (err e
if _, err := c.bw.Write(dstKey.AppendTo(nil)); err != nil {
return err
}
if _, err := c.bw.Write(pkt); err != nil {
if _, err := pkt.WriteTo(c.bw); err != nil {
return err
}
return c.bw.Flush()
@@ -394,6 +435,10 @@ type PeerPresentMessage struct {
IPPort netip.AddrPort
// Flags is a bitmask of info about the client.
Flags PeerPresentFlags
// AppName is the optional app name the client advertised in
// its ClientInfo, if any. It's empty if the client didn't
// send one or the server is too old to relay it.
AppName string
}
func (PeerPresentMessage) msg() {}
@@ -554,7 +599,7 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
return sm, nil
case FrameKeepAlive:
// A one-way keep-alive message that doesn't require an acknowledgement.
// This predated framePing/framePong.
// This predated FramePing/FramePong.
return KeepAliveMessage{}, nil
case FramePeerGone:
if n < KeyLen {
@@ -594,12 +639,27 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
binary.BigEndian.Uint16(chunk[ipLen:]),
)
chunk, _, ok = cutLeadingN(remain, 1)
chunk, remain, ok = cutLeadingN(remain, 1)
if !ok {
// Older server which doesn't send PeerPresentFlags.
return msg, nil
}
msg.Flags = PeerPresentFlags(chunk[0])
chunk, remain, ok = cutLeadingN(remain, 1)
if !ok {
// Older server which doesn't send the app name.
return msg, nil
}
nameLen := int(chunk[0])
chunk, _, ok = cutLeadingN(remain, nameLen)
if !ok {
c.logf("[unexpected] short peerPresent app name from DERP server")
return msg, nil
}
if name := string(chunk); ValidAppName(name) {
msg.AppName = name
}
return msg, nil
case FrameRecvPacket:
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package derpconst contains constants used by the DERP client and server.
+35 -5
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package derphttp implements DERP-over-HTTP.
@@ -29,6 +29,7 @@ import (
"time"
"go4.org/mem"
"golang.org/x/net/http/httpguts"
"tailscale.com/derp"
"tailscale.com/derp/derpconst"
"tailscale.com/envknob"
@@ -60,6 +61,7 @@ type Client struct {
DNSCache *dnscache.Resolver // optional; nil means no caching
MeshKey key.DERPMesh // optional; for trusted clients
IsProber bool // optional; for probers to optional declare themselves as such
AppName string // optional; opaque app name to advertise to the server for stats
// WatchConnectionChanges is whether the client wishes to subscribe to
// notifications about clients connecting & disconnecting.
@@ -179,7 +181,7 @@ func NewClient(privateKey key.NodePrivate, serverURL string, logf logger.Logf, n
// isStarted reports whether this client has been used yet.
//
// If if reports false, it may still have its exported fields configured.
// If it reports false, it may still have its exported fields configured.
func (c *Client) isStarted() bool {
c.mu.Lock()
defer c.mu.Unlock()
@@ -280,10 +282,16 @@ func (c *Client) urlString(node *tailcfg.DERPNode) string {
return c.url.String()
}
proto := "https"
defaultPort := 443
if debugUseDERPHTTP() {
proto = "http"
defaultPort = 80
}
return fmt.Sprintf("%s://%s/derp", proto, node.HostName)
host := node.HostName
if node.DERPPort != 0 && node.DERPPort != defaultPort {
host = net.JoinHostPort(host, fmt.Sprint(node.DERPPort))
}
return fmt.Sprintf("%s://%s/derp", proto, host)
}
// AddressFamilySelector decides whether IPv6 is preferred for
@@ -407,6 +415,7 @@ func (c *Client) connect(ctx context.Context, caller string) (client *derp.Clien
derp.MeshKey(c.MeshKey),
derp.CanAckPings(c.canAckPings),
derp.IsProber(c.IsProber),
derp.AppName(c.AppName),
)
if err != nil {
return nil, 0, err
@@ -552,6 +561,7 @@ func (c *Client) connect(ctx context.Context, caller string) (client *derp.Clien
derp.ServerPublicKey(serverPub),
derp.CanAckPings(c.canAckPings),
derp.IsProber(c.IsProber),
derp.AppName(c.AppName),
)
if err != nil {
return nil, 0, err
@@ -836,6 +846,16 @@ func firstStr(a, b string) string {
// dialNodeUsingProxy connects to n using a CONNECT to the HTTP(s) proxy in proxyURL.
func (c *Client) dialNodeUsingProxy(ctx context.Context, n *tailcfg.DERPNode, proxyURL *url.URL) (_ net.Conn, err error) {
// n.HostName comes from the control-supplied DERP map and is written
// verbatim into the CONNECT request line and Host header below. Reject
// anything that isn't a valid host value so a hostname carrying CR/LF (or
// other control bytes) can't inject extra headers or a second request into
// the proxy connection. ValidHostHeader accepts the empty string, so check
// for that separately.
if n.HostName == "" || !httpguts.ValidHostHeader(n.HostName) {
return nil, fmt.Errorf("derphttp: invalid DERP node hostname %q", n.HostName)
}
pu := proxyURL
var proxyConn net.Conn
if pu.Scheme == "https" {
@@ -867,7 +887,15 @@ func (c *Client) dialNodeUsingProxy(ctx context.Context, n *tailcfg.DERPNode, pr
}
}()
target := net.JoinHostPort(n.HostName, "443")
// Keep port selection in sync with dialNode.
port := "443"
if !c.useHTTPS() {
port = "3340"
}
if n.DERPPort != 0 {
port = fmt.Sprint(n.DERPPort)
}
target := net.JoinHostPort(n.HostName, port)
var authHeader string
if buildfeatures.HasUseProxy {
@@ -1002,7 +1030,9 @@ func (c *Client) LocalAddr() (netip.AddrPort, error) {
return la, nil
}
func (c *Client) ForwardPacket(from, to key.NodePublic, b []byte) error {
// ForwardPacket forwards b from the node from to the node to over the
// mesh connection. It does not retain b after it returns.
func (c *Client) ForwardPacket(from, to key.NodePublic, b derp.LoanedBytes) error {
client, _, err := c.connect(c.newContext(), "derphttp.Client.ForwardPacket")
if err != nil {
return err
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package derphttp
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build js || ((linux || darwin) && ts_debug_websockets)
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !(js || ((linux || darwin) && ts_debug_websockets))
+33
View File
@@ -0,0 +1,33 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package derp
import (
"bytes"
"io"
)
// LoanedBytes is a packet payload lent to a callee for the duration of
// a call. The lender reuses the memory once the call returns, so the
// callee must not retain it; Clone gives it a copy to keep.
//
// It deliberately has no accessor for the underlying slice.
type LoanedBytes struct {
bs []byte
}
// LoanBytes lends b for the duration of the call it's passed to.
func LoanBytes(b []byte) LoanedBytes { return LoanedBytes{bs: b} }
// Len returns the number of bytes.
func (b LoanedBytes) Len() int { return len(b.bs) }
// WriteTo writes the bytes to w.
func (b LoanedBytes) WriteTo(w io.Writer) (int64, error) {
n, err := w.Write(b.bs)
return int64(n), err
}
// Clone returns a copy of the bytes that the caller owns.
func (b LoanedBytes) Clone() []byte { return bytes.Clone(b.bs) }
+14 -10
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package disco contains the discovery message types.
@@ -177,8 +177,13 @@ func parsePing(ver uint8, p []byte) (m *Ping, err error) {
// Deliberately lax on longer-than-expected messages, for future
// compatibility.
if len(p) >= key.NodePublicRawLen {
m.NodeKey = key.NodePublicFromRaw32(mem.B(p[:key.NodePublicRawLen]))
m.Padding -= key.NodePublicRawLen
// Skip all-zero trailing bytes: treat them as padding, not a NodeKey,
// to match AppendMarshal (which omits zero keys).
nk := key.NodePublicFromRaw32(mem.B(p[:key.NodePublicRawLen]))
if !nk.IsZero() {
m.NodeKey = nk
m.Padding -= key.NodePublicRawLen
}
}
return m, nil
}
@@ -307,8 +312,7 @@ func MessageSummary(m Message) string {
// BindUDPRelayHandshakeState represents the state of the 3-way bind handshake
// between UDP relay client and UDP relay server. Its potential values include
// those for both participants, UDP relay client and UDP relay server. A UDP
// relay server implementation can be found in net/udprelay. This is currently
// considered experimental.
// relay server implementation can be found in net/udprelay.
type BindUDPRelayHandshakeState int
const (
@@ -475,7 +479,7 @@ const allocateUDPRelayEndpointRequestLen = key.DiscoPublicRawLen*2 + // ClientDi
func (m *AllocateUDPRelayEndpointRequest) AppendMarshal(b []byte) []byte {
ret, p := appendMsgHeader(b, TypeAllocateUDPRelayEndpointRequest, v0, allocateUDPRelayEndpointRequestLen)
for i := 0; i < len(m.ClientDisco); i++ {
for i := range len(m.ClientDisco) {
disco := m.ClientDisco[i].AppendTo(nil)
copy(p, disco)
p = p[key.DiscoPublicRawLen:]
@@ -492,7 +496,7 @@ func parseAllocateUDPRelayEndpointRequest(ver uint8, p []byte) (m *AllocateUDPRe
if len(p) < allocateUDPRelayEndpointRequestLen {
return m, errShort
}
for i := 0; i < len(m.ClientDisco); i++ {
for i := range len(m.ClientDisco) {
m.ClientDisco[i] = key.DiscoPublicFromRaw32(mem.B(p[:key.DiscoPublicRawLen]))
p = p[key.DiscoPublicRawLen:]
}
@@ -565,7 +569,7 @@ func (m *UDPRelayEndpoint) encode(b []byte) {
disco := m.ServerDisco.AppendTo(nil)
copy(b, disco)
b = b[key.DiscoPublicRawLen:]
for i := 0; i < len(m.ClientDisco); i++ {
for i := range len(m.ClientDisco) {
disco = m.ClientDisco[i].AppendTo(nil)
copy(b, disco)
b = b[key.DiscoPublicRawLen:]
@@ -594,7 +598,7 @@ func (m *UDPRelayEndpoint) decode(b []byte) error {
}
m.ServerDisco = key.DiscoPublicFromRaw32(mem.B(b[:key.DiscoPublicRawLen]))
b = b[key.DiscoPublicRawLen:]
for i := 0; i < len(m.ClientDisco); i++ {
for i := range len(m.ClientDisco) {
m.ClientDisco[i] = key.DiscoPublicFromRaw32(mem.B(b[:key.DiscoPublicRawLen]))
b = b[key.DiscoPublicRawLen:]
}
@@ -606,7 +610,7 @@ func (m *UDPRelayEndpoint) decode(b []byte) error {
b = b[8:]
m.SteadyStateLifetime = time.Duration(binary.BigEndian.Uint64(b[:8]))
b = b[8:]
m.AddrPorts = make([]netip.AddrPort, 0, len(b)-udpRelayEndpointLenMinusAddrPorts/epLength)
m.AddrPorts = make([]netip.AddrPort, 0, len(b)/epLength)
for len(b) > 0 {
var a [16]byte
copy(a[:], b)
-17
View File
@@ -1,17 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build gofuzz
package disco
func Fuzz(data []byte) int {
m, _ := Parse(data)
newBytes := m.AppendMarshal(data)
parsedMarshall, _ := Parse(newBytes)
if m != parsedMarshall {
panic("Parsing error")
}
return 1
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package disco
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by tailscale/cmd/viewer; DO NOT EDIT.
+36 -5
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package drive provides a filesystem that allows sharing folders between
@@ -10,6 +10,7 @@
package drive
import (
"iter"
"net"
"net/http"
)
@@ -21,16 +22,46 @@ type Remote struct {
Available func() bool
}
// RemoteSource provides the current set of remote Taildrive nodes
// on demand. The drive filesystem consults Generation on each request
// and only rebuilds its internal child list when the value differs
// from the previously cached one. This lets callers avoid the
// per-netmap O(n) rebuild that an eager SetRemotes call would
// require.
//
// All methods may be called concurrently.
type RemoteSource interface {
// Domain returns the current tailnet domain under which remotes
// appear as sub-folders.
Domain() string
// Transport returns the http.RoundTripper used to reach remotes.
Transport() http.RoundTripper
// Remotes yields the current set of remote nodes.
// It is called by the drive filesystem only when Generation has
// changed since the last call.
Remotes() iter.Seq[*Remote]
// Generation returns a monotonically-increasing counter that
// changes whenever the values returned by Domain, Transport, or
// Remotes might have changed. The drive filesystem reads it on
// every request and skips the rebuild path entirely when it
// matches the previously-cached value.
Generation() uint64
}
// FileSystemForLocal is the Taildrive filesystem exposed to local clients. It
// provides a unified WebDAV interface to remote Taildrive shares on other nodes.
type FileSystemForLocal interface {
// HandleConn handles connections from local WebDAV clients
HandleConn(conn net.Conn, remoteAddr net.Addr) error
// SetRemotes sets the complete set of remotes on the given tailnet domain
// using a map of name -> url. If transport is specified, that transport
// will be used to connect to these remotes.
SetRemotes(domain string, remotes []*Remote, transport http.RoundTripper)
// SetRemoteSource sets the source from which the filesystem
// reads the current set of remotes. The source is consulted
// lazily on incoming WebDAV requests, so a stale cap or empty
// tailnet costs nothing per netmap update.
SetRemoteSource(source RemoteSource)
// Close() stops serving the WebDAV content
Close() error
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package drive
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !unix
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package drive
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build unix
+25 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package envknob provides access to environment-variable tweakable
@@ -35,6 +35,7 @@ import (
"tailscale.com/kube/kubetypes"
"tailscale.com/syncs"
"tailscale.com/types/opt"
"tailscale.com/util/testenv"
"tailscale.com/version"
"tailscale.com/version/distro"
)
@@ -110,6 +111,26 @@ func Setenv(envVar, val string) {
}
}
// SetenvForTest safely changes an environment variable in a test.
//
// It is designed to avoid leaking state between tests. It registers
// a cleanup function to reset the variable once the test completes,
// and panics if you try to set environment variables in parallel tests.
//
// Use this instead of t.Setenv() if your variable will be read by envknob,
// and you need to update envknob's internal caches with the new value.
func SetenvForTest(t testenv.TB, envVar, val string) {
t.Helper()
// This is copied from [tstest.AssertNotParallel], which we can't
// call here because it would create an import cycle.
t.Setenv("ASSERT_NOT_PARALLEL_TEST", "1") // panics if t.Parallel was called
prev := String(envVar)
Setenv(envVar, val)
t.Cleanup(func() { Setenv(envVar, prev) })
}
// String returns the named environment variable, using os.Getenv.
//
// If the variable is non-empty, it's also tracked & logged as being
@@ -405,6 +426,9 @@ func SSHIgnoreTailnetPolicy() bool { return Bool("TS_DEBUG_SSH_IGNORE_TAILNET_PO
// TKASkipSignatureCheck reports whether to skip node-key signature checking for development.
func TKASkipSignatureCheck() bool { return Bool("TS_UNSAFE_SKIP_NKS_VERIFICATION") }
// AssumeNetworkUp reports whether to assume network connectivity for development.
func AssumeNetworkUp() bool { return Bool("TS_ASSUME_NETWORK_UP_FOR_TEST") }
// App returns the tailscale app type of this instance, if set via
// TS_INTERNAL_APP env var. TS_INTERNAL_APP can be used to set app type for
// components that wrap tailscaled, such as containerboot. App type is intended
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_not_in_tests
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_not_in_tests
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package featureknob provides a facility to control whether features
+442
View File
@@ -0,0 +1,442 @@
# Modular Tailscale features
This directory contains Tailscale's modular feature system. The Tailscale
client has grown large; not every user wants every feature (an IoT device on
a few-dollar chip does not need Taildrop, WebDAV, ACME, or SSH). The
`feature/` tree is how we make individual features conditionally linkable so
that resource-constrained builds can omit them, and so that new code lives
in small self-contained packages instead of being dumped into
`LocalBackend`.
## New code lives here
**The preferred home for new functionality is a `feature/<name>` package.**
If there is any plausible user who might not want your feature in their
build (which is almost always the case), it should be modular from the
start. A feature package should install itself into the rest of Tailscale
via hooks, extensions, and registrations from its own `init` (see
"Hooks and registration" below). It should not export API used by callers
elsewhere in the tree; callers reach the feature through the hook, not by
importing it.
Think your functionality is so core that it must always be linked in?
Imagine any plausible user who might not want it. If you cannot,
[ask](#asking-questions) before dumping code into `ipn/ipnlocal` or
similar.
## The half-migrated reality
Much of the tree is in a **half-migrated state**. A given feature may:
- Have a `ts_omit_<name>` build tag declared in
[`featuretags/featuretags.go`](featuretags/featuretags.go), and
- Have some of its code moved to `feature/<name>/`, but
- Still have significant code living in `ipn/ipnlocal`, `cmd/tailscaled`,
or elsewhere, either:
- Behind a build-tag-conditional file (a whole `.go` file gated with
`//go:build !ts_omit_<name>`), or
- Guarded at runtime by an `if buildfeatures.HasFoo { ... }` block that
the compiler and linker dead-code-eliminate when the tag is set.
When you touch one of these features, prefer to keep migrating code into
its `feature/<name>` package rather than adding more to the old location.
But do not feel obligated to finish the migration in one PR.
## Runtime disabling: `TS_DISABLE_FEATURE`
The `TS_DISABLE_FEATURE` environment variable disables features at
runtime, even if they're compiled in. It is the runtime analog of the
`ts_omit_<name>` build tags: a mitigation knob (for when a feature has,
say, a security bug that most users don't need to be exposed to) and a
way to shrink attack surface on appliances.
TS_DISABLE_FEATURE=ssh,taildrop
Names are matched leniently: surrounding spaces, mixed case, a
`ts_omit_` prefix, and underscores in place of dashes are all accepted.
A name that this build doesn't know about, or that names a feature not
linked in, is ignored rather than fatal.
A disabled feature behaves as if it had not been linked: it is absent
from [`feature.IsRegistered`](https://pkg.go.dev/tailscale.com/feature#IsRegistered),
its hooks are unset, and its extensions and handlers are not
registered. The compile-time `buildfeatures.HasFoo` constants are
unaffected (the code is still in the binary), so keep pairing them with
`IsRegistered` as described above.
The mechanism, in order of authority:
1. [`feature.Register(name)`](https://pkg.go.dev/tailscale.com/feature#Register)
reports false and records nothing. Feature packages gate their
registration init on it:
func init() {
if !feature.Register("foo") {
return
}
// ... set hooks, register extensions and handlers ...
}
A package with more than one registration init should consolidate
them into one gated init, or guard the others with
[`feature.Disabled(name)`](https://pkg.go.dev/tailscale.com/feature#Disabled).
2. [`ipnext.RegisterExtension`](https://pkg.go.dev/tailscale.com/ipn/ipnext#RegisterExtension)
silently ignores a disabled feature's extension.
3. `feature.Hook.Set` and `feature.Hooks.Add` walk the call stack, and
if the caller is a package under `tailscale.com/feature/<x>` whose
feature is disabled, they silently skip the registration. This
catches sub-packages of a feature (such as
`feature/captiveportal/netcheckhook`), which cannot call `Register`
themselves without colliding with their parent feature, and future
packages whose authors never added the gate.
All of this happens during package init, so the variable must be set
when the process starts (in the systemd unit's `EnvironmentFile`, the
container's environment, and so on). The legacy per-feature knobs like
`TS_DISABLE_SSH_SERVER` and `TS_DISABLE_TAILDROP` keep working
independently of this one.
The state is visible in the `debug-optional-features` LocalAPI endpoint
(`tailscaled`'s registered set plus what `TS_DISABLE_FEATURE` listed),
and `feature/register_disable_test.go` locks the behavior down: it
disables every registered feature in a subprocess and fails if any of
them register anyway, so a feature that ignores the variable cannot
land.
Not everything with a `ts_omit_*` tag is runtime-disableable: code that
registers itself from outside a `feature/` package without going
through the mechanisms above (for example the `osrouter` router hooks)
is not covered.
## The registry: `feature/featuretags`
[`featuretags/featuretags.go`](featuretags/featuretags.go) is the single
source of truth. It declares the [`Features`](https://pkg.go.dev/tailscale.com/feature/featuretags#Features)
map: for each feature tag (a
lowercase string like `acme` or `taildrop`), it records the exported symbol
name used by generated constants, a human-readable description, and any
other features it depends on (forming a DAG).
Adding a new feature starts with adding an entry there. See the top of the
file for the tag naming convention: feature `foo` corresponds to build tag
`ts_omit_foo` (opt-out). The one exception is `cli`, which is opt-in via
`ts_include_cli`; see [`FeatureTag.IsOmittable`](https://pkg.go.dev/tailscale.com/feature/featuretags#FeatureTag.IsOmittable).
Features can be marked [`ImplementationDetail: true`](https://pkg.go.dev/tailscale.com/feature/featuretags#FeatureMeta)
when they are internal
plumbing (e.g. `dbus`, `c2n`) that users would not select directly; they
exist only so that user-visible features can depend on them.
## Generated constants: `feature/buildfeatures`
After editing `featuretags.go`, run:
./tool/go generate ./feature/buildfeatures
(or the meta `make generate`). That regenerates two files per feature in
[`buildfeatures/`](buildfeatures/): `feature_<name>_enabled.go` and
`feature_<name>_disabled.go`, gated with `//go:build !ts_omit_<name>` and
`//go:build ts_omit_<name>` respectively. Each pair exports a single
boolean constant, e.g. `HasACME`. Because these are Go constants, code of
the form:
if buildfeatures.HasACME {
// ...
}
is dead-code-eliminated by the compiler and linker when the feature is
omitted. Constants can eliminate *code* but they cannot remove *imports*;
for that you still need a build-tag-gated file.
Use constants when moving code to `feature/<name>` is impractical (e.g.
the code has to sit inside `LocalBackend`), or when a small conditional
inside otherwise-shared logic is clearer than splitting into two files.
Prefer separate packages when you can.
## `feature/condregister`: opt-out registration
[`condregister/`](condregister/) is the one central package that
`tailscaled` (and the macOS/iOS closed-source client) empty-imports so
that all "on by default" features get registered. Every feature that
should ship in `tailscaled` by default has a `maybe_<name>.go` file here
of the form:
//go:build !ts_omit_<name>
package condregister
import _ "tailscale.com/feature/<name>"
That is the whole file. The build tag is the only mechanism that decides
whether the feature is compiled in. Because `tailscaled` imports
`condregister`, all these `maybe_*` files pull in their respective
`feature/<name>` packages by default; adding `ts_omit_<name>` to the
build removes the file, and with it the import, and with it the
package's `init` and everything it transitively depends on.
Some `maybe_*` files carry additional build tags (e.g.
`//go:build !ios && !ts_omit_capture`) when a feature should be excluded
on specific platforms.
### The `feature/<name>` package should not carry `ts_omit_*` tags
The `ts_omit_<name>` tag lives only on the `condregister/maybe_<name>.go`
(and analogous `tsnet/maybe_<name>.go`) import shim, not on the
`feature/<name>/*.go` files themselves. Keeping the feature package
free of its own omit tag lets any program directly opt in with a blank
import regardless of what `ts_omit_*` tags are set on the top-level
build:
import _ "tailscale.com/feature/foo"
That is how a `tsnet`-using application pulls a feature in on its own
terms without having to reason about which `ts_omit_*` tags are in
effect.
OS-specific build tags inside `feature/<name>/` are fine, and often
desirable: if the feature only makes sense on some platforms, gating
its files with `//go:build linux` (or `!ios`, etc.) both keeps
`go test ./...` passing on every GOOS and prevents a `tsnet` user on an
unsupported platform from accidentally blank-importing a package that
would fail to compile or run there.
## `tsnet` does NOT depend on `condregister`
`tsnet` is a library, not a daemon, and it links in a different (and
generally smaller) default feature set than `tailscaled`. `tsnet` has its
own top-level `maybe_*.go` files that decide which features it opts in
to, and its own `depaware.txt`.
**Consequence:** you cannot assume `buildfeatures.HasFoo == true` means
feature `foo` was actually linked. In a `tsnet` build, `ts_omit_foo` may
not be set (so `HasFoo` is `true`), yet `tsnet` may have never imported
`feature/foo`, so its `init` never ran and no hooks were installed.
`buildfeatures.HasFoo` really means "not explicitly omitted by build
tag." Whether the feature is actually present is
[`feature.IsRegistered("foo")`](https://pkg.go.dev/tailscale.com/feature#IsRegistered).
The idiom is:
buildfeatures.HasFoo && feature.IsRegistered("foo")
`HasFoo` lets the compiler DCE the whole expression in `ts_omit_foo`
builds; `IsRegistered` guards against the `tsnet` case where the tag is
unset but the package was never imported.
## The `feature` API
Package [`feature`](https://pkg.go.dev/tailscale.com/feature) itself
exposes the small runtime API used by feature packages and their callers:
- [`feature.Register(name)`](https://pkg.go.dev/tailscale.com/feature#Register):
a feature package calls this from its `init` to record that it was
linked in and to find out whether it should register itself at all
(it reports false when disabled via
[`TS_DISABLE_FEATURE`](#runtime-disabling-ts_disable_feature), as
described below). Callers use
[`feature.IsRegistered(name)`](https://pkg.go.dev/tailscale.com/feature#IsRegistered)
to check.
- [`feature.Hook[Func]`](https://pkg.go.dev/tailscale.com/feature#Hook):
a single-writer, single-reader hook. The
extension point declares a `var HookX feature.Hook[func(...)]`, the
feature package `Set`s it once from `init`, and callers use
`GetOk`/`GetOrNil`/`IsSet` to invoke it. `Set` panics if called twice.
- [`feature.Hooks[Func]`](https://pkg.go.dev/tailscale.com/feature#Hooks):
a slice of hooks for extension points that may
legitimately have multiple registrants.
- Common cross-feature hooks (auto-update, proxy, TPM, SSH host keys,
hardware attestation) live in [`hooks.go`](hooks.go) alongside their
small dispatcher functions like
[`feature.CanAutoUpdate()`](https://pkg.go.dev/tailscale.com/feature#CanAutoUpdate)
and
[`feature.TPMAvailable()`](https://pkg.go.dev/tailscale.com/feature#TPMAvailable).
Add hooks here only sparingly: every hook in this package is loaded
by every consumer of `feature`, so its function signature must not
reference types from "heavy" packages. By heavy we mean any package
whose presence in this signature would unnecessarily grow
[`cmd/tailscaled/depaware-min.txt`](../cmd/tailscaled/depaware-min.txt):
packages with a large API/dependency footprint (e.g. `net/http`,
`crypto/tls`, `golang.org/x/crypto/ssh`, `k8s.io/...`), or packages
that trigger expensive runtime features like `reflect`-based
registration. The minimal build's depaware file is sacred and must
not grow without good reason. Prefer primitives, small `types/...`
packages, or interfaces. If your hook needs a wide type, keep the
hook (and its dispatcher) in the caller's package instead of
promoting it here.
[`ipnext.RegisterExtension`](https://pkg.go.dev/tailscale.com/ipn/ipnext#RegisterExtension)
(in `ipn/ipnext`) is the corresponding
mechanism for features that need to hook into `LocalBackend`; see the
next section.
## IPN extensions: `ipn/ipnext`
For features that need to attach state and behavior to `LocalBackend`,
the plain `feature.Hook` mechanism is not enough; you also need
per-`LocalBackend` state and a well-defined lifecycle. That is what
[`ipn/ipnext`](https://pkg.go.dev/tailscale.com/ipn/ipnext) provides.
Per-`LocalBackend` state matters because a single process can have more
than one `LocalBackend` alive at once: a `tsnet` program can host many
`tsnet.Server` nodes concurrently, each with its own `LocalBackend`.
Package-global variables in a feature package would be shared across all
of them, which should be avoided unless it is otherwise infeasible. An
[`ipnext.Extension`](https://pkg.go.dev/tailscale.com/ipn/ipnext#Extension)
is instantiated once *per* `LocalBackend`, so each node gets its own
copy of the extension's state.
(A related, not-yet-realized goal is letting different `tsnet` nodes in
the same process opt in to different sets of hooks. Today the hook
registration is still process-global (the `init` runs once and the hook
is on for every `LocalBackend`), but the extension design leaves room to
make this per-node in the future. Don't rely on the process-global
behavior; keep state on the extension instance, not in globals.)
An `ipnext.Extension` is created once per `LocalBackend`. From `init`, a
feature package registers a factory:
func init() {
feature.Register("taildrop")
ipnext.RegisterExtension("taildrop", newExtension)
}
When `LocalBackend.Start` runs, it instantiates each registered
extension and calls its
`Init(host `[`ipnext.Host`](https://pkg.go.dev/tailscale.com/ipn/ipnext#Host)`) error`
method. `Init` is where the extension wires up its per-backend hooks
against `host.Hooks()`:
func (e *extension) Init(h ipnext.Host) error {
h.Hooks().ProfileStateChange.Add(e.onChangeProfile)
h.Hooks().OnSelfChange.Add(e.onSelfChange)
h.Hooks().MutateNotifyLocked.Add(e.setNotifyFilesWaiting)
h.Hooks().SetPeerStatus.Add(e.setPeerStatus)
h.Hooks().BackendStateChange.Add(e.onBackendStateChange)
return nil
}
[`ipnext.Hooks`](https://pkg.go.dev/tailscale.com/ipn/ipnext#Hooks)
is a struct of `feature.Hook`/`feature.Hooks` fields
covering `LocalBackend`'s extension points: backend and profile state
changes, netmap toggles, self-node changes, notify mutation, peer
status, packet-filter hooks, audit logging, and more. Read the type in
`ipn/ipnext/ipnext.go` for the current list; new hooks are added there
as needed.
Extension hooks run synchronously with the triggering `LocalBackend`
operation and can influence its outcome. Because multiple extensions may
touch the same shared state (prefs, active profile, exit node, etc.),
new hooks should be designed with a clear conflict-resolution story
rather than assuming a single writer.
To get back to your extension from a `LocalBackend` inside a LocalAPI or
C2N handler, use
[`ipnlocal.GetExt[*yourExtType](b)`](https://pkg.go.dev/tailscale.com/ipn/ipnlocal#GetExt).
Canonical examples:
- [`feature/taildrop/ext.go`](taildrop/ext.go): full extension with
per-backend state.
- [`feature/acme/acme.go`](acme/acme.go): extension that also `Set`s a
handful of `ipnlocal.Hook*` singletons.
The `ipnext` mechanism is a work in progress and not every part of
`LocalBackend` has been converted to hooks yet. If you need a new
extension point, [ask](#asking-questions).
## Tests: `featuretags` and `depaware` / `depchecker`
Tests come in three flavors:
1. **`feature/featuretags` self-tests** ([`featuretags_test.go`](featuretags/featuretags_test.go))
validate the registry itself: that every declared dependency exists,
that there are no cycles, that `Requires`/`RequiredBy` return the
expected sets, and that every `ts_omit_*` string that appears
anywhere in the tree (via `git grep`) is declared in the map.
2. **`depaware`** ([github.com/tailscale/depaware](https://github.com/tailscale/depaware))
snapshots the full set of Go packages linked into each binary into
a checked-in `depaware.txt` file. Its job is right there in the
name: to force us to be *aware of our deps*. Every change to the
dependency graph shows up as a diff to `depaware.txt` in the same
PR that introduced it, so reviewers can see, prominently, exactly
what got pulled in or dropped. We use this to have an auditable
history of the dependency footprint of key programs and libraries.
We track several flavors:
- full `tailscaled` on all GOOSes
- full `tailscale` CLI on all GOOSes
- `cmd/derper`
- minimal `tailscaled`+CLI (`depaware-min.txt`, `depaware-minbox.txt`)
- `tsnet` (has its own `depaware.txt`)
- `k8s-operator`, which uses `tsnet`
When reviewing a PR, look at the depaware diff to see what got pulled
in or dropped, and make sure the changes make sense.
3. **[`deptest.DepChecker`](https://pkg.go.dev/tailscale.com/tstest/deptest#DepChecker)**
lets you *lock down* omissions once you've
achieved them. Rather than hoping nobody accidentally adds a
`taildrop` import back into a `ts_omit_taildrop` build, you write:
func TestOmitACME(t *testing.T) {
deptest.DepChecker{
GOOS: "linux",
GOARCH: "amd64",
Tags: "ts_omit_acme,ts_include_cli",
OnDep: func(dep string) {
if strings.Contains(dep, "/acme") {
t.Errorf("unexpected dep with ts_omit_acme: %q", dep)
}
},
}.Check(t)
}
`cmd/tailscaled/deps_test.go` has many examples; add new ones there
or wherever they fit. `tsnet` has its own `TestDeps` in
[`tsnet_test.go`](../tsnet/tsnet_test.go) with a `BadDeps` map
asserting that things like `feature/remoteconfig`,
`feature/syspolicy`, and `x/crypto/ssh` never sneak into `tsnet`.
## Tooling
- `cmd/featuretags` constructs valid sets of Go omit build tags. Start
from a minimal build and add features with `--min --add=...`, or start
from a full build and remove with `--remove=...`; either way it
respects the declared dependency DAG.
- `build_dist.sh` uses `cmd/featuretags` under the hood, so you do not
need to hand-maintain its build-tag lists as new features are added.
## Summary of what to do for a new feature
1. Add an entry to `Features` in `feature/featuretags/featuretags.go`,
including any `Deps`.
2. Run `./tool/go generate ./feature/buildfeatures` (or `make generate`).
3. Create `feature/<name>/` with the code, and register hooks and any
`ipnext.Extension` from its `init`, gated as described in
["Runtime disabling: `TS_DISABLE_FEATURE`](#runtime-disabling-ts_disable_feature):
func init() {
if !feature.Register("<name>") {
return
}
// ... set hooks, register extensions and handlers ...
}
4. If the feature should be on by default in `tailscaled`, add
`feature/condregister/maybe_<name>.go` with `//go:build !ts_omit_<name>`
and a blank import of `tailscale.com/feature/<name>`.
5. If `tsnet` should also link it, add an equivalent `maybe_<name>.go`
under `tsnet/`. Otherwise remember `buildfeatures.HasFoo` alone is not
enough; pair it with `feature.IsRegistered("foo")`.
6. Add a `deptest.DepChecker` test to lock down what does *not* get
linked when the feature is omitted.
7. Regenerate depaware and review the diff.
## Asking questions
If something in this document is unclear, if you are not sure whether
your work should be modular, or if you need a new extension point:
- **Tailscale employees:** ask in the `#client` Slack channel.
- **Open source contributors:** file a public GitHub issue with your
proposal or question at
[github.com/tailscale/tailscale/issues](https://github.com/tailscale/tailscale/issues).
+288
View File
@@ -0,0 +1,288 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package acme registers the ACME/TLS-cert feature and implements its
// associated [ipnext.Extension]. The extension owns the per-LocalBackend
// ACME serialization mutex, in-flight cert tracking, the refresh loop's
// cancel func, and the test-only cert override; together with the cert
// acquisition logic in this package, it is everything tailscaled needs
// to obtain and renew TLS certificates via ACME.
//
// In builds without ACME support (js or ts_omit_acme), this package is
// not linked in; [ipn/ipnlocal] then exposes only stub wrappers that
// return errNoCerts or no-op.
package acme
import (
"context"
"crypto/tls"
"errors"
"net/http"
"sync"
"sync/atomic"
"time"
"tailscale.com/feature"
"tailscale.com/health"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnext"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/syncs"
xacme "tailscale.com/tempfork/acme"
"tailscale.com/tsconst"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/util/mak"
"tailscale.com/util/set"
)
// featureName is the name of the feature implemented by this package.
const featureName = "acme"
func init() {
if !feature.Register(featureName) {
return
}
ipnext.RegisterExtension(featureName, newExtension)
ipnlocal.HookGetCertPEM.Set(getCertPEMHook)
ipnlocal.HookGetACMETLSALPNCert.Set(getACMETLSALPNCertHook)
ipnlocal.HookGetACMETLSALPNProto.Set(getACMETLSALPNProtoHook)
ipnlocal.HookUpdateCertRefreshLoop.Set(updateCertRefreshLoopHook)
ipnlocal.HookShutdownCertRefreshLoop.Set(shutdownCertRefreshLoopHook)
ipnlocal.HookConfigureCertsForTest.Set(configureCertsForTestHook)
ipnlocal.HookHandleC2NTLSCertStatus.Set(handleC2NTLSCertStatus)
}
// errNoExt is returned when a hook is invoked on a [*ipnlocal.LocalBackend]
// that has no [extension] registered (shouldn't happen in practice, but
// guards against misuse from tests that swap extension registrations).
var errNoExt = errors.New("acme extension not registered on this LocalBackend")
// extension is the ACME/cert [ipnext.Extension]. It owns the
// per-[*ipnlocal.LocalBackend] state previously held in package-level
// globals and in [*ipnlocal.LocalBackend] fields.
//
// All methods that take a [*ipnlocal.LocalBackend] argument operate on
// the backend the extension was instantiated for; the argument is
// passed through from the hook in [ipn/ipnlocal] rather than stored on
// the extension, which keeps the extension's lifecycle independent of
// any specific backend reference.
type extension struct {
logf logger.Logf
// domainsMu guards domainLocks.
domainsMu syncs.Mutex
// domainLocks holds a per-domain mutex serialising ACME operations
// for that domain. Different domains run concurrently; the same
// domain still queues so the first goroutine fills the on-disk
// cache and the rest reuse it. Entries are never removed; a node
// only ever has a handful of domains so the map stays small.
domainLocks map[string]*syncs.Mutex
// accountMu serialises ACME account setup (key generation, LE
// registration) so two first-time issuances don't create separate
// accounts.
accountMu syncs.Mutex
// renewMu guards renewCertAt and renewingCertDomains.
// Lock order: per-domain lock before renewMu.
renewMu syncs.Mutex
renewCertAt map[string]time.Time // lazily initialized under renewMu
// renewingCertDomains tracks domains for which an async renewal is
// currently in flight.
renewingCertDomains set.Set[string]
// pendingACMETLSALPNCerts maps SNI names to short-lived ACME
// tls-alpn-01 challenge certificates while an ACME order is
// waiting for validation. Entries are deleted by the cleanup
// function returned from storeACMETLSALPNCert after the challenge
// validation path finishes, whether it succeeds or fails.
pendingACMETLSALPNCerts syncs.Map[string, *tls.Certificate]
// pendingCertDomains tracks the set of domains for which an ACME
// issuance is currently in flight with no usable cached cert. It
// backs the tls-cert-pending health Warnable.
// Guarded by pendingCertDomainsMu.
pendingCertDomainsMu sync.Mutex
pendingCertDomains set.Set[string]
// wg tracks all background goroutines spawned by this extension
// (async cert renewals, the cert refresh loop and its per-domain
// workers). [extension.Shutdown] waits on it.
wg sync.WaitGroup
// goroutinesStarted counts goroutines started via [extension.Go].
// Tests use it to assert whether an operation kicked off async work.
goroutinesStarted atomic.Int64
// mu guards the test/lifecycle fields below.
mu sync.Mutex
// getCertForTest is used to retrieve TLS certificates in tests.
// See [LocalBackend.ConfigureCertsForTest].
getCertForTest func(hostname string) (*ipnlocal.TLSCertKeyPair, error)
// certRefreshCancel cancels the background TLS cert refresh loop
// that periodically pokes [LocalBackend.GetCertPEM] so renewals
// happen on idle nodes. Non-nil while the loop is running.
certRefreshCancel context.CancelFunc
}
// lockDomain returns the mutex for domain, creating it on first use.
func (e *extension) lockDomain(domain string) *syncs.Mutex {
e.domainsMu.Lock()
defer e.domainsMu.Unlock()
if m, ok := e.domainLocks[domain]; ok {
return m
}
m := new(syncs.Mutex)
mak.Set(&e.domainLocks, domain, m)
return m
}
// Go runs f in a new goroutine tracked by e.wg. [extension.Shutdown]
// waits for all such goroutines to finish.
func (e *extension) Go(f func()) {
e.wg.Add(1)
e.goroutinesStarted.Add(1)
go func() {
defer e.wg.Done()
f()
}()
}
// newExtension is the [ipnext.NewExtensionFn] registered for this
// feature. It is called once per [*ipnlocal.LocalBackend].
func newExtension(logf logger.Logf, _ ipnext.SafeBackend) (ipnext.Extension, error) {
return &extension{
logf: logger.WithPrefix(logf, featureName+": "),
}, nil
}
// Name implements [ipnext.Extension].
func (e *extension) Name() string { return featureName }
// Init implements [ipnext.Extension].
func (e *extension) Init(ipnext.Host) error { return nil }
// Shutdown implements [ipnext.Extension]. It cancels the cert refresh
// loop if it's running, then waits for all in-flight goroutines
// (async renewals, refresh loop workers) to finish.
func (e *extension) Shutdown() error {
e.mu.Lock()
if e.certRefreshCancel != nil {
e.certRefreshCancel()
e.certRefreshCancel = nil
}
e.mu.Unlock()
e.wg.Wait()
return nil
}
// extFor returns the [*extension] for b, or an error if no acme
// extension is registered on b.
func extFor(b *ipnlocal.LocalBackend) (*extension, error) {
e, ok := ipnlocal.GetExt[*extension](b)
if !ok {
return nil, errNoExt
}
return e, nil
}
// Hook adapter funcs that thread (b *ipnlocal.LocalBackend) into the
// extension's methods. These are what get installed in
// [ipnlocal.Hook*] at init time.
func getCertPEMHook(ctx context.Context, b *ipnlocal.LocalBackend, domain string, minValidity time.Duration) (*ipnlocal.TLSCertKeyPair, error) {
e, err := extFor(b)
if err != nil {
return nil, err
}
pair, err := e.getCertPEMWithValidity(ctx, b, domain, minValidity)
if err != nil {
if ae, ok := errors.AsType[*xacme.Error](err); ok {
if d, ok := xacme.RateLimit(ae); ok {
return nil, certRateLimitedError{retryAfter: d, underlying: err}
}
}
return nil, err
}
return pair, nil
}
func getACMETLSALPNCertHook(b *ipnlocal.LocalBackend, hi *tls.ClientHelloInfo) (*tls.Certificate, bool) {
e, err := extFor(b)
if err != nil {
return nil, false
}
return e.getACMETLSALPNCert(hi)
}
func getACMETLSALPNProtoHook(b *ipnlocal.LocalBackend, hi *tls.ClientHelloInfo) (string, bool) {
e, err := extFor(b)
if err != nil {
return "", false
}
return e.getACMETLSALPNProto(hi)
}
func updateCertRefreshLoopHook(b *ipnlocal.LocalBackend, state ipn.State, sc ipn.ServeConfigView) {
e, err := extFor(b)
if err != nil {
return
}
e.updateCertRefreshLoop(b, state, sc)
}
func shutdownCertRefreshLoopHook(b *ipnlocal.LocalBackend) {
e, err := extFor(b)
if err != nil {
return
}
e.Shutdown()
}
func configureCertsForTestHook(b *ipnlocal.LocalBackend, getCert func(string) (*ipnlocal.TLSCertKeyPair, error)) {
e, err := extFor(b)
if err != nil {
panic(err)
}
e.mu.Lock()
defer e.mu.Unlock()
e.getCertForTest = getCert
}
func handleC2NTLSCertStatus(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) {
e, err := extFor(b)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
e.handleC2NTLSCertStatus(b, w, r)
}
// ACME / cert metrics. These are package-level (process-wide) because
// they aggregate across all [*extension]s in the process.
var (
metricACMEDNS01Start = clientmetric.NewCounter("cert_acme_dns01_start")
metricACMEDNS01Success = clientmetric.NewCounter("cert_acme_dns01_success")
metricACMEDNS01Failure = clientmetric.NewCounter("cert_acme_dns01_failure")
metricACMETLSALPN01Start = clientmetric.NewCounter("cert_acme_tls_alpn01_start")
metricACMETLSALPN01Success = clientmetric.NewCounter("cert_acme_tls_alpn01_success")
metricACMETLSALPN01Failure = clientmetric.NewCounter("cert_acme_tls_alpn01_failure")
)
// certPendingWarnable fires while ACME is fetching a TLS certificate
// for which no usable cached copy exists (initial issuance or after
// the cached cert has expired). Async renewal of a still-valid cert
// does not fire it.
var certPendingWarnable = health.Register(&health.Warnable{
Code: tsconst.HealthWarnableTLSCertPending,
Title: "Fetching TLS certificate",
Severity: health.SeverityLow,
Text: func(args health.Args) string {
return "Fetching TLS certificate via ACME for: " + args[health.ArgDomains]
},
})
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package acme
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"time"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/tailcfg"
)
// handleC2NTLSCertStatus returns info about the last TLS certificate
// issued for the provided domain. It is the implementation of
// [ipnlocal.HookHandleC2NTLSCertStatus]; control calls it to clean up
// DNS TXT records when they're no longer needed by LetsEncrypt.
//
// It does not kick off a cert fetch or async refresh. It only reports
// anything that's already sitting on disk, and only reports metadata
// about the public cert (stuff that'd be the in CT logs anyway).
func (e *extension) handleC2NTLSCertStatus(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) {
cs, err := e.getCertStore(b)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
domain := r.FormValue("domain")
if domain == "" {
http.Error(w, "no 'domain'", http.StatusBadRequest)
return
}
ret := &tailcfg.C2NTLSCertInfo{}
pair, err := getCertPEMCached(cs, domain, b.Clock().Now())
ret.Valid = err == nil
if err != nil {
ret.Error = err.Error()
if errors.Is(err, errCertExpired) {
ret.Expired = true
} else if errors.Is(err, ipn.ErrStateNotExist) {
ret.Missing = true
ret.Error = "no certificate"
}
} else {
block, _ := pem.Decode(pair.CertPEM)
if block == nil {
ret.Error = "invalid PEM"
ret.Valid = false
} else {
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
ret.Error = fmt.Sprintf("invalid certificate: %v", err)
ret.Valid = false
} else {
ret.NotBefore = cert.NotBefore.UTC().Format(time.RFC3339)
ret.NotAfter = cert.NotAfter.UTC().Format(time.RFC3339)
}
}
}
writeJSON(w, ret)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
Loaded 100 of 2146 files, more files were not shown because too many files have changed in this diff. Show more