Update
This commit is contained in:
1 parent
3530afb854
commit
6903fcff27
2146 files changed
+112256
-190703
No files matched your search
Generated
+60
-2
@@ -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
|
||||
Generated
+9
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
1.94.1
|
||||
1.104.1
|
||||
+14
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package atomicfile
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package atomicfile
|
||||
|
||||
+8
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package apitype
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailscale
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package controlbase
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package controlbase
|
||||
|
||||
+169
-31
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package controlclient
|
||||
|
||||
+187
-30
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package controlclient
|
||||
|
||||
+2
-4
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package controlclient
|
||||
|
||||
+5
-2
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package controlhttp
|
||||
|
||||
+4
-2
@@ -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
@@ -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
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package derphttp
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package disco
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package drive
|
||||
|
||||
+1
-1
@@ -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
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package drive
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user