This commit is contained in:
2026-02-19 10:07:43 +00:00
parent 007438e372
commit 6e637ecf77
1763 changed files with 60820 additions and 279516 deletions

3
vendor/tailscale.com/.gitignore generated vendored
View File

@@ -49,3 +49,6 @@ client/web/build/assets
*.xcworkspacedata
/tstest/tailmac/bin
/tstest/tailmac/build
# Ignore personal IntelliJ settings
.idea/

187
vendor/tailscale.com/.golangci.yml generated vendored
View File

@@ -1,97 +1,110 @@
version: "2"
# Configuration for how we run golangci-lint
# Timeout of 5m was the default in v1.
run:
timeout: 5m
linters:
# Don't enable any linters by default; just the ones that we explicitly
# enable in the list below.
disable-all: true
default: none
enable:
- bidichk
- gofmt
- goimports
- govet
- misspell
- revive
# Configuration for how we run golangci-lint
run:
timeout: 5m
issues:
# Excluding configuration per-path, per-linter, per-text and per-source
exclude-rules:
# These are forks of an upstream package and thus are exempt from stylistic
# changes that would make pulling in upstream changes harder.
- path: tempfork/.*\.go
text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`"
- path: util/singleflight/.*\.go
text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`"
# Per-linter settings are contained in this top-level key
linters-settings:
gofmt:
rewrite-rules:
- pattern: 'interface{}'
replacement: 'any'
govet:
settings:
# Matches what we use in corp as of 2023-12-07
enable:
- asmdecl
- assign
- atomic
- bools
- buildtag
- cgocall
- copylocks
- deepequalerrors
- errorsas
- framepointer
- httpresponse
- ifaceassert
- loopclosure
- lostcancel
- nilfunc
- nilness
- printf
- reflectvaluecompare
- shift
- sigchanyzer
- sortslice
- stdmethods
- stringintconv
- structtag
- testinggoroutine
- tests
- unmarshal
- unreachable
- unsafeptr
- unusedresult
settings:
printf:
# List of print function names to check (in addition to default)
funcs:
- github.com/tailscale/tailscale/types/logger.Discard
# NOTE(andrew-d): this doesn't currently work because the printf
# analyzer doesn't support type declarations
#- github.com/tailscale/tailscale/types/logger.Logf
revive:
enable-all-rules: false
ignore-generated-header: true
govet:
enable:
- asmdecl
- assign
- atomic
- bools
- buildtag
- cgocall
- copylocks
- deepequalerrors
- errorsas
- framepointer
- httpresponse
- ifaceassert
- loopclosure
- lostcancel
- nilfunc
- nilness
- printf
- reflectvaluecompare
- shift
- sigchanyzer
- sortslice
- stdmethods
- stringintconv
- structtag
- testinggoroutine
- tests
- unmarshal
- unreachable
- unsafeptr
- unusedresult
settings:
printf:
# List of print function names to check (in addition to default)
funcs:
- github.com/tailscale/tailscale/types/logger.Discard
# NOTE(andrew-d): this doesn't currently work because the printf
# analyzer doesn't support type declarations
#- github.com/tailscale/tailscale/types/logger.Logf
revive:
enable-all-rules: false
rules:
- name: atomic
- name: context-keys-type
- name: defer
arguments: [[
# Calling 'recover' at the time a defer is registered (i.e. "defer recover()") has no effect.
"immediate-recover",
# Calling 'recover' outside of a deferred function has no effect
"recover",
# Returning values from a deferred function has no effect
"return",
]]
- name: duplicated-imports
- name: errorf
- name: string-of-int
- name: time-equal
- name: unconditional-recursion
- name: useless-break
- name: waitgroup-by-value
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- name: atomic
- name: context-keys-type
- name: defer
arguments: [[
# Calling 'recover' at the time a defer is registered (i.e. "defer recover()") has no effect.
"immediate-recover",
# Calling 'recover' outside of a deferred function has no effect
"recover",
# Returning values from a deferred function has no effect
"return",
]]
- name: duplicated-imports
- name: errorf
- name: string-of-int
- name: time-equal
- name: unconditional-recursion
- name: useless-break
- name: waitgroup-by-value
# These are forks of an upstream package and thus are exempt from stylistic
# changes that would make pulling in upstream changes harder.
- path: tempfork/.*\.go
text: File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`
- path: util/singleflight/.*\.go
text: File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
- goimports
settings:
gofmt:
rewrite-rules:
- pattern: interface{}
replacement: any
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

2
vendor/tailscale.com/ALPINE.txt generated vendored
View File

@@ -1 +1 @@
3.19
3.22

View File

@@ -1,135 +1,103 @@
# Contributor Covenant Code of Conduct
# Tailscale Community Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation
in our community a harassment-free experience for everyone, regardless
of age, body size, visible or invisible disability, ethnicity, sex
characteristics, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance,
race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open,
welcoming, diverse, inclusive, and healthy community.
We are committed to creating an open, welcoming, diverse, inclusive, healthy and respectful community.
Unacceptable, harmful and inappropriate behavior will not be tolerated.
## Our Standards
Examples of behavior that contributes to a positive environment for
our community include:
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our
mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
- Demonstrating empathy and kindness toward other people.
- Being respectful of differing opinions, viewpoints, and experiences.
- Giving and gracefully accepting constructive feedback.
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience.
- Focusing on what is best not just for us as individuals, but for the overall community.
Examples of unacceptable behavior include:
Examples of unacceptable behavior include without limitation:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or
political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in
a professional setting
- The use of language, imagery or emojis (collectively "content") that is racist, sexist, homophobic, transphobic, or otherwise harassing or discriminatory based on any protected characteristic.
- The use of sexualized content and sexual attention or advances of any kind.
- The use of violent, intimidating or bullying content.
- Trolling, concern trolling, insulting or derogatory comments, and personal or political attacks.
- Public or private harassment.
- Publishing others' personal information, such as a photo, physical address, email address, online profile information, or other personal information, without their explicit permission or with the intent to bully or harass the other person.
- Posting deep fake or other AI generated content about or involving another person without the explicit permission.
- Spamming community channels and members, such as sending repeat messages, low-effort content, or automated messages.
- Phishing or any similar activity.
- Distributing or promoting malware.
- The use of any coded or suggestive content to hide or provoke otherwise unacceptable behavior.
- Other conduct which could reasonably be considered harmful, illegal, or inappropriate in a professional setting.
## Enforcement Responsibilities
Please also see the Tailscale Acceptable Use Policy, available at [tailscale.com/tailscale-aup](https://tailscale.com/tailscale-aup).
Community leaders are responsible for clarifying and enforcing our
standards of acceptable behavior and will take appropriate and fair
corrective action in response to any behavior that they deem
inappropriate, threatening, offensive, or harmful.
## Reporting Incidents
Community leaders have the right and responsibility to remove, edit,
or reject comments, commits, code, wiki edits, issues, and other
contributions that are not aligned to this Code of Conduct, and will
communicate reasons for moderation decisions when appropriate.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to Tailscale directly via <info@tailscale.com>, or to the community leaders or moderators via DM or similar.
All complaints will be reviewed and investigated promptly and fairly.
We will respect the privacy and safety of the reporter of any issues.
## Scope
This Code of Conduct applies within all community spaces, and also
applies when an individual is officially representing the community in
public spaces. Examples of representing our community include using an
official e-mail address, posting via an official social media account,
or acting as an appointed representative at an online or offline
event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior
may be reported to the community leaders responsible for enforcement
at [info@tailscale.com](mailto:info@tailscale.com). All complaints
will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and
security of the reporter of any incident.
Please note that this community is not moderated by staff 24/7, and we do not have, and do not undertake, any obligation to prescreen, monitor, edit, or remove any content or data, or to actively seek facts or circumstances indicating illegal activity.
While we strive to keep the community safe and welcoming, moderation may not be immediate at all hours.
If you encounter any issues, report them using the appropriate channels.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in
determining the consequences for any action they deem in violation of
this Code of Conduct:
Community leaders and moderators are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders and moderators have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Community Code of Conduct.
Tailscale retains full discretion to take action (or not) in response to a violation of these guidelines with or without notice or liability to you.
We will interpret our policies and resolve disputes in favor of protecting users, customers, the public, our community and our company, as a whole.
Community leaders will follow these community enforcement guidelines in determining the consequences for any action they deem in violation of this Code of Conduct,
and retain full discretion to apply the enforcement guidelines as necessary depending on the circumstances:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior
deemed unprofessional or unwelcome in the community.
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders,
providing clarity around the nature of the violation and an
explanation of why the behavior was inappropriate. A public apology
may be requested.
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate.
A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
Community Impact: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued
behavior. No interaction with the people involved, including
unsolicited interaction with those enforcing the Code of Conduct, for
a specified period of time. This includes avoiding interactions in
community spaces as well as external channels like social
media. Violating these terms may lead to a temporary or permanent ban.
Consequence: A warning with consequences for continued behavior.
No interaction with the people involved, including unsolicited interaction with those enforcing this Community Code of Conduct, for a specified period of time.
This includes avoiding interactions in community spaces as well as external channels like social media.
Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards,
including sustained inappropriate behavior.
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or
public communication with the community for a specified period of
time. No public or private interaction with the people involved,
including unsolicited interaction with those enforcing the Code of
Conduct, is allowed during this period. Violating these terms may lead
to a permanent ban.
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time.
No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of
community standards, including sustained inappropriate behavior,
harassment of an individual, or aggression toward or disparagement of
classes of individuals.
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction
within the community.
Consequence: A permanent ban from any sort of public interaction within the community.
## Acceptable Use Policy
Violation of this Community Code of Conduct may also violate the Tailscale Acceptable Use Policy, which may result in suspension or termination of your Tailscale account.
For more information, please see the Tailscale Acceptable Use Policy, available at [tailscale.com/tailscale-aup](https://tailscale.com/tailscale-aup).
## Privacy
Please see the Tailscale [Privacy Policy](https://tailscale.com/privacy-policy) for more information about how Tailscale collects, uses, discloses and protects information.
## Attribution
This Code of Conduct is adapted from the [Contributor
Covenant][homepage], version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at <https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
Community Impact Guidelines were inspired by [Mozilla's code of
conduct enforcement ladder](https://github.com/mozilla/diversity).
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the
FAQ at https://www.contributor-covenant.org/faq. Translations are
available at https://www.contributor-covenant.org/translations.
For answers to common questions about this code of conduct, see the FAQ at <https://www.contributor-covenant.org/faq>.
Translations are available at <https://www.contributor-covenant.org/translations>.

22
vendor/tailscale.com/Dockerfile generated vendored
View File

@@ -7,6 +7,15 @@
# Tailscale images are currently built using https://github.com/tailscale/mkctr,
# and the build script can be found in ./build_docker.sh.
#
# If you want to build local images for testing, you can use make.
#
# To build a Tailscale image and push to the local docker registry:
#
# $ REPO=local/tailscale TAGS=v0.0.1 PLATFORM=local make publishdevimage
#
# To build a Tailscale image and push to a remote docker registry:
#
# $ REPO=<your-registry>/<your-repo>/tailscale TAGS=v0.0.1 make publishdevimage
#
# This Dockerfile includes all the tailscale binaries.
#
@@ -27,7 +36,7 @@
# $ docker exec tailscaled tailscale status
FROM golang:1.24-alpine AS build-env
FROM golang:1.25-alpine AS build-env
WORKDIR /go/src/tailscale
@@ -62,10 +71,15 @@ RUN GOARCH=$TARGETARCH go install -ldflags="\
-X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
FROM alpine:3.19
FROM alpine:3.22
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
RUN rm /sbin/iptables && ln -s /sbin/iptables-legacy /sbin/iptables
RUN rm /sbin/ip6tables && ln -s /sbin/ip6tables-legacy /sbin/ip6tables
# Alpine 3.19 replaced legacy iptables with nftables based implementation.
# Tailscale is used on some hosts that don't support nftables, such as Synology
# NAS, so link iptables back to legacy version. Hosts that don't require legacy
# iptables should be able to use Tailscale in nftables mode. See
# https://github.com/tailscale/tailscale/issues/17854
RUN rm /usr/sbin/iptables && ln -s /usr/sbin/iptables-legacy /usr/sbin/iptables
RUN rm /usr/sbin/ip6tables && ln -s /usr/sbin/ip6tables-legacy /usr/sbin/ip6tables
COPY --from=build-env /go/bin/* /usr/local/bin/
# For compat with the previous run.sh, although ideally you should be

16
vendor/tailscale.com/Dockerfile.base generated vendored
View File

@@ -1,12 +1,12 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
FROM alpine:3.19
FROM alpine:3.22
RUN apk add --no-cache ca-certificates iptables iptables-legacy iproute2 ip6tables iputils
# Alpine 3.19 replaces legacy iptables with nftables based implementation. We
# can't be certain that all hosts that run Tailscale containers currently
# suppport nftables, so link back to legacy for backwards compatibility reasons.
# TODO(irbekrm): add some way how to determine if we still run on nodes that
# don't support nftables, so that we can eventually remove these symlinks.
RUN rm /sbin/iptables && ln -s /sbin/iptables-legacy /sbin/iptables
RUN rm /sbin/ip6tables && ln -s /sbin/ip6tables-legacy /sbin/ip6tables
# Alpine 3.19 replaced legacy iptables with nftables based implementation.
# Tailscale is used on some hosts that don't support nftables, such as Synology
# NAS, so link iptables back to legacy version. Hosts that don't require legacy
# iptables should be able to use Tailscale in nftables mode. See
# https://github.com/tailscale/tailscale/issues/17854
RUN rm /usr/sbin/iptables && ln -s /usr/sbin/iptables-legacy /usr/sbin/iptables
RUN rm /usr/sbin/ip6tables && ln -s /usr/sbin/ip6tables-legacy /usr/sbin/ip6tables

89
vendor/tailscale.com/Makefile generated vendored
View File

@@ -8,8 +8,9 @@ PLATFORM ?= "flyio" ## flyio==linux/amd64. Set to "" to build all platforms.
vet: ## Run go vet
./tool/go vet ./...
tidy: ## Run go mod tidy
tidy: ## Run go mod tidy and update nix flake hashes
./tool/go mod tidy
./update-flake.sh
lint: ## Run golangci-lint
./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run
@@ -17,22 +18,36 @@ lint: ## Run golangci-lint
updatedeps: ## Update depaware deps
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
# it finds in its $$PATH is the right one.
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update --internal \
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update --vendor --internal \
tailscale.com/cmd/tailscaled \
tailscale.com/cmd/tailscale \
tailscale.com/cmd/derper \
tailscale.com/cmd/k8s-operator \
tailscale.com/cmd/stund
tailscale.com/cmd/stund \
tailscale.com/cmd/tsidp
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update --goos=linux,darwin,windows,android,ios --vendor --internal \
tailscale.com/tsnet
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update --file=depaware-minbox.txt --goos=linux --tags="$$(./tool/go run ./cmd/featuretags --min --add=cli)" --vendor --internal \
tailscale.com/cmd/tailscaled
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update --file=depaware-min.txt --goos=linux --tags="$$(./tool/go run ./cmd/featuretags --min)" --vendor --internal \
tailscale.com/cmd/tailscaled
depaware: ## Run depaware checks
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
# it finds in its $$PATH is the right one.
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check --internal \
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check --vendor --internal \
tailscale.com/cmd/tailscaled \
tailscale.com/cmd/tailscale \
tailscale.com/cmd/derper \
tailscale.com/cmd/k8s-operator \
tailscale.com/cmd/stund
tailscale.com/cmd/stund \
tailscale.com/cmd/tsidp
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check --goos=linux,darwin,windows,android,ios --vendor --internal \
tailscale.com/tsnet
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check --file=depaware-minbox.txt --goos=linux --tags="$$(./tool/go run ./cmd/featuretags --min --add=cli)" --vendor --internal \
tailscale.com/cmd/tailscaled
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check --file=depaware-min.txt --goos=linux --tags="$$(./tool/go run ./cmd/featuretags --min)" --vendor --internal \
tailscale.com/cmd/tailscaled
buildwindows: ## Build tailscale CLI for windows/amd64
GOOS=windows GOARCH=amd64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
@@ -58,7 +73,7 @@ buildmultiarchimage: ## Build (and optionally push) multiarch docker image
check: staticcheck vet depaware buildwindows build386 buildlinuxarm buildwasm ## Perform basic checks and compilation tests
staticcheck: ## Run staticcheck.io checks
./tool/go run honnef.co/go/tools/cmd/staticcheck -- $$(./tool/go list ./... | grep -v tempfork)
./tool/go run honnef.co/go/tools/cmd/staticcheck -- $$(./tool/go run ./tool/listpkgs --ignore-3p ./...)
kube-generate-all: kube-generate-deepcopy ## Refresh generated files for Tailscale Kubernetes Operator
./tool/go generate ./cmd/k8s-operator
@@ -86,42 +101,60 @@ pushspk: spk ## Push and install synology package on ${SYNO_HOST} host
scp tailscale.spk root@${SYNO_HOST}:
ssh root@${SYNO_HOST} /usr/syno/bin/synopkg install tailscale.spk
publishdevimage: ## Build and publish tailscale image to location specified by ${REPO}
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
.PHONY: check-image-repo
check-image-repo:
@if [ -z "$(REPO)" ]; then \
echo "REPO=... required; e.g. REPO=ghcr.io/$$USER/tailscale" >&2; \
exit 1; \
fi
@for repo in tailscale/tailscale ghcr.io/tailscale/tailscale \
tailscale/k8s-operator ghcr.io/tailscale/k8s-operator \
tailscale/k8s-nameserver ghcr.io/tailscale/k8s-nameserver \
tailscale/tsidp ghcr.io/tailscale/tsidp \
tailscale/k8s-proxy ghcr.io/tailscale/k8s-proxy; do \
if [ "$(REPO)" = "$$repo" ]; then \
echo "REPO=... must not be $$repo" >&2; \
exit 1; \
fi; \
done
publishdevimage: check-image-repo ## Build and publish tailscale image to location specified by ${REPO}
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=client ./build_docker.sh
publishdevoperator: ## Build and publish k8s-operator image to location specified by ${REPO}
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
publishdevoperator: check-image-repo ## Build and publish k8s-operator image to location specified by ${REPO}
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-operator ./build_docker.sh
publishdevnameserver: ## Build and publish k8s-nameserver image to location specified by ${REPO}
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-nameserver" || (echo "REPO=... must not be tailscale/k8s-nameserver" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-nameserver" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-nameserver" && exit 1)
publishdevnameserver: check-image-repo ## Build and publish k8s-nameserver image to location specified by ${REPO}
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-nameserver ./build_docker.sh
publishdevtsidp: check-image-repo ## Build and publish tsidp image to location specified by ${REPO}
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=tsidp ./build_docker.sh
publishdevproxy: check-image-repo ## Build and publish k8s-proxy image to location specified by ${REPO}
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-proxy ./build_docker.sh
.PHONY: sshintegrationtest
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
@GOOS=linux GOARCH=amd64 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
@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
.PHONY: generate
generate: ## Generate code
./tool/go generate ./...
.PHONY: pin-github-actions
pin-github-actions:
./tool/go tool github.com/stacklok/frizbee actions .github/workflows
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'
@echo ""
@echo "Specify a command. The choices are:"
@echo ""
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'
@echo ""
.PHONY: help

5
vendor/tailscale.com/README.md generated vendored
View File

@@ -37,7 +37,7 @@ not open source.
## Building
We always require the latest Go release, currently Go 1.23. (While we build
We always require the latest Go release, currently Go 1.25. (While we build
releases with our [Go fork](https://github.com/tailscale/go/), its use is not
required.)
@@ -71,8 +71,7 @@ We require [Developer Certificate of
Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin)
`Signed-off-by` lines in commits.
See `git log` for our commit message style. It's basically the same as
[Go's style](https://go.dev/wiki/CommitMessage).
See [commit-messages.md](docs/commit-messages.md) (or skim `git log`) for our commit message style.
## About Us

2
vendor/tailscale.com/VERSION.txt generated vendored
View File

@@ -1 +1 @@
1.82.0
1.94.1

View File

@@ -12,19 +12,20 @@ package appc
import (
"context"
"fmt"
"maps"
"net/netip"
"slices"
"strings"
"sync"
"time"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/syncs"
"tailscale.com/types/appctype"
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/util/clientmetric"
"tailscale.com/util/dnsname"
"tailscale.com/util/eventbus"
"tailscale.com/util/execqueue"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
)
@@ -115,19 +116,6 @@ func metricStoreRoutes(rate, nRoutes int64) {
recordMetric(nRoutes, metricStoreRoutesNBuckets, metricStoreRoutesN)
}
// RouteInfo is a data structure used to persist the in memory state of an AppConnector
// so that we can know, even after a restart, which routes came from ACLs and which were
// learned from domains.
type RouteInfo struct {
// Control is the routes from the 'routes' section of an app connector acl.
Control []netip.Prefix `json:",omitempty"`
// Domains are the routes discovered by observing DNS lookups for configured domains.
Domains map[string][]netip.Addr `json:",omitempty"`
// Wildcards are the configured DNS lookup domains to observe. When a DNS query matches Wildcards,
// its result is added to Domains.
Wildcards []string `json:",omitempty"`
}
// AppConnector is an implementation of an AppConnector that performs
// its function as a subsystem inside of a tailscale node. At the control plane
// side App Connector routing is configured in terms of domains rather than IP
@@ -138,14 +126,20 @@ type RouteInfo struct {
// routes not yet served by the AppConnector the local node configuration is
// updated to advertise the new route.
type AppConnector struct {
// These fields are immutable after initialization.
logf logger.Logf
eventBus *eventbus.Bus
routeAdvertiser RouteAdvertiser
pubClient *eventbus.Client
updatePub *eventbus.Publisher[appctype.RouteUpdate]
storePub *eventbus.Publisher[appctype.RouteInfo]
// storeRoutesFunc will be called to persist routes if it is not nil.
storeRoutesFunc func(*RouteInfo) error
// hasStoredRoutes records whether the connector was initialized with
// persisted route information.
hasStoredRoutes bool
// mu guards the fields that follow
mu sync.Mutex
mu syncs.Mutex
// domains is a map of lower case domain names with no trailing dot, to an
// ordered list of resolved IP addresses.
@@ -164,53 +158,83 @@ type AppConnector struct {
writeRateDay *rateLogger
}
// Config carries the settings for an [AppConnector].
type Config struct {
// Logf is the logger to which debug logs from the connector will be sent.
// It must be non-nil.
Logf logger.Logf
// EventBus receives events when the collection of routes maintained by the
// connector is updated. It must be non-nil.
EventBus *eventbus.Bus
// RouteAdvertiser allows the connector to update the set of advertised routes.
RouteAdvertiser RouteAdvertiser
// RouteInfo, if non-nil, use used as the initial set of routes for the
// connector. If nil, the connector starts empty.
RouteInfo *appctype.RouteInfo
// HasStoredRoutes indicates that the connector should assume stored routes.
HasStoredRoutes bool
}
// NewAppConnector creates a new AppConnector.
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser, routeInfo *RouteInfo, storeRoutesFunc func(*RouteInfo) error) *AppConnector {
func NewAppConnector(c Config) *AppConnector {
switch {
case c.Logf == nil:
panic("missing logger")
case c.EventBus == nil:
panic("missing event bus")
}
ec := c.EventBus.Client("appc.AppConnector")
ac := &AppConnector{
logf: logger.WithPrefix(logf, "appc: "),
routeAdvertiser: routeAdvertiser,
storeRoutesFunc: storeRoutesFunc,
logf: logger.WithPrefix(c.Logf, "appc: "),
eventBus: c.EventBus,
pubClient: ec,
updatePub: eventbus.Publish[appctype.RouteUpdate](ec),
storePub: eventbus.Publish[appctype.RouteInfo](ec),
routeAdvertiser: c.RouteAdvertiser,
hasStoredRoutes: c.HasStoredRoutes,
}
if routeInfo != nil {
ac.domains = routeInfo.Domains
ac.wildcards = routeInfo.Wildcards
ac.controlRoutes = routeInfo.Control
if c.RouteInfo != nil {
ac.domains = c.RouteInfo.Domains
ac.wildcards = c.RouteInfo.Wildcards
ac.controlRoutes = c.RouteInfo.Control
}
ac.writeRateMinute = newRateLogger(time.Now, time.Minute, func(c int64, s time.Time, l int64) {
ac.logf("routeInfo write rate: %d in minute starting at %v (%d routes)", c, s, l)
metricStoreRoutes(c, l)
ac.writeRateMinute = newRateLogger(time.Now, time.Minute, func(c int64, s time.Time, ln int64) {
ac.logf("routeInfo write rate: %d in minute starting at %v (%d routes)", c, s, ln)
metricStoreRoutes(c, ln)
})
ac.writeRateDay = newRateLogger(time.Now, 24*time.Hour, func(c int64, s time.Time, l int64) {
ac.logf("routeInfo write rate: %d in 24 hours starting at %v (%d routes)", c, s, l)
ac.writeRateDay = newRateLogger(time.Now, 24*time.Hour, func(c int64, s time.Time, ln int64) {
ac.logf("routeInfo write rate: %d in 24 hours starting at %v (%d routes)", c, s, ln)
})
return ac
}
// ShouldStoreRoutes returns true if the appconnector was created with the controlknob on
// and is storing its discovered routes persistently.
func (e *AppConnector) ShouldStoreRoutes() bool {
return e.storeRoutesFunc != nil
}
func (e *AppConnector) ShouldStoreRoutes() bool { return e.hasStoredRoutes }
// storeRoutesLocked takes the current state of the AppConnector and persists it
func (e *AppConnector) storeRoutesLocked() error {
if !e.ShouldStoreRoutes() {
return nil
}
func (e *AppConnector) storeRoutesLocked() {
if e.storePub.ShouldPublish() {
// log write rate and write size
numRoutes := int64(len(e.controlRoutes))
for _, rs := range e.domains {
numRoutes += int64(len(rs))
}
e.writeRateMinute.update(numRoutes)
e.writeRateDay.update(numRoutes)
// log write rate and write size
numRoutes := int64(len(e.controlRoutes))
for _, rs := range e.domains {
numRoutes += int64(len(rs))
e.storePub.Publish(appctype.RouteInfo{
// Clone here, as the subscriber will handle these outside our lock.
Control: slices.Clone(e.controlRoutes),
Domains: maps.Clone(e.domains),
Wildcards: slices.Clone(e.wildcards),
})
}
e.writeRateMinute.update(numRoutes)
e.writeRateDay.update(numRoutes)
return e.storeRoutesFunc(&RouteInfo{
Control: e.controlRoutes,
Domains: e.domains,
Wildcards: e.wildcards,
})
}
// ClearRoutes removes all route state from the AppConnector.
@@ -220,7 +244,8 @@ func (e *AppConnector) ClearRoutes() error {
e.controlRoutes = nil
e.domains = nil
e.wildcards = nil
return e.storeRoutesLocked()
e.storeRoutesLocked()
return nil
}
// UpdateDomainsAndRoutes starts an asynchronous update of the configuration
@@ -249,6 +274,18 @@ func (e *AppConnector) Wait(ctx context.Context) {
e.queue.Wait(ctx)
}
// Close closes the connector and cleans up resources associated with it.
// It is safe (and a noop) to call Close on nil.
func (e *AppConnector) Close() {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
e.queue.Shutdown() // TODO(creachadair): Should we wait for it too?
e.pubClient.Close()
}
func (e *AppConnector) updateDomains(domains []string) {
e.mu.Lock()
defer e.mu.Unlock()
@@ -280,20 +317,26 @@ func (e *AppConnector) updateDomains(domains []string) {
}
}
// Everything left in oldDomains is a domain we're no longer tracking
// and if we are storing route info we can unadvertise the routes
if e.ShouldStoreRoutes() {
// Everything left in oldDomains is a domain we're no longer tracking and we
// can unadvertise the routes.
if e.hasStoredRoutes {
toRemove := []netip.Prefix{}
for _, addrs := range oldDomains {
for _, a := range addrs {
toRemove = append(toRemove, netip.PrefixFrom(a, a.BitLen()))
}
}
e.queue.Add(func() {
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes on domain removal: %v: %v: %v", slicesx.MapKeys(oldDomains), toRemove, err)
if len(toRemove) != 0 {
if ra := e.routeAdvertiser; ra != nil {
e.queue.Add(func() {
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes on domain removal: %v: %v: %v", slicesx.MapKeys(oldDomains), toRemove, err)
}
})
}
})
e.updatePub.Publish(appctype.RouteUpdate{Unadvertise: toRemove})
}
}
e.logf("handling domains: %v and wildcards: %v", slicesx.MapKeys(e.domains), e.wildcards)
@@ -314,11 +357,10 @@ func (e *AppConnector) updateRoutes(routes []netip.Prefix) {
var toRemove []netip.Prefix
// If we're storing routes and know e.controlRoutes is a good
// representation of what should be in AdvertisedRoutes we can stop
// advertising routes that used to be in e.controlRoutes but are not
// in routes.
if e.ShouldStoreRoutes() {
// If we know e.controlRoutes is a good representation of what should be in
// AdvertisedRoutes we can stop advertising routes that used to be in
// e.controlRoutes but are not in routes.
if e.hasStoredRoutes {
toRemove = routesWithout(e.controlRoutes, routes)
}
@@ -335,19 +377,23 @@ nextRoute:
}
}
e.queue.Add(func() {
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
e.logf("failed to advertise routes: %v: %v", routes, err)
}
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes: %v: %v", toRemove, err)
}
if e.routeAdvertiser != nil {
e.queue.Add(func() {
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
e.logf("failed to advertise routes: %v: %v", routes, err)
}
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes: %v: %v", toRemove, err)
}
})
}
e.updatePub.Publish(appctype.RouteUpdate{
Advertise: routes,
Unadvertise: toRemove,
})
e.controlRoutes = routes
if err := e.storeRoutesLocked(); err != nil {
e.logf("failed to store route info: %v", err)
}
e.storeRoutesLocked()
}
// Domains returns the currently configured domain list.
@@ -372,124 +418,6 @@ func (e *AppConnector) DomainRoutes() map[string][]netip.Addr {
return drCopy
}
// ObserveDNSResponse is a callback invoked by the DNS resolver when a DNS
// response is being returned over the PeerAPI. The response is parsed and
// matched against the configured domains, if matched the routeAdvertiser is
// advised to advertise the discovered route.
func (e *AppConnector) ObserveDNSResponse(res []byte) error {
var p dnsmessage.Parser
if _, err := p.Start(res); err != nil {
return err
}
if err := p.SkipAllQuestions(); err != nil {
return err
}
// cnameChain tracks a chain of CNAMEs for a given query in order to reverse
// a CNAME chain back to the original query for flattening. The keys are
// CNAME record targets, and the value is the name the record answers, so
// for www.example.com CNAME example.com, the map would contain
// ["example.com"] = "www.example.com".
var cnameChain map[string]string
// addressRecords is a list of address records found in the response.
var addressRecords map[string][]netip.Addr
for {
h, err := p.AnswerHeader()
if err == dnsmessage.ErrSectionDone {
break
}
if err != nil {
return err
}
if h.Class != dnsmessage.ClassINET {
if err := p.SkipAnswer(); err != nil {
return err
}
continue
}
switch h.Type {
case dnsmessage.TypeCNAME, dnsmessage.TypeA, dnsmessage.TypeAAAA:
default:
if err := p.SkipAnswer(); err != nil {
return err
}
continue
}
domain := strings.TrimSuffix(strings.ToLower(h.Name.String()), ".")
if len(domain) == 0 {
continue
}
if h.Type == dnsmessage.TypeCNAME {
res, err := p.CNAMEResource()
if err != nil {
return err
}
cname := strings.TrimSuffix(strings.ToLower(res.CNAME.String()), ".")
if len(cname) == 0 {
continue
}
mak.Set(&cnameChain, cname, domain)
continue
}
switch h.Type {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return err
}
addr := netip.AddrFrom4(r.A)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return err
}
addr := netip.AddrFrom16(r.AAAA)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
default:
if err := p.SkipAnswer(); err != nil {
return err
}
continue
}
}
e.mu.Lock()
defer e.mu.Unlock()
for domain, addrs := range addressRecords {
domain, isRouted := e.findRoutedDomainLocked(domain, cnameChain)
// domain and none of the CNAMEs in the chain are routed
if !isRouted {
continue
}
// advertise each address we have learned for the routed domain, that
// was not already known.
var toAdvertise []netip.Prefix
for _, addr := range addrs {
if !e.isAddrKnownLocked(domain, addr) {
toAdvertise = append(toAdvertise, netip.PrefixFrom(addr, addr.BitLen()))
}
}
if len(toAdvertise) > 0 {
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
e.scheduleAdvertisement(domain, toAdvertise...)
}
}
return nil
}
// starting from the given domain that resolved to an address, find it, or any
// of the domains in the CNAME chain toward resolving it, that are routed
// domains, returning the routed domain name and a bool indicating whether a
@@ -544,10 +472,13 @@ func (e *AppConnector) isAddrKnownLocked(domain string, addr netip.Addr) bool {
// associated with the given domain.
func (e *AppConnector) scheduleAdvertisement(domain string, routes ...netip.Prefix) {
e.queue.Add(func() {
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
e.logf("failed to advertise routes for %s: %v: %v", domain, routes, err)
return
if e.routeAdvertiser != nil {
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
e.logf("failed to advertise routes for %s: %v: %v", domain, routes, err)
return
}
}
e.updatePub.Publish(appctype.RouteUpdate{Advertise: routes})
e.mu.Lock()
defer e.mu.Unlock()
@@ -561,9 +492,7 @@ func (e *AppConnector) scheduleAdvertisement(domain string, routes ...netip.Pref
e.logf("[v2] advertised route for %v: %v", domain, addr)
}
}
if err := e.storeRoutesLocked(); err != nil {
e.logf("failed to store route info: %v", err)
}
e.storeRoutesLocked()
})
}
@@ -581,8 +510,8 @@ func (e *AppConnector) addDomainAddrLocked(domain string, addr netip.Addr) {
slices.SortFunc(e.domains[domain], compareAddr)
}
func compareAddr(l, r netip.Addr) int {
return l.Compare(r)
func compareAddr(a, b netip.Addr) int {
return a.Compare(b)
}
// routesWithout returns a without b where a and b

110
vendor/tailscale.com/appc/conn25.go generated vendored Normal file
View File

@@ -0,0 +1,110 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package appc
import (
"net/netip"
"sync"
"tailscale.com/tailcfg"
)
// 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 dupeTransitIPMessage = "Duplicate transit address in ConnectorTransitIPRequest"
// 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
}
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)
}
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"`
}

61
vendor/tailscale.com/appc/ippool.go generated vendored Normal file
View File

@@ -0,0 +1,61 @@
// 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
}

132
vendor/tailscale.com/appc/observe.go generated vendored Normal file
View File

@@ -0,0 +1,132 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_appconnectors
package appc
import (
"net/netip"
"strings"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/util/mak"
)
// ObserveDNSResponse is a callback invoked by the DNS resolver when a DNS
// response is being returned over the PeerAPI. The response is parsed and
// matched against the configured domains, if matched the routeAdvertiser is
// advised to advertise the discovered route.
func (e *AppConnector) ObserveDNSResponse(res []byte) error {
var p dnsmessage.Parser
if _, err := p.Start(res); err != nil {
return err
}
if err := p.SkipAllQuestions(); err != nil {
return err
}
// cnameChain tracks a chain of CNAMEs for a given query in order to reverse
// a CNAME chain back to the original query for flattening. The keys are
// CNAME record targets, and the value is the name the record answers, so
// for www.example.com CNAME example.com, the map would contain
// ["example.com"] = "www.example.com".
var cnameChain map[string]string
// addressRecords is a list of address records found in the response.
var addressRecords map[string][]netip.Addr
for {
h, err := p.AnswerHeader()
if err == dnsmessage.ErrSectionDone {
break
}
if err != nil {
return err
}
if h.Class != dnsmessage.ClassINET {
if err := p.SkipAnswer(); err != nil {
return err
}
continue
}
switch h.Type {
case dnsmessage.TypeCNAME, dnsmessage.TypeA, dnsmessage.TypeAAAA:
default:
if err := p.SkipAnswer(); err != nil {
return err
}
continue
}
domain := strings.TrimSuffix(strings.ToLower(h.Name.String()), ".")
if len(domain) == 0 {
continue
}
if h.Type == dnsmessage.TypeCNAME {
res, err := p.CNAMEResource()
if err != nil {
return err
}
cname := strings.TrimSuffix(strings.ToLower(res.CNAME.String()), ".")
if len(cname) == 0 {
continue
}
mak.Set(&cnameChain, cname, domain)
continue
}
switch h.Type {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return err
}
addr := netip.AddrFrom4(r.A)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return err
}
addr := netip.AddrFrom16(r.AAAA)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
default:
if err := p.SkipAnswer(); err != nil {
return err
}
continue
}
}
e.mu.Lock()
defer e.mu.Unlock()
for domain, addrs := range addressRecords {
domain, isRouted := e.findRoutedDomainLocked(domain, cnameChain)
// domain and none of the CNAMEs in the chain are routed
if !isRouted {
continue
}
// advertise each address we have learned for the routed domain, that
// was not already known.
var toAdvertise []netip.Prefix
for _, addr := range addrs {
if !e.isAddrKnownLocked(domain, addr) {
toAdvertise = append(toAdvertise, netip.PrefixFrom(addr, addr.BitLen()))
}
}
if len(toAdvertise) > 0 {
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
e.scheduleAdvertisement(domain, toAdvertise...)
}
}
return nil
}

8
vendor/tailscale.com/appc/observe_disabled.go generated vendored Normal file
View File

@@ -0,0 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ts_omit_appconnectors
package appc
func (e *AppConnector) ObserveDNSResponse(res []byte) error { return nil }

View File

@@ -48,5 +48,9 @@ func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
if err := f.Close(); err != nil {
return err
}
return rename(tmpName, filename)
return Rename(tmpName, filename)
}
// Rename srcFile to dstFile, similar to [os.Rename] but preserving file
// attributes and ACLs on Windows.
func Rename(srcFile, dstFile string) error { return rename(srcFile, dstFile) }

View File

@@ -44,7 +44,7 @@ var (
)
func replaceFileW(replaced *uint16, replacement *uint16, backup *uint16, flags uint32, exclude unsafe.Pointer, reserved unsafe.Pointer) (err error) {
r1, _, e1 := syscall.Syscall6(procReplaceFileW.Addr(), 6, uintptr(unsafe.Pointer(replaced)), uintptr(unsafe.Pointer(replacement)), uintptr(unsafe.Pointer(backup)), uintptr(flags), uintptr(exclude), uintptr(reserved))
r1, _, e1 := syscall.SyscallN(procReplaceFileW.Addr(), uintptr(unsafe.Pointer(replaced)), uintptr(unsafe.Pointer(replacement)), uintptr(unsafe.Pointer(backup)), uintptr(flags), uintptr(exclude), uintptr(reserved))
if int32(r1) == 0 {
err = errnoErr(e1)
}

24
vendor/tailscale.com/build_dist.sh generated vendored
View File

@@ -18,7 +18,7 @@ fi
eval `CGO_ENABLED=0 GOOS=$($go env GOHOSTOS) GOARCH=$($go env GOHOSTARCH) $go run ./cmd/mkversion`
if [ "$1" = "shellvars" ]; then
if [ "$#" -ge 1 ] && [ "$1" = "shellvars" ]; then
cat <<EOF
VERSION_MINOR="$VERSION_MINOR"
VERSION_SHORT="$VERSION_SHORT"
@@ -28,18 +28,34 @@ EOF
exit 0
fi
tags=""
tags="${TAGS:-}"
ldflags="-X tailscale.com/version.longStamp=${VERSION_LONG} -X tailscale.com/version.shortStamp=${VERSION_SHORT}"
# build_dist.sh arguments must precede go build arguments.
while [ "$#" -gt 1 ]; do
case "$1" in
--extra-small)
if [ ! -z "${TAGS:-}" ]; then
echo "set either --extra-small or \$TAGS, but not both"
exit 1
fi
shift
ldflags="$ldflags -w -s"
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion,ts_omit_ssh,ts_omit_wakeonlan,ts_omit_capture"
tags="${tags:+$tags,},$(GOOS= GOARCH= $go run ./cmd/featuretags --min --add=osrouter)"
;;
--min)
# --min is like --extra-small but even smaller, removing all features,
# even if it results in a useless binary (e.g. removing both netstack +
# osrouter). It exists for benchmarking purposes only.
shift
ldflags="$ldflags -w -s"
tags="${tags:+$tags,},$(GOOS= GOARCH= $go run ./cmd/featuretags --min)"
;;
--box)
if [ ! -z "${TAGS:-}" ]; then
echo "set either --box or \$TAGS, but not both"
exit 1
fi
shift
tags="${tags:+$tags,}ts_include_cli"
;;
@@ -49,4 +65,4 @@ while [ "$#" -gt 1 ]; do
esac
done
exec $go build ${tags:+-tags=$tags} -ldflags "$ldflags" "$@"
exec $go build ${tags:+-tags=$tags} -trimpath -ldflags "$ldflags" "$@"

54
vendor/tailscale.com/build_docker.sh generated vendored
View File

@@ -6,6 +6,16 @@
# hash of this repository as produced by ./cmd/mkversion.
# This is the image build mechanim 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.
#
# To build a Tailscale image and push to the local docker registry:
# $ REPO=local/tailscale TAGS=v0.0.1 PLATFORM=local make publishdevimage
#
# To build a Tailscale image and push to a remote docker registry:
#
# $ REPO=<your-registry>/<your-repo>/tailscale TAGS=v0.0.1 make publishdevimage
set -eu
@@ -16,7 +26,7 @@ eval "$(./build_dist.sh shellvars)"
DEFAULT_TARGET="client"
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
DEFAULT_BASE="tailscale/alpine-base:3.19"
DEFAULT_BASE="tailscale/alpine-base:3.22"
# Set a few pre-defined OCI annotations. The source annotation is used by tools such as Renovate that scan the linked
# Github repo to find release notes for any new image tags. Note that for official Tailscale images the default
# annotations defined here will be overriden by release scripts that call this script.
@@ -28,6 +38,7 @@ TARGET="${TARGET:-${DEFAULT_TARGET}}"
TAGS="${TAGS:-${DEFAULT_TAGS}}"
BASE="${BASE:-${DEFAULT_BASE}}"
PLATFORM="${PLATFORM:-}" # default to all platforms
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
ANNOTATIONS="${ANNOTATIONS:-${DEFAULT_ANNOTATIONS}}"
@@ -52,6 +63,7 @@ case "$TARGET" in
--push="${PUSH}" \
--target="${PLATFORM}" \
--annotations="${ANNOTATIONS}" \
--files="${FILES}" \
/usr/local/bin/containerboot
;;
k8s-operator)
@@ -70,6 +82,7 @@ case "$TARGET" in
--push="${PUSH}" \
--target="${PLATFORM}" \
--annotations="${ANNOTATIONS}" \
--files="${FILES}" \
/usr/local/bin/operator
;;
k8s-nameserver)
@@ -88,8 +101,47 @@ case "$TARGET" in
--push="${PUSH}" \
--target="${PLATFORM}" \
--annotations="${ANNOTATIONS}" \
--files="${FILES}" \
/usr/local/bin/k8s-nameserver
;;
tsidp)
DEFAULT_REPOS="tailscale/tsidp"
REPOS="${REPOS:-${DEFAULT_REPOS}}"
go run github.com/tailscale/mkctr \
--gopaths="tailscale.com/cmd/tsidp:/usr/local/bin/tsidp" \
--ldflags=" \
-X tailscale.com/version.longStamp=${VERSION_LONG} \
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
--base="${BASE}" \
--tags="${TAGS}" \
--gotags="ts_package_container" \
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
--annotations="${ANNOTATIONS}" \
--files="${FILES}" \
/usr/local/bin/tsidp
;;
k8s-proxy)
DEFAULT_REPOS="tailscale/k8s-proxy"
REPOS="${REPOS:-${DEFAULT_REPOS}}"
go run github.com/tailscale/mkctr \
--gopaths="tailscale.com/cmd/k8s-proxy:/usr/local/bin/k8s-proxy" \
--ldflags=" \
-X tailscale.com/version.longStamp=${VERSION_LONG} \
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
--base="${BASE}" \
--tags="${TAGS}" \
--gotags="ts_kube,ts_package_container" \
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
--annotations="${ANNOTATIONS}" \
--files="${FILES}" \
/usr/local/bin/k8s-proxy
;;
*)
echo "unknown target: $TARGET"
exit 1

151
vendor/tailscale.com/client/local/cert.go generated vendored Normal file
View File

@@ -0,0 +1,151 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !ts_omit_acme
package local
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/url"
"strings"
"time"
"go4.org/mem"
)
// 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.
//
// The control plane will only permit SetDNS requests with very
// specific names and values. The name should be
// "_acme-challenge." + your node's MagicDNS name. It's expected that
// clients cache the certs from LetsEncrypt (or whichever CA is
// providing them) and only request new ones as needed; the control plane
// rate limits SetDNS requests.
//
// This is a low-level interface; it's expected that most Tailscale
// users use a higher level interface to getting/using TLS
// certificates.
func (lc *Client) SetDNS(ctx context.Context, name, value string) error {
v := url.Values{}
v.Set("name", name)
v.Set("value", value)
_, err := lc.send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
return err
}
// CertPair returns a cert and private key for the provided DNS domain.
//
// It returns a cached certificate from disk if it's still valid.
//
// Deprecated: use [Client.CertPair].
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return defaultClient.CertPair(ctx, domain)
}
// CertPair returns a cert and private key for the provided DNS domain.
//
// It returns a cached certificate from disk if it's still valid.
//
// 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)
}
// CertPairWithValidity returns a cert and private key for the provided DNS
// domain.
//
// It returns a cached certificate from disk if it's still valid.
// When minValidity is non-zero, the returned certificate will be valid for at
// least the given duration, if permitted by the CA. If the certificate is
// valid, but for less than minValidity, it will be synchronously renewed.
//
// 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 {
return nil, nil, err
}
// with ?type=pair, the response PEM is first the one private
// key PEM block, then the cert PEM blocks.
i := mem.Index(mem.B(res), mem.S("--\n--"))
if i == -1 {
return nil, nil, fmt.Errorf("unexpected output: no delimiter")
}
i += len("--\n")
keyPEM, certPEM = res[:i], res[i:]
if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) {
return nil, nil, fmt.Errorf("unexpected output: key in cert")
}
return certPEM, keyPEM, nil
}
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
//
// It returns a cached certificate from disk if it's still valid.
//
// It's the right signature to use as the value of
// [tls.Config.GetCertificate].
//
// Deprecated: use [Client.GetCertificate].
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return defaultClient.GetCertificate(hi)
}
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
//
// It returns a cached certificate from disk if it's still valid.
//
// It's the right signature to use as the value of
// [tls.Config.GetCertificate].
//
// API maturity: this is considered a stable API.
func (lc *Client) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if hi == nil || hi.ServerName == "" {
return nil, errors.New("no SNI ServerName")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
name := hi.ServerName
if !strings.Contains(name, ".") {
if v, ok := lc.ExpandSNIName(ctx, name); ok {
name = v
}
}
certPEM, keyPEM, err := lc.CertPair(ctx, name)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, err
}
return &cert, nil
}
// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
//
// Deprecated: use [Client.ExpandSNIName].
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
return defaultClient.ExpandSNIName(ctx, name)
}
// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
func (lc *Client) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
st, err := lc.StatusWithoutPeers(ctx)
if err != nil {
return "", false
}
for _, d := range st.CertDomains {
if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' {
return d, true
}
}
return "", false
}

84
vendor/tailscale.com/client/local/debugportmapper.go generated vendored Normal file
View File

@@ -0,0 +1,84 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_debugportmapper
package local
import (
"cmp"
"context"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"strconv"
"time"
"tailscale.com/client/tailscale/apitype"
)
// DebugPortmapOpts contains options for the [Client.DebugPortmap] command.
type DebugPortmapOpts struct {
// Duration is how long the mapping should be created for. It defaults
// to 5 seconds if not set.
Duration time.Duration
// Type is the kind of portmap to debug. The empty string instructs the
// portmap client to perform all known types. Other valid options are
// "pmp", "pcp", and "upnp".
Type string
// GatewayAddr specifies the gateway address used during portmapping.
// If set, SelfAddr must also be set. If unset, it will be
// autodetected.
GatewayAddr netip.Addr
// SelfAddr specifies the gateway address used during portmapping. If
// set, GatewayAddr must also be set. If unset, it will be
// autodetected.
SelfAddr netip.Addr
// LogHTTP instructs the debug-portmap endpoint to print all HTTP
// requests and responses made to the logs.
LogHTTP bool
}
// DebugPortmap invokes the debug-portmap endpoint, and returns an
// io.ReadCloser that can be used to read the logs that are printed during this
// process.
//
// opts can be nil; if so, default values will be used.
func (lc *Client) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts) (io.ReadCloser, error) {
vals := make(url.Values)
if opts == nil {
opts = &DebugPortmapOpts{}
}
vals.Set("duration", cmp.Or(opts.Duration, 5*time.Second).String())
vals.Set("type", opts.Type)
vals.Set("log_http", strconv.FormatBool(opts.LogHTTP))
if opts.GatewayAddr.IsValid() != opts.SelfAddr.IsValid() {
return nil, fmt.Errorf("both GatewayAddr and SelfAddr must be provided if one is")
} else if opts.GatewayAddr.IsValid() {
vals.Set("gateway_and_self", fmt.Sprintf("%s/%s", opts.GatewayAddr, opts.SelfAddr))
}
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-portmap?"+vals.Encode(), nil)
if err != nil {
return nil, err
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
body, _ := io.ReadAll(res.Body)
res.Body.Close()
return nil, fmt.Errorf("HTTP %s: %s", res.Status, body)
}
return res.Body, nil
}

View File

@@ -1,21 +1,20 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.22
// Package local contains a Go client for the Tailscale LocalAPI.
package local
import (
"bufio"
"bytes"
"cmp"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"iter"
"net"
"net/http"
"net/http/httptrace"
@@ -28,21 +27,24 @@ import (
"sync"
"time"
"go4.org/mem"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/drive"
"tailscale.com/envknob"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netutil"
"tailscale.com/net/udprelay/status"
"tailscale.com/paths"
"tailscale.com/safesocket"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/appctype"
"tailscale.com/types/dnstype"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
"tailscale.com/util/syspolicy/setting"
"tailscale.com/util/clientmetric"
"tailscale.com/util/eventbus"
)
// defaultClient is the default Client when using the legacy
@@ -294,7 +296,7 @@ func (lc *Client) get200(ctx context.Context, path string) ([]byte, error) {
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
//
// Deprecated: use Client.WhoIs.
// Deprecated: use [Client.WhoIs].
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
return defaultClient.WhoIs(ctx, remoteAddr)
}
@@ -309,7 +311,7 @@ func decodeJSON[T any](b []byte) (ret T, err error) {
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
//
// If not found, the error is ErrPeerNotFound.
// If not found, the error is [ErrPeerNotFound].
//
// 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
@@ -325,7 +327,8 @@ func (lc *Client) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsR
return decodeJSON[*apitype.WhoIsResponse](body)
}
// ErrPeerNotFound is returned by WhoIs and WhoIsNodeKey when a peer is not found.
// ErrPeerNotFound is returned by [Client.WhoIs], [Client.WhoIsNodeKey] and
// [Client.WhoIsProto] when a peer is not found.
var ErrPeerNotFound = errors.New("peer not found")
// WhoIsNodeKey returns the owner of the given wireguard public key.
@@ -345,7 +348,7 @@ func (lc *Client) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apityp
// WhoIsProto returns the owner of the remoteAddr, which must be an IP or
// IP:port, for the given protocol (tcp or udp).
//
// If not found, the error is ErrPeerNotFound.
// If not found, the error is [ErrPeerNotFound].
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 {
@@ -380,18 +383,42 @@ func (lc *Client) UserMetrics(ctx context.Context) ([]byte, error) {
//
// IncrementCounter does not support gauge metrics or negative delta values.
func (lc *Client) IncrementCounter(ctx context.Context, name string, delta int) error {
type metricUpdate struct {
Name string `json:"name"`
Type string `json:"type"`
Value int `json:"value"` // amount to increment by
if !buildfeatures.HasClientMetrics {
return nil
}
if delta < 0 {
return errors.New("negative delta not allowed")
}
_, err := lc.send(ctx, "POST", "/localapi/v0/upload-client-metrics", 200, jsonBody([]metricUpdate{{
_, err := lc.send(ctx, "POST", "/localapi/v0/upload-client-metrics", 200, jsonBody([]clientmetric.MetricUpdate{{
Name: name,
Type: "counter",
Value: delta,
Op: "add",
}}))
return err
}
// IncrementGauge increments the value of a Tailscale daemon's gauge
// metric by the given delta. If the metric has yet to exist, a new gauge
// metric is created and initialized to delta. The delta value can be negative.
func (lc *Client) IncrementGauge(ctx context.Context, name string, delta int) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/upload-client-metrics", 200, jsonBody([]clientmetric.MetricUpdate{{
Name: name,
Type: "gauge",
Value: delta,
Op: "add",
}}))
return err
}
// SetGauge sets the value of a Tailscale daemon's gauge metric to the given value.
// If the metric has yet to exist, a new gauge metric is created and initialized to value.
func (lc *Client) SetGauge(ctx context.Context, name string, value int) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/upload-client-metrics", 200, jsonBody([]clientmetric.MetricUpdate{{
Name: name,
Type: "gauge",
Value: value,
Op: "set",
}}))
return err
}
@@ -413,6 +440,50 @@ func (lc *Client) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
return res.Body, nil
}
// EventBusGraph returns a graph of active publishers and subscribers in the eventbus
// as a [eventbus.DebugTopics]
func (lc *Client) EventBusGraph(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/debug-bus-graph")
}
// 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.
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",
"http://"+apitype.LocalAPIHost+"/localapi/v0/debug-bus-events", nil)
if err != nil {
yield(eventbus.DebugEvent{}, err)
return
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
yield(eventbus.DebugEvent{}, err)
return
}
if res.StatusCode != http.StatusOK {
yield(eventbus.DebugEvent{}, errors.New(res.Status))
return
}
defer res.Body.Close()
dec := json.NewDecoder(bufio.NewReader(res.Body))
for {
var evt eventbus.DebugEvent
if err := dec.Decode(&evt); err == io.EOF {
return
} else if err != nil {
yield(eventbus.DebugEvent{}, err)
return
}
if !yield(evt, nil) {
return
}
}
}
}
// Pprof returns a pprof profile of the Tailscale daemon.
func (lc *Client) Pprof(ctx context.Context, pprofType string, sec int) ([]byte, error) {
var secArg string
@@ -490,7 +561,7 @@ func (lc *Client) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (st
// BugReport logs and returns a log marker that can be shared by the user with support.
//
// This is the same as calling BugReportWithOpts and only specifying the Note
// This is the same as calling [Client.BugReportWithOpts] and only specifying the Note
// field.
func (lc *Client) BugReport(ctx context.Context, note string) (string, error) {
return lc.BugReportWithOpts(ctx, BugReportOpts{Note: note})
@@ -531,68 +602,17 @@ func (lc *Client) DebugResultJSON(ctx context.Context, action string) (any, erro
return x, nil
}
// DebugPortmapOpts contains options for the DebugPortmap command.
type DebugPortmapOpts struct {
// Duration is how long the mapping should be created for. It defaults
// to 5 seconds if not set.
Duration time.Duration
// Type is the kind of portmap to debug. The empty string instructs the
// portmap client to perform all known types. Other valid options are
// "pmp", "pcp", and "upnp".
Type string
// GatewayAddr specifies the gateway address used during portmapping.
// If set, SelfAddr must also be set. If unset, it will be
// autodetected.
GatewayAddr netip.Addr
// SelfAddr specifies the gateway address used during portmapping. If
// set, GatewayAddr must also be set. If unset, it will be
// autodetected.
SelfAddr netip.Addr
// LogHTTP instructs the debug-portmap endpoint to print all HTTP
// requests and responses made to the logs.
LogHTTP bool
}
// DebugPortmap invokes the debug-portmap endpoint, and returns an
// io.ReadCloser that can be used to read the logs that are printed during this
// process.
//
// opts can be nil; if so, default values will be used.
func (lc *Client) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts) (io.ReadCloser, error) {
vals := make(url.Values)
if opts == nil {
opts = &DebugPortmapOpts{}
}
vals.Set("duration", cmp.Or(opts.Duration, 5*time.Second).String())
vals.Set("type", opts.Type)
vals.Set("log_http", strconv.FormatBool(opts.LogHTTP))
if opts.GatewayAddr.IsValid() != opts.SelfAddr.IsValid() {
return nil, fmt.Errorf("both GatewayAddr and SelfAddr must be provided if one is")
} else if opts.GatewayAddr.IsValid() {
vals.Set("gateway_and_self", fmt.Sprintf("%s/%s", opts.GatewayAddr, opts.SelfAddr))
}
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-portmap?"+vals.Encode(), 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)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
}
var x apitype.OptionalFeatures
if err := json.Unmarshal(body, &x); err != nil {
return nil, err
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
body, _ := io.ReadAll(res.Body)
res.Body.Close()
return nil, fmt.Errorf("HTTP %s: %s", res.Status, body)
}
return res.Body, nil
return &x, nil
}
// SetDevStoreKeyValue set a statestore key/value. It's only meant for development.
@@ -612,6 +632,9 @@ func (lc *Client) SetDevStoreKeyValue(ctx context.Context, key, value string) er
// the provided duration. If the duration is in the past, the debug logging
// is disabled.
func (lc *Client) SetComponentDebugLogging(ctx context.Context, component string, d time.Duration) error {
if !buildfeatures.HasDebug {
return feature.ErrUnavailable
}
body, err := lc.send(ctx, "POST",
fmt.Sprintf("/localapi/v0/component-debug-logging?component=%s&secs=%d",
url.QueryEscape(component), int64(d.Seconds())), 200, nil)
@@ -677,7 +700,7 @@ func (lc *Client) WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, erro
return lc.AwaitWaitingFiles(ctx, 0)
}
// AwaitWaitingFiles is like WaitingFiles but takes a duration to await for an answer.
// AwaitWaitingFiles is like [Client.WaitingFiles] but takes a duration to await for an answer.
// If the duration is 0, it will return immediately. The duration is respected at second
// granularity only. If no files are available, it returns (nil, nil).
func (lc *Client) AwaitWaitingFiles(ctx context.Context, d time.Duration) ([]apitype.WaitingFile, error) {
@@ -751,6 +774,9 @@ func (lc *Client) PushFile(ctx context.Context, target tailcfg.StableNodeID, siz
// machine is properly configured to forward IP packets as a subnet router
// or exit node.
func (lc *Client) CheckIPForwarding(ctx context.Context) error {
if !buildfeatures.HasAdvertiseRoutes {
return nil
}
body, err := lc.get200(ctx, "/localapi/v0/check-ip-forwarding")
if err != nil {
return err
@@ -787,6 +813,25 @@ 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.
@@ -844,36 +889,12 @@ func (lc *Client) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Pref
return decodeJSON[*ipn.Prefs](body)
}
// GetEffectivePolicy returns the effective policy for the specified scope.
func (lc *Client) GetEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
scopeID, err := scope.MarshalText()
if err != nil {
return nil, err
}
body, err := lc.get200(ctx, "/localapi/v0/policy/"+string(scopeID))
if err != nil {
return nil, err
}
return decodeJSON[*setting.Snapshot](body)
}
// ReloadEffectivePolicy reloads the effective policy for the specified scope
// by reading and merging policy settings from all applicable policy sources.
func (lc *Client) ReloadEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
scopeID, err := scope.MarshalText()
if err != nil {
return nil, err
}
body, err := lc.send(ctx, "POST", "/localapi/v0/policy/"+string(scopeID), 200, http.NoBody)
if err != nil {
return nil, err
}
return decodeJSON[*setting.Snapshot](body)
}
// 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.
func (lc *Client) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {
if !buildfeatures.HasDNS {
return nil, feature.ErrUnavailable
}
body, err := lc.get200(ctx, "/localapi/v0/dns-osconfig")
if err != nil {
return nil, err
@@ -889,6 +910,9 @@ func (lc *Client) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, err
// It returns the raw DNS response bytes and the resolvers that were used to answer the query
// (often just one, but can be more if we raced multiple resolvers).
func (lc *Client) QueryDNS(ctx context.Context, name string, queryType string) (bytes []byte, resolvers []*dnstype.Resolver, err error) {
if !buildfeatures.HasDNS {
return nil, nil, feature.ErrUnavailable
}
body, err := lc.get200(ctx, fmt.Sprintf("/localapi/v0/dns-query?name=%s&type=%s", url.QueryEscape(name), queryType))
if err != nil {
return nil, nil, err
@@ -919,34 +943,12 @@ func (lc *Client) Logout(ctx context.Context) error {
return err
}
// 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.
//
// The control plane will only permit SetDNS requests with very
// specific names and values. The name should be
// "_acme-challenge." + your node's MagicDNS name. It's expected that
// clients cache the certs from LetsEncrypt (or whichever CA is
// providing them) and only request new ones as needed; the control plane
// rate limits SetDNS requests.
//
// This is a low-level interface; it's expected that most Tailscale
// users use a higher level interface to getting/using TLS
// certificates.
func (lc *Client) SetDNS(ctx context.Context, name, value string) error {
v := url.Values{}
v.Set("name", name)
v.Set("value", value)
_, err := lc.send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
return err
}
// DialTCP connects to the host's port via Tailscale.
//
// The host may be a base DNS name (resolved from the netmap inside
// 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.
// The ctx is only used for the duration of the call, not the lifetime of the [net.Conn].
func (lc *Client) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
return lc.UserDial(ctx, "tcp", host, port)
}
@@ -957,7 +959,7 @@ func (lc *Client) DialTCP(ctx context.Context, host string, port uint16) (net.Co
// a FQDN, or an IP address.
//
// The ctx is only used for the duration of the call, not the lifetime of the
// net.Conn.
// [net.Conn].
func (lc *Client) UserDial(ctx context.Context, network, host string, port uint16) (net.Conn, error) {
connCh := make(chan net.Conn, 1)
trace := httptrace.ClientTrace{
@@ -1021,117 +1023,6 @@ func (lc *Client) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error)
return &derpMap, nil
}
// CertPair returns a cert and private key for the provided DNS domain.
//
// It returns a cached certificate from disk if it's still valid.
//
// Deprecated: use Client.CertPair.
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return defaultClient.CertPair(ctx, domain)
}
// CertPair returns a cert and private key for the provided DNS domain.
//
// It returns a cached certificate from disk if it's still valid.
//
// 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)
}
// CertPairWithValidity returns a cert and private key for the provided DNS
// domain.
//
// It returns a cached certificate from disk if it's still valid.
// When minValidity is non-zero, the returned certificate will be valid for at
// least the given duration, if permitted by the CA. If the certificate is
// valid, but for less than minValidity, it will be synchronously renewed.
//
// 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 {
return nil, nil, err
}
// with ?type=pair, the response PEM is first the one private
// key PEM block, then the cert PEM blocks.
i := mem.Index(mem.B(res), mem.S("--\n--"))
if i == -1 {
return nil, nil, fmt.Errorf("unexpected output: no delimiter")
}
i += len("--\n")
keyPEM, certPEM = res[:i], res[i:]
if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) {
return nil, nil, fmt.Errorf("unexpected output: key in cert")
}
return certPEM, keyPEM, nil
}
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
//
// It returns a cached certificate from disk if it's still valid.
//
// It's the right signature to use as the value of
// tls.Config.GetCertificate.
//
// Deprecated: use Client.GetCertificate.
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return defaultClient.GetCertificate(hi)
}
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
//
// It returns a cached certificate from disk if it's still valid.
//
// It's the right signature to use as the value of
// tls.Config.GetCertificate.
//
// API maturity: this is considered a stable API.
func (lc *Client) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if hi == nil || hi.ServerName == "" {
return nil, errors.New("no SNI ServerName")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
name := hi.ServerName
if !strings.Contains(name, ".") {
if v, ok := lc.ExpandSNIName(ctx, name); ok {
name = v
}
}
certPEM, keyPEM, err := lc.CertPair(ctx, name)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, err
}
return &cert, nil
}
// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
//
// Deprecated: use Client.ExpandSNIName.
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
return defaultClient.ExpandSNIName(ctx, name)
}
// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
func (lc *Client) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
st, err := lc.StatusWithoutPeers(ctx)
if err != nil {
return "", false
}
for _, d := range st.CertDomains {
if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' {
return d, true
}
}
return "", false
}
// PingOpts contains options for the ping request.
//
// The zero value is valid, which means to use defaults.
@@ -1165,197 +1056,6 @@ func (lc *Client) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.Ping
return lc.PingWithOpts(ctx, ip, pingtype, PingOpts{})
}
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
func (lc *Client) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
return decodeJSON[*ipnstate.NetworkLockStatus](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) {
var b bytes.Buffer
type initRequest struct {
Keys []tka.Key
DisablementValues [][]byte
SupportDisablement []byte
}
if err := json.NewEncoder(&b).Encode(initRequest{Keys: keys, DisablementValues: disablementValues, SupportDisablement: supportDisablement}); err != nil {
return nil, err
}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/init", 200, &b)
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
return decodeJSON[*ipnstate.NetworkLockStatus](body)
}
// NetworkLockWrapPreauthKey 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) {
encodedPrivate, err := tkaKey.MarshalText()
if err != nil {
return "", err
}
var b bytes.Buffer
type wrapRequest struct {
TSKey string
TKAKey string // key.NLPrivate.MarshalText
}
if err := json.NewEncoder(&b).Encode(wrapRequest{TSKey: preauthKey, TKAKey: string(encodedPrivate)}); err != nil {
return "", err
}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/wrap-preauth-key", 200, &b)
if err != nil {
return "", fmt.Errorf("error: %w", err)
}
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 {
var b bytes.Buffer
type modifyRequest struct {
AddKeys []tka.Key
RemoveKeys []tka.Key
}
if err := json.NewEncoder(&b).Encode(modifyRequest{AddKeys: addKeys, RemoveKeys: removeKeys}); err != nil {
return err
}
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/modify", 204, &b); err != nil {
return fmt.Errorf("error: %w", err)
}
return nil
}
// NetworkLockSign signs the specified node-key and transmits that signature to the control plane.
// rotationPublic, if specified, must be an ed25519 public key.
func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
var b bytes.Buffer
type signRequest struct {
NodeKey key.NodePublic
RotationPublic []byte
}
if err := json.NewEncoder(&b).Encode(signRequest{NodeKey: nodeKey, RotationPublic: rotationPublic}); err != nil {
return err
}
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/sign", 200, &b); err != nil {
return fmt.Errorf("error: %w", err)
}
return nil
}
// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/affected-sigs", 200, bytes.NewReader(keyID))
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
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) {
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)
}
// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
// This endpoint expects an empty JSON stanza as the payload.
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil {
return err
}
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/force-local-disable", 200, &b); err != nil {
return fmt.Errorf("error: %w", err)
}
return nil
}
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
// in url and returns information extracted from it.
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
vr := struct {
URL string
}{url}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/verify-deeplink", 200, jsonBody(vr))
if err != nil {
return nil, fmt.Errorf("sending verify-deeplink: %w", err)
}
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) {
vr := struct {
Keys []tkatype.KeyID
ForkFrom string
}{removeKeys, forkFrom.String()}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/generate-recovery-aum", 200, jsonBody(vr))
if err != nil {
return nil, fmt.Errorf("sending generate-recovery-aum: %w", err)
}
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) {
r := bytes.NewReader(aum.Serialize())
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
if err != nil {
return nil, fmt.Errorf("sending cosign-recovery-aum: %w", err)
}
return body, nil
}
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
r := bytes.NewReader(aum.Serialize())
_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
if err != nil {
return fmt.Errorf("sending cosign-recovery-aum: %w", err)
}
return nil
}
// SetServeConfig sets or replaces the serving settings.
// If config is nil, settings are cleared and serving is disabled.
func (lc *Client) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
h := make(http.Header)
if config != nil {
h.Set("If-Match", config.ETag)
}
_, _, err := lc.sendWithHeaders(ctx, "POST", "/localapi/v0/serve-config", 200, jsonBody(config), h)
if err != nil {
return fmt.Errorf("sending serve config: %w", err)
}
return nil
}
// 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.
@@ -1367,40 +1067,6 @@ func (lc *Client) DisconnectControl(ctx context.Context) error {
return nil
}
// NetworkLockDisable shuts down network-lock across the tailnet.
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
return fmt.Errorf("error: %w", err)
}
return nil
}
// GetServeConfig return the current serve config.
//
// If the serve config is empty, it returns (nil, nil).
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 {
return nil, fmt.Errorf("getting serve config: %w", err)
}
sc, err := getServeConfigFromJSON(body)
if err != nil {
return nil, err
}
if sc == nil {
sc = new(ipn.ServeConfig)
}
sc.ETag = h.Get("Etag")
return sc, nil
}
func getServeConfigFromJSON(body []byte) (sc *ipn.ServeConfig, err error) {
if err := json.Unmarshal(body, &sc); err != nil {
return nil, err
}
return sc, nil
}
// tailscaledConnectHint gives a little thing about why tailscaled (or
// platform equivalent) is not answering localapi connections.
//
@@ -1502,9 +1168,9 @@ func (lc *Client) SwitchProfile(ctx context.Context, profile ipn.ProfileID) erro
// DeleteProfile removes the profile with the given ID.
// If the profile is the current profile, an empty profile
// will be selected as if SwitchToEmptyProfile was called.
// will be selected as if [Client.SwitchToEmptyProfile] was called.
func (lc *Client) DeleteProfile(ctx context.Context, profile ipn.ProfileID) error {
_, err := lc.send(ctx, "DELETE", "/localapi/v0/profiles"+url.PathEscape(string(profile)), http.StatusNoContent, nil)
_, err := lc.send(ctx, "DELETE", "/localapi/v0/profiles/"+url.PathEscape(string(profile)), http.StatusNoContent, nil)
return err
}
@@ -1556,10 +1222,20 @@ func (lc *Client) DebugSetExpireIn(ctx context.Context, d time.Duration) error {
return err
}
// DebugPeerRelaySessions returns debug information about the current peer
// relay sessions running through this node.
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 {
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[*status.ServerStatus](body)
}
// StreamDebugCapture streams a pcap-formatted packet capture.
//
// The provided context does not determine the lifetime of the
// returned io.ReadCloser.
// returned [io.ReadCloser].
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 {
@@ -1582,7 +1258,7 @@ func (lc *Client) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error)
// The context is used for the life of the watch, not just the call to
// WatchIPNBus.
//
// The returned IPNBusWatcher's Close method must be called when done to release
// The returned [IPNBusWatcher]'s Close method must be called when done to release
// resources.
//
// A default set of ipn.Notify messages are returned but the set can be modified by mask.
@@ -1609,7 +1285,7 @@ func (lc *Client) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*IP
}, nil
}
// CheckUpdate returns a tailcfg.ClientVersion indicating whether or not an update is available
// 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.
func (lc *Client) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, error) {
@@ -1685,7 +1361,7 @@ func (lc *Client) DriveShareList(ctx context.Context) ([]*drive.Share, error) {
}
// IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus.
// It's returned by Client.WatchIPNBus.
// It's returned by [Client.WatchIPNBus].
//
// It must be closed when done.
type IPNBusWatcher struct {
@@ -1693,7 +1369,7 @@ type IPNBusWatcher struct {
httpRes *http.Response
dec *json.Decoder
mu sync.Mutex
mu syncs.Mutex
closed bool
}
@@ -1729,3 +1405,34 @@ func (lc *Client) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggesti
}
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.
func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {
body, err := lc.get200(ctx, "/localapi/v0/check-so-mark-in-use")
if err != nil {
return false, err
}
var res struct {
UseSOMark bool `json:"useSoMark"`
}
if err := json.Unmarshal(body, &res); err != nil {
return false, fmt.Errorf("invalid JSON from check-so-mark-in-use: %w", err)
}
return res.UseSOMark, nil
}
// ShutdownTailscaled requests a graceful shutdown of tailscaled.
func (lc *Client) ShutdownTailscaled(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/shutdown", 200, nil)
return err
}
func (lc *Client) GetAppConnectorRouteInfo(ctx context.Context) (appctype.RouteInfo, error) {
body, err := lc.get200(ctx, "/localapi/v0/appc-route-info")
if err != nil {
return appctype.RouteInfo{}, err
}
return decodeJSON[appctype.RouteInfo](body)
}

55
vendor/tailscale.com/client/local/serve.go generated vendored Normal file
View File

@@ -0,0 +1,55 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serve
package local
import (
"context"
"encoding/json"
"fmt"
"net/http"
"tailscale.com/ipn"
)
// GetServeConfig return the current serve config.
//
// If the serve config is empty, it returns (nil, nil).
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 {
return nil, fmt.Errorf("getting serve config: %w", err)
}
sc, err := getServeConfigFromJSON(body)
if err != nil {
return nil, err
}
if sc == nil {
sc = new(ipn.ServeConfig)
}
sc.ETag = h.Get("Etag")
return sc, nil
}
func getServeConfigFromJSON(body []byte) (sc *ipn.ServeConfig, err error) {
if err := json.Unmarshal(body, &sc); err != nil {
return nil, err
}
return sc, nil
}
// SetServeConfig sets or replaces the serving settings.
// If config is nil, settings are cleared and serving is disabled.
func (lc *Client) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
h := make(http.Header)
if config != nil {
h.Set("If-Match", config.ETag)
}
_, _, err := lc.sendWithHeaders(ctx, "POST", "/localapi/v0/serve-config", 200, jsonBody(config), h)
if err != nil {
return fmt.Errorf("sending serve config: %w", err)
}
return nil
}

40
vendor/tailscale.com/client/local/syspolicy.go generated vendored Normal file
View File

@@ -0,0 +1,40 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_syspolicy
package local
import (
"context"
"net/http"
"tailscale.com/util/syspolicy/setting"
)
// GetEffectivePolicy returns the effective policy for the specified scope.
func (lc *Client) GetEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
scopeID, err := scope.MarshalText()
if err != nil {
return nil, err
}
body, err := lc.get200(ctx, "/localapi/v0/policy/"+string(scopeID))
if err != nil {
return nil, err
}
return decodeJSON[*setting.Snapshot](body)
}
// ReloadEffectivePolicy reloads the effective policy for the specified scope
// by reading and merging policy settings from all applicable policy sources.
func (lc *Client) ReloadEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
scopeID, err := scope.MarshalText()
if err != nil {
return nil, err
}
body, err := lc.send(ctx, "POST", "/localapi/v0/policy/"+string(scopeID), 200, http.NoBody)
if err != nil {
return nil, err
}
return decodeJSON[*setting.Snapshot](body)
}

204
vendor/tailscale.com/client/local/tailnetlock.go generated vendored Normal file
View File

@@ -0,0 +1,204 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_tailnetlock
package local
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/url"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tka"
"tailscale.com/types/key"
"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) {
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)
}
// 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) {
var b bytes.Buffer
type initRequest struct {
Keys []tka.Key
DisablementValues [][]byte
SupportDisablement []byte
}
if err := json.NewEncoder(&b).Encode(initRequest{Keys: keys, DisablementValues: disablementValues, SupportDisablement: supportDisablement}); err != nil {
return nil, err
}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/init", 200, &b)
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
return decodeJSON[*ipnstate.NetworkLockStatus](body)
}
// NetworkLockWrapPreauthKey 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) {
encodedPrivate, err := tkaKey.MarshalText()
if err != nil {
return "", err
}
var b bytes.Buffer
type wrapRequest struct {
TSKey string
TKAKey string // key.NLPrivate.MarshalText
}
if err := json.NewEncoder(&b).Encode(wrapRequest{TSKey: preauthKey, TKAKey: string(encodedPrivate)}); err != nil {
return "", err
}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/wrap-preauth-key", 200, &b)
if err != nil {
return "", fmt.Errorf("error: %w", err)
}
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 {
var b bytes.Buffer
type modifyRequest struct {
AddKeys []tka.Key
RemoveKeys []tka.Key
}
if err := json.NewEncoder(&b).Encode(modifyRequest{AddKeys: addKeys, RemoveKeys: removeKeys}); err != nil {
return err
}
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/modify", 204, &b); err != nil {
return fmt.Errorf("error: %w", err)
}
return nil
}
// NetworkLockSign signs the specified node-key and transmits that signature to the control plane.
// rotationPublic, if specified, must be an ed25519 public key.
func (lc *Client) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
var b bytes.Buffer
type signRequest struct {
NodeKey key.NodePublic
RotationPublic []byte
}
if err := json.NewEncoder(&b).Encode(signRequest{NodeKey: nodeKey, RotationPublic: rotationPublic}); err != nil {
return err
}
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/sign", 200, &b); err != nil {
return fmt.Errorf("error: %w", err)
}
return nil
}
// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
func (lc *Client) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/affected-sigs", 200, bytes.NewReader(keyID))
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
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) {
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)
}
// NetworkLockForceLocalDisable forcibly shuts down network lock on this node.
func (lc *Client) NetworkLockForceLocalDisable(ctx context.Context) error {
// This endpoint expects an empty JSON stanza as the payload.
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(struct{}{}); err != nil {
return err
}
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/force-local-disable", 200, &b); err != nil {
return fmt.Errorf("error: %w", err)
}
return nil
}
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
// in url and returns information extracted from it.
func (lc *Client) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
vr := struct {
URL string
}{url}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/verify-deeplink", 200, jsonBody(vr))
if err != nil {
return nil, fmt.Errorf("sending verify-deeplink: %w", err)
}
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) {
vr := struct {
Keys []tkatype.KeyID
ForkFrom string
}{removeKeys, forkFrom.String()}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/generate-recovery-aum", 200, jsonBody(vr))
if err != nil {
return nil, fmt.Errorf("sending generate-recovery-aum: %w", err)
}
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) {
r := bytes.NewReader(aum.Serialize())
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
if err != nil {
return nil, fmt.Errorf("sending cosign-recovery-aum: %w", err)
}
return body, nil
}
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
func (lc *Client) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
r := bytes.NewReader(aum.Serialize())
_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
if err != nil {
return fmt.Errorf("sending cosign-recovery-aum: %w", err)
}
return nil
}
// NetworkLockDisable shuts down network-lock across the tailnet.
func (lc *Client) NetworkLockDisable(ctx context.Context, secret []byte) error {
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
return fmt.Errorf("error: %w", err)
}
return nil
}

View File

@@ -94,3 +94,13 @@ type DNSQueryResponse struct {
// Resolvers is the list of resolvers that the forwarder deemed able to resolve the query.
Resolvers []*dnstype.Resolver
}
// OptionalFeatures describes which optional features are enabled in the build.
type OptionalFeatures struct {
// Features is the map of optional feature names to whether they are
// enabled.
//
// Disabled features may be absent from the map. (That is, false values
// are not guaranteed to be present.)
Features map[string]bool
}

View File

@@ -3,17 +3,50 @@
package apitype
// DNSConfig is the DNS configuration for a tailnet
// used in /tailnet/{tailnet}/dns/config.
type DNSConfig struct {
Resolvers []DNSResolver `json:"resolvers"`
FallbackResolvers []DNSResolver `json:"fallbackResolvers"`
Routes map[string][]DNSResolver `json:"routes"`
Domains []string `json:"domains"`
Nameservers []string `json:"nameservers"`
Proxied bool `json:"proxied"`
TempCorpIssue13969 string `json:"TempCorpIssue13969,omitempty"`
// Resolvers are the global DNS resolvers to use
// overriding the local OS configuration.
Resolvers []DNSResolver `json:"resolvers"`
// FallbackResolvers are used as global resolvers when
// the client is unable to determine the OS's preferred DNS servers.
FallbackResolvers []DNSResolver `json:"fallbackResolvers"`
// Routes map DNS name suffixes to a set of DNS resolvers,
// used for Split DNS and other advanced routing overlays.
Routes map[string][]DNSResolver `json:"routes"`
// Domains are the search domains to use.
Domains []string `json:"domains"`
// Proxied means MagicDNS is enabled.
Proxied bool `json:"proxied"`
// TempCorpIssue13969 is from an internal hack day prototype,
// See tailscale/corp#13969.
TempCorpIssue13969 string `json:"TempCorpIssue13969,omitempty"`
// Nameservers are the IP addresses of global nameservers to use.
// This is a deprecated format but may still be found in tailnets
// that were configured a long time ago. When making updates,
// set Resolvers and leave Nameservers empty.
Nameservers []string `json:"nameservers"`
}
// DNSResolver is a DNS resolver in a DNS configuration.
type DNSResolver struct {
Addr string `json:"addr"`
// Addr is the address of the DNS resolver.
// It is usually an IP address or a DoH URL.
// See dnstype.Resolver.Addr for full details.
Addr string `json:"addr"`
// BootstrapResolution is an optional suggested resolution for
// the DoT/DoH resolver.
BootstrapResolution []string `json:"bootstrapResolution,omitempty"`
// UseWithExitNode signals this resolver should be used
// even when a tailscale exit node is configured on a device.
UseWithExitNode bool `json:"useWithExitNode,omitempty"`
}

34
vendor/tailscale.com/client/tailscale/cert.go generated vendored Normal file
View File

@@ -0,0 +1,34 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !ts_omit_acme
package tailscale
import (
"context"
"crypto/tls"
"tailscale.com/client/local"
)
// GetCertificate is an alias for [tailscale.com/client/local.GetCertificate].
//
// Deprecated: import [tailscale.com/client/local] instead and use [local.Client.GetCertificate].
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return local.GetCertificate(hi)
}
// CertPair is an alias for [tailscale.com/client/local.CertPair].
//
// Deprecated: import [tailscale.com/client/local] instead and use [local.Client.CertPair].
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return local.CertPair(ctx, domain)
}
// ExpandSNIName is an alias for [tailscale.com/client/local.ExpandSNIName].
//
// Deprecated: import [tailscale.com/client/local] instead and use [local.Client.ExpandSNIName].
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
return local.ExpandSNIName(ctx, name)
}

View File

@@ -5,102 +5,75 @@ package tailscale
import (
"context"
"crypto/tls"
"tailscale.com/client/local"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn/ipnstate"
)
// ErrPeerNotFound is an alias for tailscale.com/client/local.
// ErrPeerNotFound is an alias for [tailscale.com/client/local.ErrPeerNotFound].
//
// Deprecated: import tailscale.com/client/local instead.
// Deprecated: import [tailscale.com/client/local] instead.
var ErrPeerNotFound = local.ErrPeerNotFound
// LocalClient is an alias for tailscale.com/client/local.
// LocalClient is an alias for [tailscale.com/client/local.Client].
//
// Deprecated: import tailscale.com/client/local instead.
// Deprecated: import [tailscale.com/client/local] instead.
type LocalClient = local.Client
// IPNBusWatcher is an alias for tailscale.com/client/local.
// IPNBusWatcher is an alias for [tailscale.com/client/local.IPNBusWatcher].
//
// Deprecated: import tailscale.com/client/local instead.
// Deprecated: import [tailscale.com/client/local] instead.
type IPNBusWatcher = local.IPNBusWatcher
// BugReportOpts is an alias for tailscale.com/client/local.
// BugReportOpts is an alias for [tailscale.com/client/local.BugReportOpts].
//
// Deprecated: import tailscale.com/client/local instead.
// Deprecated: import [tailscale.com/client/local] instead.
type BugReportOpts = local.BugReportOpts
// DebugPortMapOpts is an alias for tailscale.com/client/local.
// PingOpts is an alias for [tailscale.com/client/local.PingOpts].
//
// Deprecated: import tailscale.com/client/local instead.
type DebugPortmapOpts = local.DebugPortmapOpts
// PingOpts is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
// Deprecated: import [tailscale.com/client/local] instead.
type PingOpts = local.PingOpts
// GetCertificate is an alias for tailscale.com/client/local.
// SetVersionMismatchHandler is an alias for [tailscale.com/client/local.SetVersionMismatchHandler].
//
// Deprecated: import tailscale.com/client/local instead.
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return local.GetCertificate(hi)
}
// SetVersionMismatchHandler is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
// Deprecated: import [tailscale.com/client/local] instead.
func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
local.SetVersionMismatchHandler(f)
}
// IsAccessDeniedError is an alias for tailscale.com/client/local.
// IsAccessDeniedError is an alias for [tailscale.com/client/local.IsAccessDeniedError].
//
// Deprecated: import tailscale.com/client/local instead.
// Deprecated: import [tailscale.com/client/local] instead.
func IsAccessDeniedError(err error) bool {
return local.IsAccessDeniedError(err)
}
// IsPreconditionsFailedError is an alias for tailscale.com/client/local.
// IsPreconditionsFailedError is an alias for [tailscale.com/client/local.IsPreconditionsFailedError].
//
// Deprecated: import tailscale.com/client/local instead.
// Deprecated: import [tailscale.com/client/local] instead.
func IsPreconditionsFailedError(err error) bool {
return local.IsPreconditionsFailedError(err)
}
// WhoIs is an alias for tailscale.com/client/local.
// WhoIs is an alias for [tailscale.com/client/local.WhoIs].
//
// Deprecated: import tailscale.com/client/local instead.
// Deprecated: import [tailscale.com/client/local] instead and use [local.Client.WhoIs].
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
return local.WhoIs(ctx, remoteAddr)
}
// Status is an alias for tailscale.com/client/local.
// Status is an alias for [tailscale.com/client/local.Status].
//
// Deprecated: import tailscale.com/client/local instead.
// Deprecated: import [tailscale.com/client/local] instead.
func Status(ctx context.Context) (*ipnstate.Status, error) {
return local.Status(ctx)
}
// StatusWithoutPeers is an alias for tailscale.com/client/local.
// StatusWithoutPeers is an alias for [tailscale.com/client/local.StatusWithoutPeers].
//
// Deprecated: import tailscale.com/client/local instead.
// Deprecated: import [tailscale.com/client/local] instead.
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
return local.StatusWithoutPeers(ctx)
}
// CertPair is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return local.CertPair(ctx, domain)
}
// ExpandSNIName is an alias for tailscale.com/client/local.
//
// Deprecated: import tailscale.com/client/local instead.
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
return local.ExpandSNIName(ctx, name)
}

View File

@@ -8,7 +8,7 @@
// This package is only intended for internal and transitional use.
//
// Deprecated: the official control plane client is available at
// tailscale.com/client/tailscale/v2.
// [tailscale.com/client/tailscale/v2].
package tailscale
import (
@@ -22,7 +22,7 @@ import (
)
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
// for now. This package is being replaced by tailscale.com/client/tailscale/v2.
// for now. This package is being replaced by [tailscale.com/client/tailscale/v2].
var I_Acknowledge_This_API_Is_Unstable = false
// TODO: use url.PathEscape() for deviceID and tailnets when constructing requests.
@@ -34,10 +34,10 @@ const maxReadSize = 10 << 20
// Client makes API calls to the Tailscale control plane API server.
//
// Use NewClient to instantiate one. Exported fields should be set before
// Use [NewClient] to instantiate one. Exported fields should be set before
// the client is used and not changed thereafter.
//
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
// Deprecated: use [tailscale.com/client/tailscale/v2] instead.
type Client struct {
// tailnet is the globally unique identifier for a Tailscale network, such
// as "example.com" or "user@gmail.com".
@@ -51,7 +51,7 @@ type Client struct {
BaseURL string
// HTTPClient optionally specifies an alternate HTTP client to use.
// If nil, http.DefaultClient is used.
// If nil, [http.DefaultClient] is used.
HTTPClient *http.Client
// UserAgent optionally specifies an alternate User-Agent header
@@ -119,7 +119,7 @@ type AuthMethod interface {
modifyRequest(req *http.Request)
}
// APIKey is an AuthMethod for NewClient that authenticates requests
// APIKey is an [AuthMethod] for [NewClient] that authenticates requests
// using an authkey.
type APIKey string
@@ -133,15 +133,15 @@ func (c *Client) setAuth(r *http.Request) {
}
}
// NewClient is a convenience method for instantiating a new Client.
// NewClient is a convenience method for instantiating a new [Client].
//
// tailnet is the globally unique identifier for a Tailscale network, such
// as "example.com" or "user@gmail.com".
// If httpClient is nil, then http.DefaultClient is used.
// If httpClient is nil, then [http.DefaultClient] is used.
// "api.tailscale.com" is set as the BaseURL for the returned client
// and can be changed manually by the user.
//
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
// Deprecated: use [tailscale.com/client/tailscale/v2] instead.
func NewClient(tailnet string, auth AuthMethod) *Client {
return &Client{
tailnet: tailnet,
@@ -193,9 +193,9 @@ func (e ErrResponse) Error() string {
}
// HandleErrorResponse decodes the error message from the server and returns
// an ErrResponse from it.
// an [ErrResponse] from it.
//
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
// Deprecated: use [tailscale.com/client/tailscale/v2] instead.
func HandleErrorResponse(b []byte, resp *http.Response) error {
var errResp ErrResponse
if err := json.Unmarshal(b, &errResp); err != nil {

View File

@@ -192,7 +192,7 @@ func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
if err != nil {
return true
}
controlURL, err := url.Parse(prefs.ControlURLOrDefault())
controlURL, err := url.Parse(prefs.ControlURLOrDefault(s.polc))
if err != nil {
return true
}

View File

@@ -3,7 +3,7 @@
"version": "0.0.1",
"license": "BSD-3-Clause",
"engines": {
"node": "18.20.4",
"node": "22.14.0",
"yarn": "1.22.19"
},
"type": "module",
@@ -20,7 +20,7 @@
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/node": "^18.16.1",
"@types/node": "^22.14.0",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react-swc": "^3.6.0",
@@ -34,10 +34,10 @@
"prettier-plugin-organize-imports": "^3.2.2",
"tailwindcss": "^3.3.3",
"typescript": "^5.3.3",
"vite": "^5.1.7",
"vite": "^5.4.21",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^3.5.0",
"vitest": "^1.3.1"
"vitest": "^1.6.1"
},
"resolutions": {
"@typescript-eslint/eslint-plugin": "^6.2.1",

View File

@@ -5,8 +5,8 @@
package web
import (
"cmp"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
@@ -14,19 +14,20 @@ import (
"log"
"net/http"
"net/netip"
"net/url"
"os"
"path"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/gorilla/csrf"
"tailscale.com/client/local"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/clientupdate"
"tailscale.com/envknob"
"tailscale.com/envknob/featureknob"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
@@ -37,6 +38,7 @@ import (
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/util/httpm"
"tailscale.com/util/syspolicy/policyclient"
"tailscale.com/version"
"tailscale.com/version/distro"
)
@@ -50,6 +52,7 @@ type Server struct {
mode ServerMode
logf logger.Logf
polc policyclient.Client // must be non-nil
lc *local.Client
timeNow func() time.Time
@@ -60,6 +63,12 @@ type Server struct {
cgiMode bool
pathPrefix string
// originOverride is the origin that the web UI is accessible from.
// This value is used in the fallback CSRF checks when Sec-Fetch-Site is not
// available. In this case the application will compare Host and Origin
// header values to determine if the request is from the same origin.
originOverride string
apiHandler http.Handler // serves api endpoints; csrf-protected
assetsHandler http.Handler // serves frontend assets
assetsCleanup func() // called from Server.Shutdown
@@ -134,9 +143,13 @@ type ServerOpts struct {
TimeNow func() time.Time
// Logf optionally provides a logger function.
// log.Printf is used as default.
// If nil, log.Printf is used as default.
Logf logger.Logf
// PolicyClient, if non-nil, will be used to fetch policy settings.
// If nil, the default policy client will be used.
PolicyClient policyclient.Client
// The following two fields are required and used exclusively
// in ManageServerMode to facilitate the control server login
// check step for authorizing browser sessions.
@@ -150,6 +163,9 @@ type ServerOpts struct {
// as completed.
// This field is required for ManageServerMode mode.
WaitAuthURL func(ctx context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error)
// OriginOverride specifies the origin that the web UI will be accessible from if hosted behind a reverse proxy or CGI.
OriginOverride string
}
// NewServer constructs a new Tailscale web client server.
@@ -169,15 +185,17 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
opts.LocalClient = &local.Client{}
}
s = &Server{
mode: opts.Mode,
logf: opts.Logf,
devMode: envknob.Bool("TS_DEBUG_WEB_CLIENT_DEV"),
lc: opts.LocalClient,
cgiMode: opts.CGIMode,
pathPrefix: opts.PathPrefix,
timeNow: opts.TimeNow,
newAuthURL: opts.NewAuthURL,
waitAuthURL: opts.WaitAuthURL,
mode: opts.Mode,
polc: cmp.Or(opts.PolicyClient, policyclient.Get()),
logf: opts.Logf,
devMode: envknob.Bool("TS_DEBUG_WEB_CLIENT_DEV"),
lc: opts.LocalClient,
cgiMode: opts.CGIMode,
pathPrefix: opts.PathPrefix,
timeNow: opts.TimeNow,
newAuthURL: opts.NewAuthURL,
waitAuthURL: opts.WaitAuthURL,
originOverride: opts.OriginOverride,
}
if opts.PathPrefix != "" {
// Enforce that path prefix always has a single leading '/'
@@ -205,7 +223,7 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
var metric string
s.apiHandler, metric = s.modeAPIHandler(s.mode)
s.apiHandler = s.withCSRF(s.apiHandler)
s.apiHandler = s.csrfProtect(s.apiHandler)
// Don't block startup on reporting metric.
// Report in separate go routine with 5 second timeout.
@@ -218,23 +236,64 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
return s, nil
}
func (s *Server) withCSRF(h http.Handler) http.Handler {
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
// ref https://github.com/tailscale/tailscale/pull/14822
// signal to the CSRF middleware that the request is being served over
// plaintext HTTP to skip TLS-only header checks.
withSetPlaintext := func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = csrf.PlaintextHTTPRequest(r)
func (s *Server) csrfProtect(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CSRF is not required for GET, HEAD, or OPTIONS requests.
if slices.Contains([]string{"GET", "HEAD", "OPTIONS"}, r.Method) {
h.ServeHTTP(w, r)
})
}
return
}
// NB: the order of the withSetPlaintext and csrfProtect calls is important
// to ensure that we signal to the CSRF middleware that the request is being
// served over plaintext HTTP and not over TLS as it presumes by default.
return withSetPlaintext(csrfProtect(h))
// first attempt to use Sec-Fetch-Site header (sent by all modern
// browsers to "potentially trustworthy" origins i.e. localhost or those
// served over HTTPS)
secFetchSite := r.Header.Get("Sec-Fetch-Site")
if secFetchSite == "same-origin" {
h.ServeHTTP(w, r)
return
} else if secFetchSite != "" {
http.Error(w, fmt.Sprintf("CSRF request denied with Sec-Fetch-Site %q", secFetchSite), http.StatusForbidden)
return
}
// if Sec-Fetch-Site is not available we presume we are operating over HTTP.
// We fall back to comparing the Origin & Host headers.
// use the Host header to determine the expected origin
// (use the override if set to allow for reverse proxying)
host := r.Host
if host == "" {
http.Error(w, "CSRF request denied with no Host header", http.StatusForbidden)
return
}
if s.originOverride != "" {
host = s.originOverride
}
originHeader := r.Header.Get("Origin")
if originHeader == "" {
http.Error(w, "CSRF request denied with no Origin header", http.StatusForbidden)
return
}
parsedOrigin, err := url.Parse(originHeader)
if err != nil {
http.Error(w, fmt.Sprintf("CSRF request denied with invalid Origin %q", r.Header.Get("Origin")), http.StatusForbidden)
return
}
origin := parsedOrigin.Host
if origin == "" {
http.Error(w, "CSRF request denied with no host in the Origin header", http.StatusForbidden)
return
}
if origin != host {
http.Error(w, fmt.Sprintf("CSRF request denied with mismatched Origin %q and Host %q", origin, host), http.StatusForbidden)
return
}
h.ServeHTTP(w, r)
})
}
func (s *Server) modeAPIHandler(mode ServerMode) (http.Handler, string) {
@@ -438,6 +497,10 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
// Client using system-specific auth.
switch distro.Get() {
case distro.Synology:
if !buildfeatures.HasSynology {
// Synology support not built in.
return false
}
authorized, _ := authorizeSynology(r)
return authorized
case distro.QNAP:
@@ -452,7 +515,6 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
// which protects the handler using gorilla csrf.
func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", csrf.Token(r))
switch {
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
s.serveGetNodeData(w, r)
@@ -575,7 +637,6 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
}
}
w.Header().Set("X-CSRF-Token", csrf.Token(r))
path := strings.TrimPrefix(r.URL.Path, "/api")
switch {
case path == "/data" && r.Method == httpm.GET:
@@ -902,7 +963,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
RunningSSHServer: prefs.RunSSH,
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
ControlAdminURL: prefs.AdminPageURL(),
ControlAdminURL: prefs.AdminPageURL(s.polc),
LicensesURL: licenses.LicensesURL(),
Features: availableFeatures(),
@@ -922,9 +983,18 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
data.ClientVersion = cv
}
if st.CurrentTailnet != nil {
data.TailnetName = st.CurrentTailnet.MagicDNSSuffix
data.DomainName = st.CurrentTailnet.Name
profile, _, err := s.lc.ProfileStatus(r.Context())
if err != nil {
s.logf("error fetching profiles: %v", err)
// If for some reason we can't fetch profiles,
// continue to use st.CurrentTailnet if set.
if st.CurrentTailnet != nil {
data.TailnetName = st.CurrentTailnet.MagicDNSSuffix
data.DomainName = st.CurrentTailnet.Name
}
} else {
data.TailnetName = profile.NetworkProfile.MagicDNSName
data.DomainName = profile.NetworkProfile.DisplayNameOrDefault()
}
if st.Self.Tags != nil {
data.Tags = st.Self.Tags.AsSlice()
@@ -984,7 +1054,7 @@ func availableFeatures() map[string]bool {
"advertise-routes": true, // available on all platforms
"use-exit-node": featureknob.CanUseExitNode() == nil,
"ssh": featureknob.CanRunTailscaleSSH() == nil,
"auto-update": version.IsUnstableBuild() && clientupdate.CanAutoUpdate(),
"auto-update": version.IsUnstableBuild() && feature.CanAutoUpdate(),
}
return features
}
@@ -1276,37 +1346,6 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
}
}
// csrfKey returns a key that can be used for CSRF protection.
// If an error occurs during key creation, the error is logged and the active process terminated.
// If the server is running in CGI mode, the key is cached to disk and reused between requests.
// If an error occurs during key storage, the error is logged and the active process terminated.
func (s *Server) csrfKey() []byte {
csrfFile := filepath.Join(os.TempDir(), "tailscale-web-csrf.key")
// if running in CGI mode, try to read from disk, but ignore errors
if s.cgiMode {
key, _ := os.ReadFile(csrfFile)
if len(key) == 32 {
return key
}
}
// create a new key
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
log.Fatalf("error generating CSRF key: %v", err)
}
// if running in CGI mode, try to write the newly created key to disk, and exit if it fails.
if s.cgiMode {
if err := os.WriteFile(csrfFile, key, 0600); err != nil {
log.Fatalf("unable to store CSRF key: %v", err)
}
}
return key
}
// enforcePrefix returns a HandlerFunc that enforces a given path prefix is used in requests,
// then strips it before invoking h.
// Unlike http.StripPrefix, it does not return a 404 if the prefix is not present.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build (linux && !android) || windows
package clientupdate
import (
"context"
"tailscale.com/clientupdate/distsign"
)
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
c, err := distsign.NewClient(up.Logf, up.PkgsAddr)
if err != nil {
return err
}
return c.Download(context.Background(), pathSrc, fileDst)
}

View File

@@ -1,10 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !((linux && !android) || windows)
package clientupdate
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
panic("unreachable")
}

View File

@@ -1,225 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Windows-specific stuff that can't go in clientupdate.go because it needs
// x/sys/windows.
package clientupdate
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
"github.com/google/uuid"
"golang.org/x/sys/windows"
"tailscale.com/util/winutil"
"tailscale.com/util/winutil/authenticode"
)
const (
// winMSIEnv is the environment variable that, if set, is the MSI file for
// the update command to install. It's passed like this so we can stop the
// tailscale.exe process from running before the msiexec process runs and
// tries to overwrite ourselves.
winMSIEnv = "TS_UPDATE_WIN_MSI"
// winExePathEnv is the environment variable that is set along with
// winMSIEnv and carries the full path of the calling tailscale.exe binary.
// It is used to re-launch the GUI process (tailscale-ipn.exe) after
// install is complete.
winExePathEnv = "TS_UPDATE_WIN_EXE_PATH"
)
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
selfExe, err := os.Executable()
if err != nil {
return "", "", err
}
f, err := os.Open(selfExe)
if err != nil {
return "", "", err
}
defer f.Close()
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
if err != nil {
return "", "", err
}
if err := markTempFileWindows(f2.Name()); err != nil {
return "", "", err
}
if _, err := io.Copy(f2, f); err != nil {
f2.Close()
return "", "", err
}
return selfExe, f2.Name(), f2.Close()
}
func markTempFileWindows(name string) error {
name16 := windows.StringToUTF16Ptr(name)
return windows.MoveFileEx(name16, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT)
}
const certSubjectTailscale = "Tailscale Inc."
func verifyAuthenticode(path string) error {
return authenticode.Verify(path, certSubjectTailscale)
}
func (up *Updater) updateWindows() error {
if msi := os.Getenv(winMSIEnv); msi != "" {
// stdout/stderr from this part of the install could be lost since the
// parent tailscaled is replaced. Create a temp log file to have some
// output to debug with in case update fails.
close, err := up.switchOutputToFile()
if err != nil {
up.Logf("failed to create log file for installation: %v; proceeding with existing outputs", err)
} else {
defer close.Close()
}
up.Logf("installing %v ...", msi)
if err := up.installMSI(msi); err != nil {
up.Logf("MSI install failed: %v", err)
return err
}
up.Logf("success.")
return nil
}
if !winutil.IsCurrentProcessElevated() {
return errors.New(`update must be run as Administrator
you can run the command prompt as Administrator one of these ways:
* right-click cmd.exe, select 'Run as administrator'
* press Windows+x, then press a
* press Windows+r, type in "cmd", then press Ctrl+Shift+Enter`)
}
ver, err := requestedTailscaleVersion(up.Version, up.Track)
if err != nil {
return err
}
arch := runtime.GOARCH
if arch == "386" {
arch = "x86"
}
if !up.confirm(ver) {
return nil
}
tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
msiDir := filepath.Join(tsDir, "MSICache")
if fi, err := os.Stat(tsDir); err != nil {
return fmt.Errorf("expected %s to exist, got stat error: %w", tsDir, err)
} else if !fi.IsDir() {
return fmt.Errorf("expected %s to be a directory; got %v", tsDir, fi.Mode())
}
if err := os.MkdirAll(msiDir, 0700); err != nil {
return err
}
up.cleanupOldDownloads(filepath.Join(msiDir, "*.msi"))
pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s-%s.msi", up.Track, ver, arch)
msiTarget := filepath.Join(msiDir, path.Base(pkgsPath))
if err := up.downloadURLToFile(pkgsPath, msiTarget); err != nil {
return err
}
up.Logf("verifying MSI authenticode...")
if err := verifyAuthenticode(msiTarget); err != nil {
return fmt.Errorf("authenticode verification of %s failed: %w", msiTarget, err)
}
up.Logf("authenticode verification succeeded")
up.Logf("making tailscale.exe copy to switch to...")
up.cleanupOldDownloads(filepath.Join(os.TempDir(), "tailscale-updater-*.exe"))
selfOrig, selfCopy, err := makeSelfCopy()
if err != nil {
return err
}
defer os.Remove(selfCopy)
up.Logf("running tailscale.exe copy for final install...")
cmd := exec.Command(selfCopy, "update")
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winExePathEnv+"="+selfOrig)
cmd.Stdout = up.Stderr
cmd.Stderr = up.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
return err
}
// Once it's started, exit ourselves, so the binary is free
// to be replaced.
os.Exit(0)
panic("unreachable")
}
func (up *Updater) installMSI(msi string) error {
var err error
for tries := 0; tries < 2; tries++ {
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/norestart", "/qn")
cmd.Dir = filepath.Dir(msi)
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
if err == nil {
break
}
up.Logf("Install attempt failed: %v", err)
uninstallVersion := up.currentVersion
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
uninstallVersion = v
}
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
up.Logf("Uninstalling current version %q for downgrade...", uninstallVersion)
cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
cmd.Stdout = up.Stdout
cmd.Stderr = up.Stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
up.Logf("msiexec uninstall: %v", err)
}
return err
}
func msiUUIDForVersion(ver string) string {
arch := runtime.GOARCH
if arch == "386" {
arch = "x86"
}
track, err := versionToTrack(ver)
if err != nil {
track = UnstableTrack
}
msiURL := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", track, ver, arch)
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
}
func (up *Updater) switchOutputToFile() (io.Closer, error) {
var logFilePath string
exePath, err := os.Executable()
if err != nil {
logFilePath = filepath.Join(os.TempDir(), "tailscale-updater.log")
} else {
logFilePath = strings.TrimSuffix(exePath, ".exe") + ".log"
}
up.Logf("writing update output to %q", logFilePath)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, err
}
up.Logf = func(m string, args ...any) {
fmt.Fprintf(logFile, m+"\n", args...)
}
up.Stdout = logFile
up.Stderr = logFile
return logFile, nil
}

View File

@@ -1,486 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package distsign implements signature and validation of arbitrary
// distributable files.
//
// There are 3 parties in this exchange:
// - builder, which creates files, signs them with signing keys and publishes
// to server
// - server, which distributes public signing keys, files and signatures
// - client, which downloads files and signatures from server, and validates
// the signatures
//
// There are 2 types of keys:
// - signing keys, that sign individual distributable files on the builder
// - root keys, that sign signing keys and are kept offline
//
// root keys -(sign)-> signing keys -(sign)-> files
//
// All keys are asymmetric Ed25519 key pairs.
//
// The server serves static files under some known prefix. The kinds of files are:
// - distsign.pub - bundle of PEM-encoded public signing keys
// - distsign.pub.sig - signature of distsign.pub using one of the root keys
// - $file - any distributable file
// - $file.sig - signature of $file using any of the signing keys
//
// The root public keys are baked into the client software at compile time.
// These keys are long-lived and prove the validity of current signing keys
// from distsign.pub. To rotate root keys, a new client release must be
// published, they are not rotated dynamically. There are multiple root keys in
// different locations specifically to allow this rotation without using the
// discarded root key for any new signatures.
//
// The signing public keys are fetched by the client dynamically before every
// download and can be rotated more readily, assuming that most deployed
// clients trust the root keys used to issue fresh signing keys.
package distsign
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/binary"
"encoding/pem"
"errors"
"fmt"
"hash"
"io"
"log"
"net/http"
"net/url"
"os"
"time"
"github.com/hdevalence/ed25519consensus"
"golang.org/x/crypto/blake2s"
"tailscale.com/net/tshttpproxy"
"tailscale.com/types/logger"
"tailscale.com/util/httpm"
"tailscale.com/util/must"
)
const (
pemTypeRootPrivate = "ROOT PRIVATE KEY"
pemTypeRootPublic = "ROOT PUBLIC KEY"
pemTypeSigningPrivate = "SIGNING PRIVATE KEY"
pemTypeSigningPublic = "SIGNING PUBLIC KEY"
downloadSizeLimit = 1 << 29 // 512MB
signingKeysSizeLimit = 1 << 20 // 1MB
signatureSizeLimit = ed25519.SignatureSize
)
// RootKey is a root key used to sign signing keys.
type RootKey struct {
k ed25519.PrivateKey
}
// GenerateRootKey generates a new root key pair and encodes it as PEM.
func GenerateRootKey() (priv, pub []byte, err error) {
pub, priv, err = ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
return pem.EncodeToMemory(&pem.Block{
Type: pemTypeRootPrivate,
Bytes: []byte(priv),
}), pem.EncodeToMemory(&pem.Block{
Type: pemTypeRootPublic,
Bytes: []byte(pub),
}), nil
}
// ParseRootKey parses the PEM-encoded private root key. The key must be in the
// same format as returned by GenerateRootKey.
func ParseRootKey(privKey []byte) (*RootKey, error) {
k, err := parsePrivateKey(privKey, pemTypeRootPrivate)
if err != nil {
return nil, fmt.Errorf("failed to parse root key: %w", err)
}
return &RootKey{k: k}, nil
}
// SignSigningKeys signs the bundle of public signing keys. The bundle must be
// a sequence of PEM blocks joined with newlines.
func (r *RootKey) SignSigningKeys(pubBundle []byte) ([]byte, error) {
if _, err := ParseSigningKeyBundle(pubBundle); err != nil {
return nil, err
}
return ed25519.Sign(r.k, pubBundle), nil
}
// SigningKey is a signing key used to sign packages.
type SigningKey struct {
k ed25519.PrivateKey
}
// GenerateSigningKey generates a new signing key pair and encodes it as PEM.
func GenerateSigningKey() (priv, pub []byte, err error) {
pub, priv, err = ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
return pem.EncodeToMemory(&pem.Block{
Type: pemTypeSigningPrivate,
Bytes: []byte(priv),
}), pem.EncodeToMemory(&pem.Block{
Type: pemTypeSigningPublic,
Bytes: []byte(pub),
}), nil
}
// ParseSigningKey parses the PEM-encoded private signing key. The key must be
// in the same format as returned by GenerateSigningKey.
func ParseSigningKey(privKey []byte) (*SigningKey, error) {
k, err := parsePrivateKey(privKey, pemTypeSigningPrivate)
if err != nil {
return nil, fmt.Errorf("failed to parse root key: %w", err)
}
return &SigningKey{k: k}, nil
}
// SignPackageHash signs the hash and the length of a package. Use PackageHash
// to compute the inputs.
func (s *SigningKey) SignPackageHash(hash []byte, len int64) ([]byte, error) {
if len <= 0 {
return nil, fmt.Errorf("package length must be positive, got %d", len)
}
msg := binary.LittleEndian.AppendUint64(hash, uint64(len))
return ed25519.Sign(s.k, msg), nil
}
// PackageHash is a hash.Hash that counts the number of bytes written. Use it
// to get the hash and length inputs to SigningKey.SignPackageHash.
type PackageHash struct {
hash.Hash
len int64
}
// NewPackageHash returns an initialized PackageHash using BLAKE2s.
func NewPackageHash() *PackageHash {
h, err := blake2s.New256(nil)
if err != nil {
// Should never happen with a nil key passed to blake2s.
panic(err)
}
return &PackageHash{Hash: h}
}
func (ph *PackageHash) Write(b []byte) (int, error) {
ph.len += int64(len(b))
return ph.Hash.Write(b)
}
// Reset the PackageHash to its initial state.
func (ph *PackageHash) Reset() {
ph.len = 0
ph.Hash.Reset()
}
// Len returns the total number of bytes written.
func (ph *PackageHash) Len() int64 { return ph.len }
// Client downloads and validates files from a distribution server.
type Client struct {
logf logger.Logf
roots []ed25519.PublicKey
pkgsAddr *url.URL
}
// NewClient returns a new client for distribution server located at pkgsAddr,
// and uses embedded root keys from the roots/ subdirectory of this package.
func NewClient(logf logger.Logf, pkgsAddr string) (*Client, error) {
if logf == nil {
logf = log.Printf
}
u, err := url.Parse(pkgsAddr)
if err != nil {
return nil, fmt.Errorf("invalid pkgsAddr %q: %w", pkgsAddr, err)
}
return &Client{logf: logf, roots: roots(), pkgsAddr: u}, nil
}
func (c *Client) url(path string) string {
return c.pkgsAddr.JoinPath(path).String()
}
// Download fetches a file at path srcPath from pkgsAddr passed in NewClient.
// The file is downloaded to dstPath and its signature is validated using the
// embedded root keys. Download returns an error if anything goes wrong with
// the actual file download or with signature validation.
func (c *Client) Download(ctx context.Context, srcPath, dstPath string) error {
// Always fetch a fresh signing key.
sigPub, err := c.signingKeys()
if err != nil {
return err
}
srcURL := c.url(srcPath)
sigURL := srcURL + ".sig"
c.logf("Downloading %q", srcURL)
dstPathUnverified := dstPath + ".unverified"
hash, len, err := c.download(ctx, srcURL, dstPathUnverified, downloadSizeLimit)
if err != nil {
return err
}
c.logf("Downloading %q", sigURL)
sig, err := fetch(sigURL, signatureSizeLimit)
if err != nil {
// Best-effort clean up of downloaded package.
os.Remove(dstPathUnverified)
return err
}
msg := binary.LittleEndian.AppendUint64(hash, uint64(len))
if !VerifyAny(sigPub, msg, sig) {
// Best-effort clean up of downloaded package.
os.Remove(dstPathUnverified)
return fmt.Errorf("signature %q for file %q does not validate with the current release signing key; either you are under attack, or attempting to download an old version of Tailscale which was signed with an older signing key", sigURL, srcURL)
}
c.logf("Signature OK")
if err := os.Rename(dstPathUnverified, dstPath); err != nil {
return fmt.Errorf("failed to move %q to %q after signature validation", dstPathUnverified, dstPath)
}
return nil
}
// ValidateLocalBinary fetches the latest signature associated with the binary
// at srcURLPath and uses it to validate the file located on disk via
// localFilePath. ValidateLocalBinary returns an error if anything goes wrong
// with the signature download or with signature validation.
func (c *Client) ValidateLocalBinary(srcURLPath, localFilePath string) error {
// Always fetch a fresh signing key.
sigPub, err := c.signingKeys()
if err != nil {
return err
}
srcURL := c.url(srcURLPath)
sigURL := srcURL + ".sig"
localFile, err := os.Open(localFilePath)
if err != nil {
return err
}
defer localFile.Close()
h := NewPackageHash()
_, err = io.Copy(h, localFile)
if err != nil {
return err
}
hash, hashLen := h.Sum(nil), h.Len()
c.logf("Downloading %q", sigURL)
sig, err := fetch(sigURL, signatureSizeLimit)
if err != nil {
return err
}
msg := binary.LittleEndian.AppendUint64(hash, uint64(hashLen))
if !VerifyAny(sigPub, msg, sig) {
return fmt.Errorf("signature %q for file %q does not validate with the current release signing key; either you are under attack, or attempting to download an old version of Tailscale which was signed with an older signing key", sigURL, localFilePath)
}
c.logf("Signature OK")
return nil
}
// signingKeys fetches current signing keys from the server and validates them
// against the roots. Should be called before validation of any downloaded file
// to get the fresh keys.
func (c *Client) signingKeys() ([]ed25519.PublicKey, error) {
keyURL := c.url("distsign.pub")
sigURL := keyURL + ".sig"
raw, err := fetch(keyURL, signingKeysSizeLimit)
if err != nil {
return nil, err
}
sig, err := fetch(sigURL, signatureSizeLimit)
if err != nil {
return nil, err
}
if !VerifyAny(c.roots, raw, sig) {
return nil, fmt.Errorf("signature %q for key %q does not validate with any known root key; either you are under attack, or running a very old version of Tailscale with outdated root keys", sigURL, keyURL)
}
keys, err := ParseSigningKeyBundle(raw)
if err != nil {
return nil, fmt.Errorf("cannot parse signing key bundle from %q: %w", keyURL, err)
}
return keys, nil
}
// fetch reads the response body from url into memory, up to limit bytes.
func fetch(url string, limit int64) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(io.LimitReader(resp.Body, limit))
}
// download writes the response body of url into a local file at dst, up to
// limit bytes. On success, the returned value is a BLAKE2s hash of the file.
func (c *Client) download(ctx context.Context, url, dst string, limit int64) ([]byte, int64, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = tshttpproxy.ProxyFromEnvironment
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
quickCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
headReq := must.Get(http.NewRequestWithContext(quickCtx, httpm.HEAD, url, nil))
res, err := hc.Do(headReq)
if err != nil {
return nil, 0, err
}
if res.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("HEAD %q: %v", url, res.Status)
}
if res.ContentLength <= 0 {
return nil, 0, fmt.Errorf("HEAD %q: unexpected Content-Length %v", url, res.ContentLength)
}
c.logf("Download size: %v", res.ContentLength)
dlReq := must.Get(http.NewRequestWithContext(ctx, httpm.GET, url, nil))
dlRes, err := hc.Do(dlReq)
if err != nil {
return nil, 0, err
}
defer dlRes.Body.Close()
// TODO(bradfitz): resume from existing partial file on disk
if dlRes.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("GET %q: %v", url, dlRes.Status)
}
of, err := os.Create(dst)
if err != nil {
return nil, 0, err
}
defer of.Close()
pw := &progressWriter{total: res.ContentLength, logf: c.logf}
h := NewPackageHash()
n, err := io.Copy(io.MultiWriter(of, h, pw), io.LimitReader(dlRes.Body, limit))
if err != nil {
return nil, n, err
}
if n != res.ContentLength {
return nil, n, fmt.Errorf("GET %q: downloaded %v, want %v", url, n, res.ContentLength)
}
if err := dlRes.Body.Close(); err != nil {
return nil, n, err
}
if err := of.Close(); err != nil {
return nil, n, err
}
pw.print()
return h.Sum(nil), h.Len(), nil
}
type progressWriter struct {
done int64
total int64
lastPrint time.Time
logf logger.Logf
}
func (pw *progressWriter) Write(p []byte) (n int, err error) {
pw.done += int64(len(p))
if time.Since(pw.lastPrint) > 2*time.Second {
pw.print()
}
return len(p), nil
}
func (pw *progressWriter) print() {
pw.lastPrint = time.Now()
pw.logf("Downloaded %v/%v (%.1f%%)", pw.done, pw.total, float64(pw.done)/float64(pw.total)*100)
}
func parsePrivateKey(data []byte, typeTag string) (ed25519.PrivateKey, error) {
b, rest := pem.Decode(data)
if b == nil {
return nil, errors.New("failed to decode PEM data")
}
if len(rest) > 0 {
return nil, errors.New("trailing PEM data")
}
if b.Type != typeTag {
return nil, fmt.Errorf("PEM type is %q, want %q", b.Type, typeTag)
}
if len(b.Bytes) != ed25519.PrivateKeySize {
return nil, errors.New("private key has incorrect length for an Ed25519 private key")
}
return ed25519.PrivateKey(b.Bytes), nil
}
// ParseSigningKeyBundle parses the bundle of PEM-encoded public signing keys.
func ParseSigningKeyBundle(bundle []byte) ([]ed25519.PublicKey, error) {
return parsePublicKeyBundle(bundle, pemTypeSigningPublic)
}
// ParseRootKeyBundle parses the bundle of PEM-encoded public root keys.
func ParseRootKeyBundle(bundle []byte) ([]ed25519.PublicKey, error) {
return parsePublicKeyBundle(bundle, pemTypeRootPublic)
}
func parsePublicKeyBundle(bundle []byte, typeTag string) ([]ed25519.PublicKey, error) {
var keys []ed25519.PublicKey
for len(bundle) > 0 {
pub, rest, err := parsePublicKey(bundle, typeTag)
if err != nil {
return nil, err
}
keys = append(keys, pub)
bundle = rest
}
if len(keys) == 0 {
return nil, errors.New("no signing keys found in the bundle")
}
return keys, nil
}
func parseSinglePublicKey(data []byte, typeTag string) (ed25519.PublicKey, error) {
pub, rest, err := parsePublicKey(data, typeTag)
if err != nil {
return nil, err
}
if len(rest) > 0 {
return nil, errors.New("trailing PEM data")
}
return pub, err
}
func parsePublicKey(data []byte, typeTag string) (pub ed25519.PublicKey, rest []byte, retErr error) {
b, rest := pem.Decode(data)
if b == nil {
return nil, nil, errors.New("failed to decode PEM data")
}
if b.Type != typeTag {
return nil, nil, fmt.Errorf("PEM type is %q, want %q", b.Type, typeTag)
}
if len(b.Bytes) != ed25519.PublicKeySize {
return nil, nil, errors.New("public key has incorrect length for an Ed25519 public key")
}
return ed25519.PublicKey(b.Bytes), rest, nil
}
// VerifyAny verifies whether sig is valid for msg using any of the keys.
// VerifyAny will panic if any of the keys have the wrong size for Ed25519.
func VerifyAny(keys []ed25519.PublicKey, msg, sig []byte) bool {
for _, k := range keys {
if ed25519consensus.Verify(k, msg, sig) {
return true
}
}
return false
}

View File

@@ -1,54 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package distsign
import (
"crypto/ed25519"
"embed"
"errors"
"fmt"
"path"
"path/filepath"
"sync"
)
//go:embed roots
var rootsFS embed.FS
var roots = sync.OnceValue(func() []ed25519.PublicKey {
roots, err := parseRoots()
if err != nil {
panic(err)
}
return roots
})
func parseRoots() ([]ed25519.PublicKey, error) {
files, err := rootsFS.ReadDir("roots")
if err != nil {
return nil, err
}
var keys []ed25519.PublicKey
for _, f := range files {
if !f.Type().IsRegular() {
continue
}
if filepath.Ext(f.Name()) != ".pem" {
continue
}
raw, err := rootsFS.ReadFile(path.Join("roots", f.Name()))
if err != nil {
return nil, err
}
key, err := parseSinglePublicKey(raw, pemTypeRootPublic)
if err != nil {
return nil, fmt.Errorf("parsing root key %q: %w", f.Name(), err)
}
keys = append(keys, key)
}
if len(keys) == 0 {
return nil, errors.New("no embedded root keys, please check clientupdate/distsign/roots/")
}
return keys, nil
}

View File

@@ -1,3 +0,0 @@
-----BEGIN ROOT PUBLIC KEY-----
Psrabv2YNiEDhPlnLVSMtB5EKACm7zxvKxfvYD4i7X8=
-----END ROOT PUBLIC KEY-----

View File

@@ -1,3 +0,0 @@
-----BEGIN ROOT PUBLIC KEY-----
ZjjKhUHBtLNRSO1dhOTjrXJGJ8lDe1594WM2XDuheVQ=
-----END ROOT PUBLIC KEY-----

View File

@@ -18,6 +18,7 @@ import (
"golang.org/x/crypto/blake2s"
chp "golang.org/x/crypto/chacha20poly1305"
"tailscale.com/syncs"
"tailscale.com/types/key"
)
@@ -48,7 +49,7 @@ type Conn struct {
// rxState is all the Conn state that Read uses.
type rxState struct {
sync.Mutex
syncs.Mutex
cipher cipher.AEAD
nonce nonce
buf *maxMsgBuffer // or nil when reads exhausted

View File

@@ -12,7 +12,6 @@ import (
"sync/atomic"
"time"
"tailscale.com/logtail/backoff"
"tailscale.com/net/sockstats"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
@@ -21,8 +20,10 @@ import (
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
"tailscale.com/types/structs"
"tailscale.com/util/backoff"
"tailscale.com/util/clientmetric"
"tailscale.com/util/execqueue"
"tailscale.com/util/testenv"
)
type LoginGoal struct {
@@ -117,14 +118,13 @@ type Auto struct {
logf logger.Logf
closed bool
updateCh chan struct{} // readable when we should inform the server of a change
observer Observer // called to update Client status; always non-nil
observer Observer // if non-nil, called to update Client status
observerQueue execqueue.ExecQueue
shutdownFn func() // to be called prior to shutdown or nil
unregisterHealthWatch func()
mu sync.Mutex // mutex guards the following fields
started bool // whether [Auto.Start] has been called
wantLoggedIn bool // whether the user wants to be logged in per last method call
urlToVisit string // the last url we were told to visit
expiry time.Time
@@ -140,7 +140,6 @@ type Auto struct {
loggedIn bool // true if currently logged in
loginGoal *LoginGoal // non-nil if some login activity is desired
inMapPoll bool // true once we get the first MapResponse in a stream; false when HTTP response ends
state State // TODO(bradfitz): delete this, make it computed by method from other state
authCtx context.Context // context used for auth requests
mapCtx context.Context // context used for netmap and update requests
@@ -153,15 +152,21 @@ type Auto struct {
// New creates and starts a new Auto.
func New(opts Options) (*Auto, error) {
c, err := NewNoStart(opts)
if c != nil {
c.Start()
c, err := newNoStart(opts)
if err != nil {
return nil, err
}
if opts.StartPaused {
c.SetPaused(true)
}
if !opts.SkipStartForTests {
c.start()
}
return c, err
}
// NewNoStart creates a new Auto, but without calling Start on it.
func NewNoStart(opts Options) (_ *Auto, err error) {
// newNoStart creates a new Auto, but without calling Start on it.
func newNoStart(opts Options) (_ *Auto, err error) {
direct, err := NewDirect(opts)
if err != nil {
return nil, err
@@ -172,9 +177,6 @@ func NewNoStart(opts Options) (_ *Auto, err error) {
}
}()
if opts.Observer == nil {
return nil, errors.New("missing required Options.Observer")
}
if opts.Logf == nil {
opts.Logf = func(fmt string, args ...any) {}
}
@@ -192,15 +194,14 @@ func NewNoStart(opts Options) (_ *Auto, err error) {
observer: opts.Observer,
shutdownFn: opts.Shutdown,
}
c.authCtx, c.authCancel = context.WithCancel(context.Background())
c.authCtx = sockstats.WithSockStats(c.authCtx, sockstats.LabelControlClientAuto, opts.Logf)
c.mapCtx, c.mapCancel = context.WithCancel(context.Background())
c.mapCtx = sockstats.WithSockStats(c.mapCtx, sockstats.LabelControlClientAuto, opts.Logf)
c.unregisterHealthWatch = opts.HealthTracker.RegisterWatcher(direct.ReportHealthChange)
return c, nil
}
// SetPaused controls whether HTTP activity should be paused.
@@ -225,10 +226,21 @@ func (c *Auto) SetPaused(paused bool) {
c.unpauseWaiters = nil
}
// Start starts the client's goroutines.
// StartForTest starts the client's goroutines.
//
// It should only be called for clients created by NewNoStart.
func (c *Auto) Start() {
// It should only be called for clients created with [Options.SkipStartForTests].
func (c *Auto) StartForTest() {
testenv.AssertInTest()
c.start()
}
func (c *Auto) start() {
c.mu.Lock()
defer c.mu.Unlock()
if c.started {
return
}
c.started = true
go c.authRoutine()
go c.mapRoutine()
go c.updateRoutine()
@@ -302,10 +314,11 @@ func (c *Auto) authRoutine() {
c.mu.Lock()
goal := c.loginGoal
ctx := c.authCtx
loggedIn := c.loggedIn
if goal != nil {
c.logf("[v1] authRoutine: %s; wantLoggedIn=%v", c.state, true)
c.logf("[v1] authRoutine: loggedIn=%v; wantLoggedIn=%v", loggedIn, true)
} else {
c.logf("[v1] authRoutine: %s; goal=nil paused=%v", c.state, c.paused)
c.logf("[v1] authRoutine: loggedIn=%v; goal=nil paused=%v", loggedIn, c.paused)
}
c.mu.Unlock()
@@ -328,11 +341,6 @@ func (c *Auto) authRoutine() {
c.mu.Lock()
c.urlToVisit = goal.url
if goal.url != "" {
c.state = StateURLVisitRequired
} else {
c.state = StateAuthenticating
}
c.mu.Unlock()
var url string
@@ -366,7 +374,6 @@ func (c *Auto) authRoutine() {
flags: LoginDefault,
url: url,
}
c.state = StateURLVisitRequired
c.mu.Unlock()
c.sendStatus("authRoutine-url", err, url, nil)
@@ -386,7 +393,6 @@ func (c *Auto) authRoutine() {
c.urlToVisit = ""
c.loggedIn = true
c.loginGoal = nil
c.state = StateAuthenticated
c.mu.Unlock()
c.sendStatus("authRoutine-success", nil, "", nil)
@@ -419,6 +425,11 @@ func (c *Auto) unpausedChanLocked() <-chan bool {
return unpaused
}
// ClientID returns the ClientID of the direct controlClient
func (c *Auto) ClientID() int64 {
return c.direct.ClientID()
}
// mapRoutineState is the state of Auto.mapRoutine while it's running.
type mapRoutineState struct {
c *Auto
@@ -431,21 +442,17 @@ func (mrs mapRoutineState) UpdateFullNetmap(nm *netmap.NetworkMap) {
c := mrs.c
c.mu.Lock()
ctx := c.mapCtx
c.inMapPoll = true
if c.loggedIn {
c.state = StateSynchronized
}
c.expiry = nm.Expiry
c.expiry = nm.SelfKeyExpiry()
stillAuthed := c.loggedIn
c.logf("[v1] mapRoutine: netmap received: %s", c.state)
c.logf("[v1] mapRoutine: netmap received: loggedIn=%v inMapPoll=true", stillAuthed)
c.mu.Unlock()
if stillAuthed {
c.sendStatus("mapRoutine-got-netmap", nil, "", nm)
}
// Reset the backoff timer if we got a netmap.
mrs.bo.BackOff(ctx, nil)
mrs.bo.Reset()
}
func (mrs mapRoutineState) UpdateNetmapDelta(muts []netmap.NodeMutation) bool {
@@ -486,8 +493,8 @@ func (c *Auto) mapRoutine() {
}
c.mu.Lock()
c.logf("[v1] mapRoutine: %s", c.state)
loggedIn := c.loggedIn
c.logf("[v1] mapRoutine: loggedIn=%v", loggedIn)
ctx := c.mapCtx
c.mu.Unlock()
@@ -518,9 +525,6 @@ func (c *Auto) mapRoutine() {
c.direct.health.SetOutOfPollNetMap()
c.mu.Lock()
c.inMapPoll = false
if c.state == StateSynchronized {
c.state = StateAuthenticated
}
paused := c.paused
c.mu.Unlock()
@@ -586,12 +590,12 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
c.mu.Unlock()
return
}
state := c.state
loggedIn := c.loggedIn
inMapPoll := c.inMapPoll
loginGoal := c.loginGoal
c.mu.Unlock()
c.logf("[v1] sendStatus: %s: %v", who, state)
c.logf("[v1] sendStatus: %s: loggedIn=%v inMapPoll=%v", who, loggedIn, inMapPoll)
var p persist.PersistView
if nm != nil && loggedIn && inMapPoll {
@@ -602,18 +606,31 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
nm = nil
}
newSt := &Status{
URL: url,
Persist: p,
NetMap: nm,
Err: err,
state: state,
URL: url,
Persist: p,
NetMap: nm,
Err: err,
LoggedIn: loggedIn && loginGoal == nil,
InMapPoll: inMapPoll,
}
if c.observer == nil {
return
}
c.lastStatus.Store(newSt)
// Launch a new goroutine to avoid blocking the caller while the observer
// does its thing, which may result in a call back into the client.
metricQueued.Add(1)
c.observerQueue.Add(func() {
c.mu.Lock()
closed := c.closed
c.mu.Unlock()
if closed {
return
}
if canSkipStatus(newSt, c.lastStatus.Load()) {
metricSkippable.Add(1)
if !c.direct.controlKnobs.DisableSkipStatusQueue.Load() {
@@ -657,14 +674,15 @@ func canSkipStatus(s1, s2 *Status) bool {
// we can't skip it.
return false
}
if s1.Err != nil || s1.URL != "" {
// If s1 has an error or a 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.
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.
return false
}
if !s1.Persist.Equals(s2.Persist) || s1.state != s2.state {
// If s1 has a different Persist or state than s2,
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.
return false
@@ -726,7 +744,6 @@ func (c *Auto) Logout(ctx context.Context) error {
}
c.mu.Lock()
c.loggedIn = false
c.state = StateNotAuthenticated
c.cancelAuthCtxLocked()
c.cancelMapCtxLocked()
c.mu.Unlock()
@@ -750,6 +767,13 @@ func (c *Auto) UpdateEndpoints(endpoints []tailcfg.Endpoint) {
}
}
// SetDiscoPublicKey sets the client's Disco public to key and sends the change
// to the control server.
func (c *Auto) SetDiscoPublicKey(key key.DiscoPublic) {
c.direct.SetDiscoPublicKey(key)
c.updateControl()
}
func (c *Auto) Shutdown() {
c.mu.Lock()
if c.closed {
@@ -774,7 +798,6 @@ func (c *Auto) Shutdown() {
shutdownFn()
}
c.unregisterHealthWatch()
<-c.authDone
<-c.mapDone
<-c.updateDone
@@ -813,13 +836,3 @@ func (c *Auto) SetDNS(ctx context.Context, req *tailcfg.SetDNSRequest) error {
func (c *Auto) DoNoiseRequest(req *http.Request) (*http.Response, error) {
return c.direct.DoNoiseRequest(req)
}
// GetSingleUseNoiseRoundTripper returns a RoundTripper that can be only be used
// once (and must be used once) to make a single HTTP request over the noise
// channel to the coordination server.
//
// In addition to the RoundTripper, it returns the HTTP/2 channel's early noise
// payload, if any.
func (c *Auto) GetSingleUseNoiseRoundTripper(ctx context.Context) (http.RoundTripper, *tailcfg.EarlyNoise, error) {
return c.direct.GetSingleUseNoiseRoundTripper(ctx)
}

View File

@@ -12,6 +12,7 @@ import (
"context"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
// LoginFlags is a bitmask of options to change the behavior of Client.Login
@@ -80,7 +81,15 @@ type Client interface {
// TODO: a server-side change would let us simply upload this
// in a separate http request. It has nothing to do with the rest of
// the state machine.
// Note: the auto client uploads the new endpoints to control immediately.
UpdateEndpoints(endpoints []tailcfg.Endpoint)
// SetDiscoPublicKey updates the disco public key that will be sent in
// 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)
// 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.

View File

@@ -4,9 +4,11 @@
package controlclient
import (
"bufio"
"bytes"
"cmp"
"context"
"crypto"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
@@ -16,19 +18,20 @@ import (
"net"
"net/http"
"net/netip"
"net/url"
"os"
"reflect"
"runtime"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"go4.org/mem"
"tailscale.com/control/controlknobs"
"tailscale.com/control/ts2021"
"tailscale.com/envknob"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn/ipnstate"
@@ -37,11 +40,11 @@ import (
"tailscale.com/net/dnsfallback"
"tailscale.com/net/netmon"
"tailscale.com/net/netutil"
"tailscale.com/net/netx"
"tailscale.com/net/tlsdial"
"tailscale.com/net/tsdial"
"tailscale.com/net/tshttpproxy"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tempfork/httprec"
"tailscale.com/tka"
"tailscale.com/tstime"
"tailscale.com/types/key"
@@ -51,62 +54,70 @@ import (
"tailscale.com/types/ptr"
"tailscale.com/types/tkatype"
"tailscale.com/util/clientmetric"
"tailscale.com/util/multierr"
"tailscale.com/util/eventbus"
"tailscale.com/util/singleflight"
"tailscale.com/util/syspolicy"
"tailscale.com/util/systemd"
"tailscale.com/util/syspolicy/pkey"
"tailscale.com/util/syspolicy/policyclient"
"tailscale.com/util/testenv"
"tailscale.com/util/zstdframe"
)
// Direct is the client that connects to a tailcontrol server for a node.
type Direct struct {
httpc *http.Client // HTTP client used to talk to tailcontrol
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
discoPubKey key.DiscoPublic
getMachinePrivKey func() (key.MachinePrivate, error)
debugFlags []string
skipIPForwardingCheck bool
pinger Pinger
popBrowser func(url string) // or nil
c2nHandler http.Handler // or nil
onClientVersion func(*tailcfg.ClientVersion) // or nil
onControlTime func(time.Time) // or nil
onTailnetDefaultAutoUpdate func(bool) // 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
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
dialPlan ControlDialPlanner // can be nil
mu sync.Mutex // mutex guards the following fields
mu syncs.Mutex // mutex guards the following fields
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]
sfGroup singleflight.Group[struct{}, *NoiseClient] // protects noiseClient creation.
noiseClient *NoiseClient
sfGroup singleflight.Group[struct{}, *ts2021.Client] // protects noiseClient creation.
noiseClient *ts2021.Client // also protected by mu
persist persist.PersistView
authKey string
tryingNewKey key.NodePrivate
expiry time.Time // or zero value if none/unknown
hostinfo *tailcfg.Hostinfo // always non-nil
netinfo *tailcfg.NetInfo
endpoints []tailcfg.Endpoint
tkaHead string
lastPingURL string // last PingRequest.URL received, for dup suppression
persist persist.PersistView
authKey string
tryingNewKey key.NodePrivate
expiry time.Time // or zero value if none/unknown
hostinfo *tailcfg.Hostinfo // always non-nil
netinfo *tailcfg.NetInfo
endpoints []tailcfg.Endpoint
tkaHead string
lastPingURL string // last PingRequest.URL received, for dup suppression
connectionHandleForTest string // sent in MapRequest.ConnectionHandleForTest
controlClientID int64 // Random ID used to differentiate clients for consumers of messages.
}
// Observer is implemented by users of the control client (such as LocalBackend)
// to get notified of changes in the control client's status.
//
// If an implementation of Observer also implements [NetmapDeltaUpdater], they get
// delta updates as well as full netmap updates.
type Observer interface {
// SetControlClientStatus is called when the client has a new status to
// report. The Client is provided to allow the Observer to track which
@@ -116,28 +127,36 @@ type Observer interface {
}
type Options struct {
Persist persist.Persist // initial persistent data
GetMachinePrivateKey func() (key.MachinePrivate, error) // returns the machine key to use
ServerURL string // URL of the tailcontrol server
AuthKey string // optional node auth key for auto registration
Clock tstime.Clock
Hostinfo *tailcfg.Hostinfo // non-nil passes ownership, nil means to use default using os.Hostname, etc
DiscoPublicKey key.DiscoPublic
Logf logger.Logf
HTTPTestClient *http.Client // optional HTTP client to use (for tests only)
NoiseTestClient *http.Client // optional HTTP client to use for noise RPCs (tests only)
DebugFlags []string // debug settings to send to control
HealthTracker *health.Tracker
PopBrowserURL func(url string) // optional func to open browser
OnClientVersion func(*tailcfg.ClientVersion) // optional func to inform GUI of client version status
OnControlTime func(time.Time) // optional func to notify callers of new time from control
OnTailnetDefaultAutoUpdate func(bool) // optional func to inform GUI of default auto-update setting for the tailnet
Dialer *tsdial.Dialer // non-nil
C2NHandler http.Handler // or nil
ControlKnobs *controlknobs.Knobs // or nil to ignore
Persist persist.Persist // initial persistent data
GetMachinePrivateKey func() (key.MachinePrivate, error) // returns the machine key to use
ServerURL string // URL of the tailcontrol server
AuthKey string // optional node auth key for auto registration
Clock tstime.Clock
Hostinfo *tailcfg.Hostinfo // non-nil passes ownership, nil means to use default using os.Hostname, etc
DiscoPublicKey key.DiscoPublic
PolicyClient policyclient.Client // or nil for none
Logf logger.Logf
HTTPTestClient *http.Client // optional HTTP client to use (for tests only)
NoiseTestClient *http.Client // optional HTTP client to use for noise RPCs (tests only)
DebugFlags []string // debug settings to send to control
HealthTracker *health.Tracker
PopBrowserURL func(url string) // optional func to open browser
Dialer *tsdial.Dialer // non-nil
C2NHandler http.Handler // or nil
ControlKnobs *controlknobs.Knobs // or nil to ignore
Bus *eventbus.Bus // non-nil, for setting up publishers
SkipStartForTests bool // if true, don't call [Auto.Start] to avoid any background goroutines (for tests only)
// StartPaused indicates whether the client should start in a paused state
// where it doesn't do network requests. This primarily exists for testing
// but not necessarily "go test" tests, so it isn't restricted to only
// being used in tests.
StartPaused bool
// Observer is called when there's a change in status to report
// from the control client.
// If nil, no status updates are reported.
Observer Observer
// SkipIPForwardingCheck declares that the host's IP
@@ -213,6 +232,8 @@ type NetmapDeltaUpdater interface {
UpdateNetmapDelta([]netmap.NodeMutation) (ok bool)
}
var nextControlClientID atomic.Int64
// NewDirect returns a new Direct client.
func NewDirect(opts Options) (*Direct, error) {
if opts.ServerURL == "" {
@@ -238,10 +259,6 @@ func NewDirect(opts Options) (*Direct, error) {
opts.ControlKnobs = &controlknobs.Knobs{}
}
opts.ServerURL = strings.TrimRight(opts.ServerURL, "/")
serverURL, err := url.Parse(opts.ServerURL)
if err != nil {
return nil, err
}
if opts.Clock == nil {
opts.Clock = tstime.StdClock{}
}
@@ -269,10 +286,14 @@ func NewDirect(opts Options) (*Direct, error) {
var interceptedDial *atomic.Bool
if httpc == nil {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = tshttpproxy.ProxyFromEnvironment
tshttpproxy.SetTransportGetProxyConnectHeader(tr)
tr.TLSClientConfig = tlsdial.Config(serverURL.Hostname(), opts.HealthTracker, tr.TLSClientConfig)
var dialFunc dialFunc
if buildfeatures.HasUseProxy {
tr.Proxy = feature.HookProxyFromEnvironment.GetOrNil()
if f, ok := feature.HookProxySetTransportGetProxyConnectHeader.GetOk(); ok {
f(tr)
}
}
tr.TLSClientConfig = tlsdial.Config(opts.HealthTracker, tr.TLSClientConfig)
var dialFunc netx.DialFunc
dialFunc, interceptedDial = makeScreenTimeDetectingDialFunc(opts.Dialer.SystemDial)
tr.DialContext = dnscache.Dialer(dialFunc, dnsCache)
tr.DialTLSContext = dnscache.TLSDialer(dialFunc, dnsCache, tr.TLSClientConfig)
@@ -286,32 +307,32 @@ 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,
discoPubKey: opts.DiscoPublicKey,
debugFlags: opts.DebugFlags,
netMon: netMon,
health: opts.HealthTracker,
skipIPForwardingCheck: opts.SkipIPForwardingCheck,
pinger: opts.Pinger,
popBrowser: opts.PopBrowserURL,
onClientVersion: opts.OnClientVersion,
onTailnetDefaultAutoUpdate: opts.OnTailnetDefaultAutoUpdate,
onControlTime: opts.OnControlTime,
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,
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,
}
c.discoPubKey = opts.DiscoPublicKey
c.closedCtx, c.closeCtx = context.WithCancel(context.Background())
c.controlClientID = nextControlClientID.Add(1)
if opts.Hostinfo == nil {
c.SetHostinfo(hostinfo.New())
} else {
@@ -321,7 +342,7 @@ func NewDirect(opts Options) (*Direct, error) {
}
}
if opts.NoiseTestClient != nil {
c.noiseClient = &NoiseClient{
c.noiseClient = &ts2021.Client{
Client: opts.NoiseTestClient,
}
c.serverNoiseKey = key.NewMachine().Public() // prevent early error before hitting test client
@@ -329,6 +350,12 @@ func NewDirect(opts Options) (*Direct, error) {
if strings.Contains(opts.ServerURL, "controlplane.tailscale.com") && envknob.Bool("TS_PANIC_IF_HIT_MAIN_CONTROL") {
c.panicOnUse = true
}
c.busClient = opts.Bus.Client("controlClient.direct")
c.clientVersionPub = eventbus.Publish[tailcfg.ClientVersion](c.busClient)
c.autoUpdatePub = eventbus.Publish[AutoUpdate](c.busClient)
c.controlTimePub = eventbus.Publish[ControlTime](c.busClient)
return c, nil
}
@@ -338,15 +365,14 @@ func (c *Direct) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.busClient.Close()
if c.noiseClient != nil {
if err := c.noiseClient.Close(); err != nil {
return err
}
}
c.noiseClient = nil
if tr, ok := c.httpc.Transport.(*http.Transport); ok {
tr.CloseIdleConnections()
}
c.httpc.CloseIdleConnections()
return nil
}
@@ -387,7 +413,7 @@ func (c *Direct) SetNetInfo(ni *tailcfg.NetInfo) bool {
return true
}
// SetNetInfo stores a new TKA head value for next update.
// SetTKAHead stores a new TKA head value for next update.
// It reports whether the TKA head changed.
func (c *Direct) SetTKAHead(tkaHead string) bool {
c.mu.Lock()
@@ -402,6 +428,14 @@ func (c *Direct) SetTKAHead(tkaHead string) bool {
return true
}
// SetConnectionHandleForTest stores a new MapRequest.ConnectionHandleForTest
// value for the next update.
func (c *Direct) SetConnectionHandleForTest(handle string) {
c.mu.Lock()
defer c.mu.Unlock()
c.connectionHandleForTest = handle
}
func (c *Direct) GetPersist() persist.PersistView {
c.mu.Lock()
defer c.mu.Unlock()
@@ -523,7 +557,9 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
} else {
if expired {
c.logf("Old key expired -> regen=true")
systemd.Status("key expired; run 'tailscale up' to authenticate")
if f, ok := feature.HookSystemdStatus.GetOk(); ok {
f("key expired; run 'tailscale up' to authenticate")
}
regen = true
}
if (opt.Flags & LoginInteractive) != 0 {
@@ -582,6 +618,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
if persist.NetworkLockKey.IsZero() {
persist.NetworkLockKey = key.NewNLPrivate()
}
nlPub := persist.NetworkLockKey.Public()
if tryingNewKey.IsZero() {
@@ -611,7 +648,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
return regen, opt.URL, nil, err
}
tailnet, err := syspolicy.GetString(syspolicy.Tailnet, "")
tailnet, err := c.polc.GetString(pkey.Tailnet, "")
if err != nil {
c.logf("unable to provide Tailnet field in register request. err: %v", err)
}
@@ -641,7 +678,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
AuthKey: authKey,
}
}
err = signRegisterRequest(&request, c.serverURL, c.serverLegacyKey, machinePrivKey.Public())
err = signRegisterRequest(c.polc, &request, c.serverURL, c.serverLegacyKey, machinePrivKey.Public())
if err != nil {
// If signing failed, clear all related fields
request.SignatureType = tailcfg.SignatureNone
@@ -678,8 +715,8 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
if err != nil {
return regen, opt.URL, nil, err
}
addLBHeader(req, request.OldNodeKey)
addLBHeader(req, request.NodeKey)
ts2021.AddLBHeader(req, request.OldNodeKey)
ts2021.AddLBHeader(req, request.NodeKey)
res, err := httpc.Do(req)
if err != nil {
@@ -816,6 +853,31 @@ func (c *Direct) SendUpdate(ctx context.Context) error {
return c.sendMapRequest(ctx, false, nil)
}
// SetDiscoPublicKey updates the disco public key in local state.
// It does not implicitly trigger [SendUpdate]; callers should arrange for that.
func (c *Direct) SetDiscoPublicKey(key key.DiscoPublic) {
c.mu.Lock()
defer c.mu.Unlock()
c.discoPubKey = key
}
// ClientID returns the controlClientID of the controlClient.
func (c *Direct) ClientID() int64 {
return c.controlClientID
}
// AutoUpdate is an eventbus value, reporting the value of tailcfg.MapResponse.DefaultAutoUpdate.
type AutoUpdate struct {
ClientID int64 // The ID field is used for consumers to differentiate instances of Direct.
Value bool // The Value represents DefaultAutoUpdate from [tailcfg.MapResponse].
}
// ControlTime is an eventbus value, reporting the value of tailcfg.MapResponse.ControlTime.
type ControlTime struct {
ClientID int64 // The ID field is used for consumers to differentiate instances of Direct.
Value time.Time // The Value represents ControlTime from [tailcfg.MapResponse].
}
// If we go more than watchdogTimeout without hearing from the server,
// end the long poll. We should be receiving a keep alive ping
// every minute.
@@ -848,8 +910,11 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
persist := c.persist
serverURL := c.serverURL
serverNoiseKey := c.serverNoiseKey
discoKey := c.discoPubKey
hi := c.hostInfoLocked()
backendLogID := hi.BackendLogID
connectionHandleForTest := c.connectionHandleForTest
tkaHead := c.tkaHead
var epStrs []string
var eps []netip.AddrPort
var epTypes []tailcfg.EndpointType
@@ -889,21 +954,44 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
}
nodeKey := persist.PublicNodeKey()
request := &tailcfg.MapRequest{
Version: tailcfg.CurrentCapabilityVersion,
KeepAlive: true,
NodeKey: nodeKey,
DiscoKey: c.discoPubKey,
Endpoints: eps,
EndpointTypes: epTypes,
Stream: isStreaming,
Hostinfo: hi,
DebugFlags: c.debugFlags,
OmitPeers: nu == nil,
TKAHead: c.tkaHead,
Version: tailcfg.CurrentCapabilityVersion,
KeepAlive: true,
NodeKey: nodeKey,
DiscoKey: discoKey,
Endpoints: eps,
EndpointTypes: epTypes,
Stream: isStreaming,
Hostinfo: hi,
DebugFlags: c.debugFlags,
OmitPeers: nu == nil,
TKAHead: tkaHead,
ConnectionHandleForTest: connectionHandleForTest,
}
// If we have a hardware attestation key, sign the node key with it and send
// the key & signature in the map request.
if buildfeatures.HasTPM {
if k := persist.AsStruct().AttestationKey; k != nil && !k.IsZero() {
hwPub := key.HardwareAttestationPublicFromPlatformKey(k)
request.HardwareAttestationKey = hwPub
t := c.clock.Now()
msg := fmt.Sprintf("%d|%s", t.Unix(), nodeKey.String())
digest := sha256.Sum256([]byte(msg))
sig, err := k.Sign(nil, digest[:], crypto.SHA256)
if err != nil {
c.logf("failed to sign node key with hardware attestation key: %v", err)
} else {
request.HardwareAttestationKeySignature = sig
request.HardwareAttestationKeySignatureTimestamp = t
}
}
}
var extraDebugFlags []string
if hi != nil && c.netMon != nil && !c.skipIPForwardingCheck &&
if buildfeatures.HasAdvertiseRoutes && hi != nil && c.netMon != nil && !c.skipIPForwardingCheck &&
ipForwardingBroken(hi.RoutableIPs, c.netMon.InterfaceState()) {
extraDebugFlags = append(extraDebugFlags, "warn-ip-forwarding-off")
}
@@ -967,7 +1055,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
if err != nil {
return err
}
addLBHeader(req, nodeKey)
ts2021.AddLBHeader(req, nodeKey)
res, err := httpc.Do(req)
if err != nil {
@@ -1015,7 +1103,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
c.persist = newPersist.View()
persist = c.persist
}
c.expiry = nm.Expiry
c.expiry = nm.SelfKeyExpiry()
}
// gotNonKeepAliveMessage is whether we've yet received a MapResponse message without
@@ -1047,7 +1135,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
vlogf("netmap: read body after %v", time.Since(t0).Round(time.Millisecond))
var resp tailcfg.MapResponse
if err := c.decodeMsg(msg, &resp); err != nil {
if err := sess.decodeMsg(msg, &resp); err != nil {
vlogf("netmap: decode error: %v", err)
return err
}
@@ -1072,21 +1160,19 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
c.logf("netmap: control says to open URL %v; no popBrowser func", u)
}
}
if resp.ClientVersion != nil && c.onClientVersion != nil {
c.onClientVersion(resp.ClientVersion)
if resp.ClientVersion != nil {
c.clientVersionPub.Publish(*resp.ClientVersion)
}
if resp.ControlTime != nil && !resp.ControlTime.IsZero() {
c.logf.JSON(1, "controltime", resp.ControlTime.UTC())
if c.onControlTime != nil {
c.onControlTime(*resp.ControlTime)
}
c.controlTimePub.Publish(ControlTime{c.controlClientID, *resp.ControlTime})
}
if resp.KeepAlive {
vlogf("netmap: got keep-alive")
} else {
vlogf("netmap: got new map")
}
if resp.ControlDialPlan != nil {
if resp.ControlDialPlan != nil && !ignoreDialPlan() {
if c.dialPlan != nil {
c.logf("netmap: got new dial plan from control")
c.dialPlan.Store(resp.ControlDialPlan)
@@ -1098,11 +1184,21 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
metricMapResponseKeepAlives.Add(1)
continue
}
if au, ok := resp.DefaultAutoUpdate.Get(); ok {
if c.onTailnetDefaultAutoUpdate != nil {
c.onTailnetDefaultAutoUpdate(au)
// DefaultAutoUpdate in its CapMap and deprecated top-level field forms.
if self := resp.Node; self != nil {
for _, v := range self.CapMap[tailcfg.NodeAttrDefaultAutoUpdate] {
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)
}
}
}
if au, ok := resp.DeprecatedDefaultAutoUpdate.Get(); ok {
c.autoUpdatePub.Publish(AutoUpdate{c.controlClientID, au})
}
metricMapResponseMap.Add(1)
if gotNonKeepAliveMessage {
@@ -1125,12 +1221,33 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
return nil
}
// NetmapFromMapResponseForDebug returns a NetworkMap from the given MapResponse.
// It is intended for debugging only.
func NetmapFromMapResponseForDebug(ctx context.Context, pr persist.PersistView, resp *tailcfg.MapResponse) (*netmap.NetworkMap, error) {
if resp == nil {
return nil, errors.New("nil MapResponse")
}
if resp.Node == nil {
return nil, errors.New("MapResponse lacks Node")
}
nu := &rememberLastNetmapUpdater{}
sess := newMapSession(pr.PrivateNodeKey(), nu, nil)
defer sess.Close()
if err := sess.HandleNonKeepAliveMapResponse(ctx, resp); err != nil {
return nil, fmt.Errorf("HandleNonKeepAliveMapResponse: %w", err)
}
return sess.netmap(), nil
}
func (c *Direct) handleDebugMessage(ctx context.Context, debug *tailcfg.Debug) error {
if code := debug.Exit; code != nil {
c.logf("exiting process with status %v per controlplane", *code)
os.Exit(*code)
}
if debug.DisableLogTail {
if buildfeatures.HasLogTail && debug.DisableLogTail {
logtail.Disable()
envknob.SetNoLogsNoSupport()
}
@@ -1179,12 +1296,23 @@ func decode(res *http.Response, v any) error {
var jsonEscapedZero = []byte(`\u0000`)
const justKeepAliveStr = `{"KeepAlive":true}`
// decodeMsg is responsible for uncompressing msg and unmarshaling into v.
func (c *Direct) decodeMsg(compressedMsg []byte, v any) error {
func (sess *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) {
v.KeepAlive = true
return nil
}
b, err := zstdframe.AppendDecode(nil, compressedMsg)
if err != nil {
return err
}
sess.ztdDecodesForTest++
if DevKnob.DumpNetMaps() {
var buf bytes.Buffer
json.Indent(&buf, b, "", " ")
@@ -1197,6 +1325,9 @@ func (c *Direct) decodeMsg(compressedMsg []byte, v any) error {
if err := json.Unmarshal(b, v); err != nil {
return fmt.Errorf("response: %v", err)
}
if v.KeepAlive && string(b) == justKeepAliveStr {
sess.keepAliveZ = compressedMsg
}
return nil
}
@@ -1244,7 +1375,7 @@ func loadServerPubKeys(ctx context.Context, httpc *http.Client, serverURL string
out = tailcfg.OverTLSPublicKeyResponse{}
k, err := key.ParseMachinePublicUntyped(mem.B(b))
if err != nil {
return nil, multierr.New(jsonErr, err)
return nil, errors.Join(jsonErr, err)
}
out.LegacyPublicKey = k
return &out, nil
@@ -1314,6 +1445,10 @@ func (c *Direct) isUniquePingRequest(pr *tailcfg.PingRequest) bool {
return true
}
// HookAnswerC2NPing is where feature/c2n conditionally registers support
// for handling C2N (control-to-node) HTTP requests.
var HookAnswerC2NPing feature.Hook[func(logger.Logf, http.Handler, *http.Client, *tailcfg.PingRequest)]
func (c *Direct) answerPing(pr *tailcfg.PingRequest) {
httpc := c.httpc
useNoise := pr.URLIsNoise || pr.Types == "c2n"
@@ -1334,11 +1469,16 @@ func (c *Direct) answerPing(pr *tailcfg.PingRequest) {
answerHeadPing(c.logf, httpc, pr)
return
case "c2n":
if !buildfeatures.HasC2N {
return
}
if !useNoise && !envknob.Bool("TS_DEBUG_PERMIT_HTTP_C2N") {
c.logf("refusing to answer c2n ping without noise")
return
}
answerC2NPing(c.logf, c.c2nHandler, httpc, pr)
if f, ok := HookAnswerC2NPing.GetOk(); ok {
f(c.logf, c.c2nHandler, httpc, pr)
}
return
}
for _, t := range strings.Split(pr.Types, ",") {
@@ -1373,54 +1513,6 @@ func answerHeadPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest) {
}
}
func answerC2NPing(logf logger.Logf, c2nHandler http.Handler, c *http.Client, pr *tailcfg.PingRequest) {
if c2nHandler == nil {
logf("answerC2NPing: c2nHandler not defined")
return
}
hreq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(pr.Payload)))
if err != nil {
logf("answerC2NPing: ReadRequest: %v", err)
return
}
if pr.Log {
logf("answerC2NPing: got c2n request for %v ...", hreq.RequestURI)
}
handlerTimeout := time.Minute
if v := hreq.Header.Get("C2n-Handler-Timeout"); v != "" {
handlerTimeout, _ = time.ParseDuration(v)
}
handlerCtx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
hreq = hreq.WithContext(handlerCtx)
rec := httprec.NewRecorder()
c2nHandler.ServeHTTP(rec, hreq)
cancel()
c2nResBuf := new(bytes.Buffer)
rec.Result().Write(c2nResBuf)
replyCtx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(replyCtx, "POST", pr.URL, c2nResBuf)
if err != nil {
logf("answerC2NPing: NewRequestWithContext: %v", err)
return
}
if pr.Log {
logf("answerC2NPing: sending POST ping to %v ...", pr.URL)
}
t0 := clock.Now()
_, err = c.Do(req)
d := time.Since(t0).Round(time.Millisecond)
if err != nil {
logf("answerC2NPing error: %v to %v (after %v)", err, pr.URL, d)
} else if pr.Log {
logf("answerC2NPing complete to %v (after %v)", pr.URL, d)
}
}
// sleepAsRequest implements the sleep for a tailcfg.Debug message requesting
// that the client sleep. The complication is that while we're sleeping (if for
// a long time), we need to periodically reset the watchdog timer before it
@@ -1445,7 +1537,7 @@ func sleepAsRequested(ctx context.Context, logf logger.Logf, d time.Duration, cl
}
// getNoiseClient returns the noise client, creating one if one doesn't exist.
func (c *Direct) getNoiseClient() (*NoiseClient, error) {
func (c *Direct) getNoiseClient() (*ts2021.Client, error) {
c.mu.Lock()
serverNoiseKey := c.serverNoiseKey
nc := c.noiseClient
@@ -1460,13 +1552,13 @@ func (c *Direct) getNoiseClient() (*NoiseClient, error) {
if c.dialPlan != nil {
dp = c.dialPlan.Load
}
nc, err, _ := c.sfGroup.Do(struct{}{}, func() (*NoiseClient, error) {
nc, err, _ := c.sfGroup.Do(struct{}{}, func() (*ts2021.Client, error) {
k, err := c.getMachinePrivKey()
if err != nil {
return nil, err
}
c.logf("[v1] creating new noise client")
nc, err := NewNoiseClient(NoiseOpts{
nc, err := ts2021.NewClient(ts2021.ClientOpts{
PrivKey: k,
ServerPubKey: serverNoiseKey,
ServerURL: c.serverURL,
@@ -1500,7 +1592,7 @@ func (c *Direct) setDNSNoise(ctx context.Context, req *tailcfg.SetDNSRequest) er
if err != nil {
return err
}
res, err := nc.post(ctx, "/machine/set-dns", newReq.NodeKey, &newReq)
res, err := nc.Post(ctx, "/machine/set-dns", newReq.NodeKey, &newReq)
if err != nil {
return err
}
@@ -1521,6 +1613,9 @@ func (c *Direct) setDNSNoise(ctx context.Context, req *tailcfg.SetDNSRequest) er
// SetDNS sends the SetDNSRequest request to the control plane server,
// requesting a DNS record be created or updated.
func (c *Direct) SetDNS(ctx context.Context, req *tailcfg.SetDNSRequest) (err error) {
if !buildfeatures.HasACME {
return feature.ErrUnavailable
}
metricSetDNS.Add(1)
defer func() {
if err != nil {
@@ -1541,20 +1636,6 @@ func (c *Direct) DoNoiseRequest(req *http.Request) (*http.Response, error) {
return nc.Do(req)
}
// GetSingleUseNoiseRoundTripper returns a RoundTripper that can be only be used
// once (and must be used once) to make a single HTTP request over the noise
// channel to the coordination server.
//
// In addition to the RoundTripper, it returns the HTTP/2 channel's early noise
// payload, if any.
func (c *Direct) GetSingleUseNoiseRoundTripper(ctx context.Context) (http.RoundTripper, *tailcfg.EarlyNoise, error) {
nc, err := c.getNoiseClient()
if err != nil {
return nil, nil, err
}
return nc.GetSingleUseRoundTripper(ctx)
}
// doPingerPing sends a Ping to pr.IP using pinger, and sends an http request back to
// pr.URL with ping response data.
func doPingerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pinger Pinger, pingType tailcfg.PingType) {
@@ -1611,47 +1692,6 @@ func postPingResult(start time.Time, logf logger.Logf, c *http.Client, pr *tailc
return nil
}
// ReportHealthChange reports to the control plane a change to this node's
// health. w must be non-nil. us can be nil to indicate a healthy state for w.
func (c *Direct) ReportHealthChange(w *health.Warnable, us *health.UnhealthyState) {
if w == health.NetworkStatusWarnable || w == health.IPNStateWarnable || w == health.LoginStateWarnable {
// We don't report these. These include things like the network is down
// (in which case we can't report anyway) or the user wanted things
// stopped, as opposed to the more unexpected failure types in the other
// subsystems.
return
}
np, err := c.getNoiseClient()
if err != nil {
// Don't report errors to control if the server doesn't support noise.
return
}
nodeKey, ok := c.GetPersist().PublicNodeKeyOK()
if !ok {
return
}
if c.panicOnUse {
panic("tainted client")
}
// TODO(angott): at some point, update `Subsys` in the request to be `Warnable`
req := &tailcfg.HealthChangeRequest{
Subsys: string(w.Code),
NodeKey: nodeKey,
}
if us != nil {
req.Error = us.Text
}
// Best effort, no logging:
ctx, cancel := context.WithTimeout(c.closedCtx, 5*time.Second)
defer cancel()
res, err := np.post(ctx, "/machine/update-health", nodeKey, req)
if err != nil {
return
}
res.Body.Close()
}
// SetDeviceAttrs does a synchronous call to the control plane to update
// the node's attributes.
//
@@ -1690,7 +1730,7 @@ func (c *Direct) SetDeviceAttrs(ctx context.Context, attrs tailcfg.AttrUpdate) e
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
res, err := nc.doWithBody(ctx, "PATCH", "/machine/set-device-attr", nodeKey, req)
res, err := nc.DoWithBody(ctx, "PATCH", "/machine/set-device-attr", nodeKey, req)
if err != nil {
return err
}
@@ -1731,7 +1771,7 @@ func (c *Direct) sendAuditLog(ctx context.Context, auditLog tailcfg.AuditLogRequ
panic("tainted client")
}
res, err := nc.post(ctx, "/machine/audit-log", nodeKey, req)
res, err := nc.Post(ctx, "/machine/audit-log", nodeKey, req)
if err != nil {
return fmt.Errorf("%w: %w", errHTTPPostFailure, err)
}
@@ -1743,20 +1783,12 @@ func (c *Direct) sendAuditLog(ctx context.Context, auditLog tailcfg.AuditLogRequ
return nil
}
func addLBHeader(req *http.Request, nodeKey key.NodePublic) {
if !nodeKey.IsZero() {
req.Header.Add(tailcfg.LBHeader, nodeKey.String())
}
}
type dialFunc = func(ctx context.Context, network, addr string) (net.Conn, error)
// makeScreenTimeDetectingDialFunc returns dialFunc, optionally wrapped (on
// Apple systems) with a func that sets the returned atomic.Bool for whether
// Screen Time seemed to intercept the connection.
//
// The returned *atomic.Bool is nil on non-Apple systems.
func makeScreenTimeDetectingDialFunc(dial dialFunc) (dialFunc, *atomic.Bool) {
func makeScreenTimeDetectingDialFunc(dial netx.DialFunc) (netx.DialFunc, *atomic.Bool) {
switch runtime.GOOS {
case "darwin", "ios":
// Continue below.
@@ -1774,6 +1806,13 @@ func makeScreenTimeDetectingDialFunc(dial dialFunc) (dialFunc, *atomic.Bool) {
}, ab
}
func ignoreDialPlan() bool {
// If we're running in v86 (a JavaScript-based emulation of a 32-bit x86)
// our networking is very limited. Let's ignore the dial plan since it's too
// complicated to race that many IPs anyway.
return hostinfo.IsInVM86()
}
func isTCPLoopback(a net.Addr) bool {
if ta, ok := a.(*net.TCPAddr); ok {
return ta.IP.IsLoopback()

View File

@@ -6,7 +6,10 @@ package controlclient
import (
"cmp"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"maps"
"net"
"reflect"
@@ -19,6 +22,7 @@ import (
"tailscale.com/control/controlknobs"
"tailscale.com/envknob"
"tailscale.com/hostinfo"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
@@ -53,6 +57,9 @@ type mapSession struct {
altClock tstime.Clock // if nil, regular time is used
cancel context.CancelFunc // always non-nil, shuts down caller's base long poll context
keepAliveZ []byte // if non-nil, the learned zstd encoding of the just-KeepAlive message for this session
ztdDecodesForTest int // for testing
// sessionAliveCtx is a Background-based context that's alive for the
// duration of the mapSession that we own the lifetime of. It's closed by
// sessionAliveCtxClose.
@@ -86,6 +93,7 @@ type mapSession struct {
lastDomain string
lastDomainAuditLogID string
lastHealth []string
lastDisplayMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage
lastPopBrowserURL string
lastTKAInfo *tailcfg.TKAInfo
lastNetmapSummary string // from NetworkMap.VeryConcise
@@ -308,6 +316,31 @@ func (ms *mapSession) updateStateFromResponse(resp *tailcfg.MapResponse) {
}
}
// In the copy/v86 wasm environment with limited networking, if the
// control plane didn't pick our DERP home for us, do it ourselves and
// mark all but the lowest region as NoMeasureNoHome. For prod, this
// will be Region 1, NYC, a compromise between the US and Europe. But
// really the control plane should pick this. This is only a fallback.
if hostinfo.IsInVM86() {
numCanMeasure := 0
lowest := 0
for rid, r := range dm.Regions {
if !r.NoMeasureNoHome {
numCanMeasure++
if lowest == 0 || rid < lowest {
lowest = rid
}
}
}
if numCanMeasure > 1 {
for rid, r := range dm.Regions {
if rid != lowest {
r.NoMeasureNoHome = true
}
}
}
}
// Zero-valued fields in a DERPMap mean that we're not changing
// anything and are using the previous value(s).
if ldm := ms.lastDERPMap; ldm != nil {
@@ -383,6 +416,21 @@ func (ms *mapSession) updateStateFromResponse(resp *tailcfg.MapResponse) {
if resp.Health != nil {
ms.lastHealth = resp.Health
}
if resp.DisplayMessages != nil {
if v, ok := resp.DisplayMessages["*"]; ok && v == nil {
ms.lastDisplayMessages = nil
}
for k, v := range resp.DisplayMessages {
if k == "*" {
continue
}
if v != nil {
mak.Set(&ms.lastDisplayMessages, k, *v)
} else {
delete(ms.lastDisplayMessages, k)
}
}
}
if resp.TKAInfo != nil {
ms.lastTKAInfo = resp.TKAInfo
}
@@ -802,9 +850,23 @@ func (ms *mapSession) sortedPeers() []tailcfg.NodeView {
func (ms *mapSession) netmap() *netmap.NetworkMap {
peerViews := ms.sortedPeers()
var msgs map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage
if len(ms.lastDisplayMessages) != 0 {
msgs = ms.lastDisplayMessages
} else if len(ms.lastHealth) > 0 {
// Convert all ms.lastHealth to the new [netmap.NetworkMap.DisplayMessages]
for _, h := range ms.lastHealth {
id := "health-" + strhash(h) // Unique ID in case there is more than one health message
mak.Set(&msgs, tailcfg.DisplayMessageID(id), tailcfg.DisplayMessage{
Title: "Coordination server reports an issue",
Severity: tailcfg.SeverityMedium,
Text: "The coordination server is reporting a health issue: " + h,
})
}
}
nm := &netmap.NetworkMap{
NodeKey: ms.publicNodeKey,
PrivateKey: ms.privateNodeKey,
MachineKey: ms.machinePubKey,
Peers: peerViews,
UserProfiles: make(map[tailcfg.UserID]tailcfg.UserProfileView),
@@ -816,7 +878,7 @@ func (ms *mapSession) netmap() *netmap.NetworkMap {
SSHPolicy: ms.lastSSHPolicy,
CollectServices: ms.collectServices,
DERPMap: ms.lastDERPMap,
ControlHealth: ms.lastHealth,
DisplayMessages: msgs,
TKAEnabled: ms.lastTKAInfo != nil && !ms.lastTKAInfo.Disabled,
}
@@ -829,8 +891,6 @@ func (ms *mapSession) netmap() *netmap.NetworkMap {
if node := ms.lastNode; node.Valid() {
nm.SelfNode = node
nm.Expiry = node.KeyExpiry()
nm.Name = node.Name()
nm.AllCaps = ms.lastCapSet
}
@@ -842,5 +902,12 @@ func (ms *mapSession) netmap() *netmap.NetworkMap {
if DevKnob.ForceProxyDNS() {
nm.DNS.Proxied = true
}
return nm
}
func strhash(h string) string {
s := sha256.New()
io.WriteString(s, h)
return hex.EncodeToString(s.Sum(nil))
}

View File

@@ -1,418 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package controlclient
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"math"
"net/http"
"net/netip"
"net/url"
"sync"
"time"
"golang.org/x/net/http2"
"tailscale.com/control/controlhttp"
"tailscale.com/health"
"tailscale.com/internal/noiseconn"
"tailscale.com/net/dnscache"
"tailscale.com/net/netmon"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
"tailscale.com/util/multierr"
"tailscale.com/util/singleflight"
)
// NoiseClient provides a http.Client to connect to tailcontrol over
// the ts2021 protocol.
type NoiseClient struct {
// Client is an HTTP client to talk to the coordination server.
// It automatically makes a new Noise connection as needed.
// It does not support node key proofs. To do that, call
// noiseClient.getConn instead to make a connection.
*http.Client
// h2t is the HTTP/2 transport we use a bit to create new
// *http2.ClientConns. We don't use its connection pool and we don't use its
// dialing. We use it for exactly one reason: its idle timeout that can only
// be configured via the HTTP/1 config. And then we call NewClientConn (with
// an existing Noise connection) on the http2.Transport which sets up an
// http2.ClientConn using that idle timeout from an http1.Transport.
h2t *http2.Transport
// sfDial ensures that two concurrent requests for a noise connection only
// produce one shared one between the two callers.
sfDial singleflight.Group[struct{}, *noiseconn.Conn]
dialer *tsdial.Dialer
dnsCache *dnscache.Resolver
privKey key.MachinePrivate
serverPubKey key.MachinePublic
host string // the host part of serverURL
httpPort string // the default port to dial
httpsPort string // the fallback Noise-over-https port or empty if none
// dialPlan optionally returns a ControlDialPlan previously received
// from the control server; either the function or the return value can
// be nil.
dialPlan func() *tailcfg.ControlDialPlan
logf logger.Logf
netMon *netmon.Monitor
health *health.Tracker
// mu only protects the following variables.
mu sync.Mutex
closed bool
last *noiseconn.Conn // or nil
nextID int
connPool map[int]*noiseconn.Conn // active connections not yet closed; see noiseconn.Conn.Close
}
// NoiseOpts contains options for the NewNoiseClient function. All fields are
// required unless otherwise specified.
type NoiseOpts struct {
// PrivKey is this node's private key.
PrivKey key.MachinePrivate
// ServerPubKey is the public key of the server.
ServerPubKey key.MachinePublic
// ServerURL is the URL of the server to connect to.
ServerURL string
// Dialer's SystemDial function is used to connect to the server.
Dialer *tsdial.Dialer
// DNSCache is the caching Resolver to use to connect to the server.
//
// This field can be nil.
DNSCache *dnscache.Resolver
// Logf is the log function to use. This field can be nil.
Logf logger.Logf
// NetMon is the network monitor that, if set, will be used to get the
// network interface state. This field can be nil; if so, the current
// state will be looked up dynamically.
NetMon *netmon.Monitor
// HealthTracker, if non-nil, is the health tracker to use.
HealthTracker *health.Tracker
// DialPlan, if set, is a function that should return an explicit plan
// on how to connect to the server.
DialPlan func() *tailcfg.ControlDialPlan
}
// NewNoiseClient returns a new noiseClient for the provided server and machine key.
// serverURL is of the form https://<host>:<port> (no trailing slash).
//
// netMon may be nil, if non-nil it's used to do faster interface lookups.
// dialPlan may be nil
func NewNoiseClient(opts NoiseOpts) (*NoiseClient, error) {
logf := opts.Logf
u, err := url.Parse(opts.ServerURL)
if err != nil {
return nil, err
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, errors.New("invalid ServerURL scheme, must be http or https")
}
var httpPort string
var httpsPort string
addr, _ := netip.ParseAddr(u.Hostname())
isPrivateHost := addr.IsPrivate() || addr.IsLoopback() || u.Hostname() == "localhost"
if port := u.Port(); port != "" {
// If there is an explicit port specified, entirely rely on the scheme,
// unless it's http with a private host in which case we never try using HTTPS.
if u.Scheme == "https" {
httpPort = ""
httpsPort = port
} else if u.Scheme == "http" {
httpPort = port
httpsPort = "443"
if isPrivateHost {
logf("setting empty HTTPS port with http scheme and private host %s", u.Hostname())
httpsPort = ""
}
}
} else if u.Scheme == "http" && isPrivateHost {
// Whenever the scheme is http and the hostname is an IP address, do not set the HTTPS port,
// as there cannot be a TLS certificate issued for an IP, unless it's a public IP.
httpPort = "80"
httpsPort = ""
} else {
// Otherwise, use the standard ports
httpPort = "80"
httpsPort = "443"
}
np := &NoiseClient{
serverPubKey: opts.ServerPubKey,
privKey: opts.PrivKey,
host: u.Hostname(),
httpPort: httpPort,
httpsPort: httpsPort,
dialer: opts.Dialer,
dnsCache: opts.DNSCache,
dialPlan: opts.DialPlan,
logf: opts.Logf,
netMon: opts.NetMon,
health: opts.HealthTracker,
}
// Create the HTTP/2 Transport using a net/http.Transport
// (which only does HTTP/1) because it's the only way to
// configure certain properties on the http2.Transport. But we
// never actually use the net/http.Transport for any HTTP/1
// requests.
h2Transport, err := http2.ConfigureTransports(&http.Transport{
IdleConnTimeout: time.Minute,
})
if err != nil {
return nil, err
}
np.h2t = h2Transport
np.Client = &http.Client{Transport: np}
return np, nil
}
// GetSingleUseRoundTripper returns a RoundTripper that can be only be used once
// (and must be used once) to make a single HTTP request over the noise channel
// to the coordination server.
//
// In addition to the RoundTripper, it returns the HTTP/2 channel's early noise
// payload, if any.
func (nc *NoiseClient) GetSingleUseRoundTripper(ctx context.Context) (http.RoundTripper, *tailcfg.EarlyNoise, error) {
for tries := 0; tries < 3; tries++ {
conn, err := nc.getConn(ctx)
if err != nil {
return nil, nil, err
}
ok, earlyPayloadMaybeNil, err := conn.ReserveNewRequest(ctx)
if err != nil {
return nil, nil, err
}
if ok {
return conn, earlyPayloadMaybeNil, nil
}
}
return nil, nil, errors.New("[unexpected] failed to reserve a request on a connection")
}
// contextErr is an error that wraps another error and is used to indicate that
// the error was because a context expired.
type contextErr struct {
err error
}
func (e contextErr) Error() string {
return e.err.Error()
}
func (e contextErr) Unwrap() error {
return e.err
}
// getConn returns a noiseconn.Conn that can be used to make requests to the
// coordination server. It may return a cached connection or create a new one.
// Dials are singleflighted, so concurrent calls to getConn may only dial once.
// As such, context values may not be respected as there are no guarantees that
// the context passed to getConn is the same as the context passed to dial.
func (nc *NoiseClient) getConn(ctx context.Context) (*noiseconn.Conn, error) {
nc.mu.Lock()
if last := nc.last; last != nil && last.CanTakeNewRequest() {
nc.mu.Unlock()
return last, nil
}
nc.mu.Unlock()
for {
// We singeflight the dial to avoid making multiple connections, however
// that means that we can't simply cancel the dial if the context is
// canceled. Instead, we have to additionally check that the context
// which was canceled is our context and retry if our context is still
// valid.
conn, err, _ := nc.sfDial.Do(struct{}{}, func() (*noiseconn.Conn, error) {
c, err := nc.dial(ctx)
if err != nil {
if ctx.Err() != nil {
return nil, contextErr{ctx.Err()}
}
return nil, err
}
return c, nil
})
var ce contextErr
if err == nil || !errors.As(err, &ce) {
return conn, err
}
if ctx.Err() == nil {
// The dial failed because of a context error, but our context
// is still valid. Retry.
continue
}
// The dial failed because our context was canceled. Return the
// underlying error.
return nil, ce.Unwrap()
}
}
func (nc *NoiseClient) RoundTrip(req *http.Request) (*http.Response, error) {
ctx := req.Context()
conn, err := nc.getConn(ctx)
if err != nil {
return nil, err
}
return conn.RoundTrip(req)
}
// connClosed removes the connection with the provided ID from the pool
// of active connections.
func (nc *NoiseClient) connClosed(id int) {
nc.mu.Lock()
defer nc.mu.Unlock()
conn := nc.connPool[id]
if conn != nil {
delete(nc.connPool, id)
if nc.last == conn {
nc.last = nil
}
}
}
// Close closes all the underlying noise connections.
// It is a no-op and returns nil if the connection is already closed.
func (nc *NoiseClient) Close() error {
nc.mu.Lock()
nc.closed = true
conns := nc.connPool
nc.connPool = nil
nc.mu.Unlock()
var errors []error
for _, c := range conns {
if err := c.Close(); err != nil {
errors = append(errors, err)
}
}
return multierr.New(errors...)
}
// dial opens a new connection to tailcontrol, fetching the server noise key
// if not cached.
func (nc *NoiseClient) dial(ctx context.Context) (*noiseconn.Conn, error) {
nc.mu.Lock()
connID := nc.nextID
nc.nextID++
nc.mu.Unlock()
if tailcfg.CurrentCapabilityVersion > math.MaxUint16 {
// Panic, because a test should have started failing several
// thousand version numbers before getting to this point.
panic("capability version is too high to fit in the wire protocol")
}
var dialPlan *tailcfg.ControlDialPlan
if nc.dialPlan != nil {
dialPlan = nc.dialPlan()
}
// If we have a dial plan, then set our timeout as slightly longer than
// the maximum amount of time contained therein; we assume that
// explicit instructions on timeouts are more useful than a single
// hard-coded timeout.
//
// The default value of 5 is chosen so that, when there's no dial plan,
// we retain the previous behaviour of 10 seconds end-to-end timeout.
timeoutSec := 5.0
if dialPlan != nil {
for _, c := range dialPlan.Candidates {
if v := c.DialStartDelaySec + c.DialTimeoutSec; v > timeoutSec {
timeoutSec = v
}
}
}
// After we establish a connection, we need some time to actually
// upgrade it into a Noise connection. With a ballpark worst-case RTT
// of 1000ms, give ourselves an extra 5 seconds to complete the
// handshake.
timeoutSec += 5
// Be extremely defensive and ensure that the timeout is in the range
// [5, 60] seconds (e.g. if we accidentally get a negative number).
if timeoutSec > 60 {
timeoutSec = 60
} else if timeoutSec < 5 {
timeoutSec = 5
}
timeout := time.Duration(timeoutSec * float64(time.Second))
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
clientConn, err := (&controlhttp.Dialer{
Hostname: nc.host,
HTTPPort: nc.httpPort,
HTTPSPort: cmp.Or(nc.httpsPort, controlhttp.NoPort),
MachineKey: nc.privKey,
ControlKey: nc.serverPubKey,
ProtocolVersion: uint16(tailcfg.CurrentCapabilityVersion),
Dialer: nc.dialer.SystemDial,
DNSCache: nc.dnsCache,
DialPlan: dialPlan,
Logf: nc.logf,
NetMon: nc.netMon,
HealthTracker: nc.health,
Clock: tstime.StdClock{},
}).Dial(ctx)
if err != nil {
return nil, err
}
ncc, err := noiseconn.New(clientConn.Conn, nc.h2t, connID, nc.connClosed)
if err != nil {
return nil, err
}
nc.mu.Lock()
if nc.closed {
nc.mu.Unlock()
ncc.Close() // Needs to be called without holding the lock.
return nil, errors.New("noise client closed")
}
defer nc.mu.Unlock()
mak.Set(&nc.connPool, connID, ncc)
nc.last = ncc
return ncc, nil
}
// post does a POST to the control server at the given path, JSON-encoding body.
// The provided nodeKey is an optional load balancing hint.
func (nc *NoiseClient) post(ctx context.Context, path string, nodeKey key.NodePublic, body any) (*http.Response, error) {
return nc.doWithBody(ctx, "POST", path, nodeKey, body)
}
func (nc *NoiseClient) doWithBody(ctx context.Context, method, path string, nodeKey key.NodePublic, body any) (*http.Response, error) {
jbody, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, "https://"+nc.host+path, bytes.NewReader(jbody))
if err != nil {
return nil, err
}
addLBHeader(req, nodeKey)
req.Header.Set("Content-Type", "application/json")
conn, err := nc.getConn(ctx)
if err != nil {
return nil, err
}
return conn.RoundTrip(req)
}

View File

@@ -18,7 +18,8 @@ import (
"github.com/tailscale/certstore"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/util/syspolicy"
"tailscale.com/util/syspolicy/pkey"
"tailscale.com/util/syspolicy/policyclient"
)
// getMachineCertificateSubject returns the exact name of a Subject that needs
@@ -30,8 +31,8 @@ import (
// each RegisterRequest will be unsigned.
//
// Example: "CN=Tailscale Inc Test Root CA,OU=Tailscale Inc Test Certificate Authority,O=Tailscale Inc,ST=ON,C=CA"
func getMachineCertificateSubject() string {
machineCertSubject, _ := syspolicy.GetString(syspolicy.MachineCertificateSubject, "")
func getMachineCertificateSubject(polc policyclient.Client) string {
machineCertSubject, _ := polc.GetString(pkey.MachineCertificateSubject, "")
return machineCertSubject
}
@@ -136,7 +137,7 @@ func findIdentity(subject string, st certstore.Store) (certstore.Identity, []*x5
// using that identity's public key. In addition to the signature, the full
// certificate chain is included so that the control server can validate the
// certificate from a copy of the root CA's certificate.
func signRegisterRequest(req *tailcfg.RegisterRequest, serverURL string, serverPubKey, machinePubKey key.MachinePublic) (err error) {
func signRegisterRequest(polc policyclient.Client, req *tailcfg.RegisterRequest, serverURL string, serverPubKey, machinePubKey key.MachinePublic) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("signRegisterRequest: %w", err)
@@ -147,7 +148,7 @@ func signRegisterRequest(req *tailcfg.RegisterRequest, serverURL string, serverP
return errBadRequest
}
machineCertificateSubject := getMachineCertificateSubject()
machineCertificateSubject := getMachineCertificateSubject(polc)
if machineCertificateSubject == "" {
return errCertificateNotConfigured
}

View File

@@ -8,9 +8,10 @@ package controlclient
import (
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/util/syspolicy/policyclient"
)
// signRegisterRequest on non-supported platforms always returns errNoCertStore.
func signRegisterRequest(req *tailcfg.RegisterRequest, serverURL string, serverPubKey, machinePubKey key.MachinePublic) error {
func signRegisterRequest(polc policyclient.Client, req *tailcfg.RegisterRequest, serverURL string, serverPubKey, machinePubKey key.MachinePublic) error {
return errNoCertStore
}

View File

@@ -4,8 +4,6 @@
package controlclient
import (
"encoding/json"
"fmt"
"reflect"
"tailscale.com/types/netmap"
@@ -13,57 +11,6 @@ import (
"tailscale.com/types/structs"
)
// State is the high-level state of the client. It is used only in
// unit tests for proper sequencing, don't depend on it anywhere else.
//
// TODO(apenwarr): eliminate the state, as it's now obsolete.
//
// apenwarr: Historical note: controlclient.Auto was originally
// intended to be the state machine for the whole tailscale client, but that
// turned out to not be the right abstraction layer, and it moved to
// ipn.Backend. Since ipn.Backend now has a state machine, it would be
// much better if controlclient could be a simple stateless API. But the
// current server-side API (two interlocking polling https calls) makes that
// very hard to implement. A server side API change could untangle this and
// remove all the statefulness.
type State int
const (
StateNew = State(iota)
StateNotAuthenticated
StateAuthenticating
StateURLVisitRequired
StateAuthenticated
StateSynchronized // connected and received map update
)
func (s State) AppendText(b []byte) ([]byte, error) {
return append(b, s.String()...), nil
}
func (s State) MarshalText() ([]byte, error) {
return []byte(s.String()), nil
}
func (s State) String() string {
switch s {
case StateNew:
return "state:new"
case StateNotAuthenticated:
return "state:not-authenticated"
case StateAuthenticating:
return "state:authenticating"
case StateURLVisitRequired:
return "state:url-visit-required"
case StateAuthenticated:
return "state:authenticated"
case StateSynchronized:
return "state:synchronized"
default:
return fmt.Sprintf("state:unknown:%d", int(s))
}
}
type Status struct {
_ structs.Incomparable
@@ -76,6 +23,14 @@ type Status struct {
// URL, if non-empty, is the interactive URL to visit to finish logging in.
URL string
// LoggedIn, if true, indicates that serveRegister has completed and no
// other login change is in progress.
LoggedIn bool
// InMapPoll, if true, indicates that we've received at least one netmap
// and are connected to receive updates.
InMapPoll bool
// NetMap is the latest server-pushed state of the tailnet network.
NetMap *netmap.NetworkMap
@@ -83,26 +38,8 @@ type Status struct {
//
// TODO(bradfitz,maisem): clarify this.
Persist persist.PersistView
// state is the internal state. It should not be exposed outside this
// package, but we have some automated tests elsewhere that need to
// use it via the StateForTest accessor.
// TODO(apenwarr): Unexport or remove these.
state State
}
// LoginFinished reports whether the controlclient is in its "StateAuthenticated"
// state where it's in a happy register state but not yet in a map poll.
//
// TODO(bradfitz): delete this and everything around Status.state.
func (s *Status) LoginFinished() bool { return s.state == StateAuthenticated }
// StateForTest returns the internal state of s for tests only.
func (s *Status) StateForTest() State { return s.state }
// SetStateForTest sets the internal state of s for tests only.
func (s *Status) SetStateForTest(state State) { s.state = state }
// Equal reports whether s and s2 are equal.
func (s *Status) Equal(s2 *Status) bool {
if s == nil && s2 == nil {
@@ -111,15 +48,8 @@ func (s *Status) Equal(s2 *Status) bool {
return s != nil && s2 != nil &&
s.Err == s2.Err &&
s.URL == s2.URL &&
s.state == s2.state &&
s.LoggedIn == s2.LoggedIn &&
s.InMapPoll == s2.InMapPoll &&
reflect.DeepEqual(s.Persist, s2.Persist) &&
reflect.DeepEqual(s.NetMap, s2.NetMap)
}
func (s Status) String() string {
b, err := json.MarshalIndent(s, "", "\t")
if err != nil {
panic(err)
}
return s.state.String() + " " + string(b)
}

View File

@@ -20,37 +20,37 @@
package controlhttp
import (
"cmp"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/http/httptrace"
"net/netip"
"net/url"
"runtime"
"sort"
"sync/atomic"
"time"
"tailscale.com/control/controlbase"
"tailscale.com/control/controlhttp/controlhttpcommon"
"tailscale.com/envknob"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/net/dnscache"
"tailscale.com/net/dnsfallback"
"tailscale.com/net/netutil"
"tailscale.com/net/netx"
"tailscale.com/net/sockstats"
"tailscale.com/net/tlsdial"
"tailscale.com/net/tshttpproxy"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/util/multierr"
)
var stdDialer net.Dialer
@@ -81,7 +81,7 @@ func (a *Dialer) getProxyFunc() func(*http.Request) (*url.URL, error) {
if a.proxyFunc != nil {
return a.proxyFunc
}
return tshttpproxy.ProxyFromEnvironment
return feature.HookProxyFromEnvironment.GetOrNil()
}
// httpsFallbackDelay is how long we'll wait for a.HTTPPort to work before
@@ -103,157 +103,71 @@ func (a *Dialer) dial(ctx context.Context) (*ClientConn, error) {
// host we know about.
useDialPlan := envknob.BoolDefaultTrue("TS_USE_CONTROL_DIAL_PLAN")
if !useDialPlan || a.DialPlan == nil || len(a.DialPlan.Candidates) == 0 {
return a.dialHost(ctx, netip.Addr{})
return a.dialHost(ctx)
}
candidates := a.DialPlan.Candidates
// Otherwise, we try dialing per the plan. Store the highest priority
// in the list, so that if we get a connection to one of those
// candidates we can return quickly.
var highestPriority int = math.MinInt
for _, c := range candidates {
if c.Priority > highestPriority {
highestPriority = c.Priority
}
}
// This context allows us to cancel in-flight connections if we get a
// highest-priority connection before we're all done.
// Create a context to be canceled as we return, so once we get a good connection,
// we can drop all the other ones.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Now, for each candidate, kick off a dial in parallel.
type dialResult struct {
conn *ClientConn
err error
addr netip.Addr
priority int
}
resultsCh := make(chan dialResult, len(candidates))
var pending atomic.Int32
pending.Store(int32(len(candidates)))
for _, c := range candidates {
go func(ctx context.Context, c tailcfg.ControlIPCandidate) {
var (
conn *ClientConn
err error
)
// Always send results back to our channel.
defer func() {
resultsCh <- dialResult{conn, err, c.IP, c.Priority}
if pending.Add(-1) == 0 {
close(resultsCh)
}
}()
// If non-zero, wait the configured start timeout
// before we do anything.
if c.DialStartDelaySec > 0 {
a.logf("[v2] controlhttp: waiting %.2f seconds before dialing %q @ %v", c.DialStartDelaySec, a.Hostname, c.IP)
tmr, tmrChannel := a.clock().NewTimer(time.Duration(c.DialStartDelaySec * float64(time.Second)))
defer tmr.Stop()
select {
case <-ctx.Done():
err = ctx.Err()
return
case <-tmrChannel:
}
}
// Now, create a sub-context with the given timeout and
// try dialing the provided host.
ctx, cancel := context.WithTimeout(ctx, time.Duration(c.DialTimeoutSec*float64(time.Second)))
defer cancel()
// This will dial, and the defer above sends it back to our parent.
a.logf("[v2] controlhttp: trying to dial %q @ %v", a.Hostname, c.IP)
conn, err = a.dialHost(ctx, c.IP)
}(ctx, c)
}
var results []dialResult
for res := range resultsCh {
// If we get a response that has the highest priority, we don't
// need to wait for any of the other connections to finish; we
// can just return this connection.
//
// TODO(andrew): we could make this better by keeping track of
// the highest remaining priority dynamically, instead of just
// checking for the highest total
if res.priority == highestPriority && res.conn != nil {
a.logf("[v1] controlhttp: high-priority success dialing %q @ %v from dial plan", a.Hostname, res.addr)
// Drain the channel and any existing connections in
// the background.
go func() {
for _, res := range results {
if res.conn != nil {
res.conn.Close()
}
}
for res := range resultsCh {
if res.conn != nil {
res.conn.Close()
}
}
if a.drainFinished != nil {
close(a.drainFinished)
}
}()
return res.conn, nil
}
// This isn't a highest-priority result, so just store it until
// we're done.
results = append(results, res)
}
// After we finish this function, close any remaining open connections.
defer func() {
for _, result := range results {
// Note: below, we nil out the returned connection (if
// any) in the slice so we don't close it.
if result.conn != nil {
result.conn.Close()
}
}
// We don't drain asynchronously after this point, so notify our
// channel when we return.
if a.drainFinished != nil {
close(a.drainFinished)
}
}()
// Sort by priority, then take the first non-error response.
sort.Slice(results, func(i, j int) bool {
// NOTE: intentionally inverted so that the highest priority
// item comes first
return results[i].priority > results[j].priority
})
var (
conn *ClientConn
errs []error
)
for i, result := range results {
if result.err != nil {
errs = append(errs, result.err)
continue
err error
}
resultsCh := make(chan dialResult) // unbuffered, never closed
dialCand := func(cand tailcfg.ControlIPCandidate) (*ClientConn, error) {
if cand.ACEHost != "" {
a.logf("[v2] controlhttp: waited %.2f seconds, dialing %q via ACE %s (%s)", cand.DialStartDelaySec, a.Hostname, cand.ACEHost, cmp.Or(cand.IP.String(), "dns"))
} else {
a.logf("[v2] controlhttp: waited %.2f seconds, dialing %q @ %s", cand.DialStartDelaySec, a.Hostname, cand.IP.String())
}
a.logf("[v1] controlhttp: succeeded dialing %q @ %v from dial plan", a.Hostname, result.addr)
conn = result.conn
results[i].conn = nil // so we don't close it in the defer
return conn, nil
ctx, cancel := context.WithTimeout(ctx, time.Duration(cand.DialTimeoutSec*float64(time.Second)))
defer cancel()
return a.dialHostOpt(ctx, cand.IP, cand.ACEHost)
}
merr := multierr.New(errs...)
// If we get here, then we didn't get anywhere with our dial plan; fall back to just using DNS.
a.logf("controlhttp: failed dialing using DialPlan, falling back to DNS; errs=%s", merr.Error())
return a.dialHost(ctx, netip.Addr{})
for _, cand := range candidates {
timer := time.AfterFunc(time.Duration(cand.DialStartDelaySec*float64(time.Second)), func() {
go func() {
conn, err := dialCand(cand)
select {
case resultsCh <- dialResult{conn, err}:
if err == nil {
a.logf("[v1] controlhttp: succeeded dialing %q @ %v from dial plan", a.Hostname, cmp.Or(cand.ACEHost, cand.IP.String()))
}
case <-ctx.Done():
if conn != nil {
conn.Close()
}
}
}()
})
defer timer.Stop()
}
var errs []error
for {
select {
case res := <-resultsCh:
if res.err == nil {
return res.conn, nil
}
errs = append(errs, res.err)
if len(errs) == len(candidates) {
// If we get here, then we didn't get anywhere with our dial plan; fall back to just using DNS.
a.logf("controlhttp: failed dialing using DialPlan, falling back to DNS; errs=%s", errors.Join(errs...))
return a.dialHost(ctx)
}
case <-ctx.Done():
a.logf("controlhttp: context aborted dialing")
return nil, ctx.Err()
}
}
}
// The TS_FORCE_NOISE_443 envknob forces the controlclient noise dialer to
@@ -270,6 +184,15 @@ var forceNoise443 = envknob.RegisterBool("TS_FORCE_NOISE_443")
// use HTTPS connections as its underlay connection (double crypto). This can
// be necessary when networks or middle boxes are messing with port 80.
func (d *Dialer) forceNoise443() bool {
if runtime.GOOS == "plan9" {
// For running demos of Plan 9 in a browser with network relays,
// we want to minimize the number of connections we're making.
// The main reason to use port 80 is to avoid double crypto
// costs server-side but the costs are tiny and number of Plan 9
// users doesn't make it worth it. Just disable this and always use
// HTTPS for Plan 9. That also reduces some log spam.
return true
}
if forceNoise443() {
return true
}
@@ -301,10 +224,19 @@ var debugNoiseDial = envknob.RegisterBool("TS_DEBUG_NOISE_DIAL")
// dialHost connects to the configured Dialer.Hostname and upgrades the
// connection into a controlbase.Conn.
func (a *Dialer) dialHost(ctx context.Context) (*ClientConn, error) {
return a.dialHostOpt(ctx,
netip.Addr{}, // no pre-resolved IP
"", // don't use ACE
)
}
// dialHostOpt connects to the configured Dialer.Hostname and upgrades the
// connection into a controlbase.Conn.
//
// If optAddr is valid, then no DNS is used and the connection will be made to the
// provided address.
func (a *Dialer) dialHost(ctx context.Context, optAddr netip.Addr) (*ClientConn, error) {
func (a *Dialer) dialHostOpt(ctx context.Context, optAddr netip.Addr, optACEHost string) (*ClientConn, error) {
// Create one shared context used by both port 80 and port 443 dials.
// If port 80 is still in flight when 443 returns, this deferred cancel
// will stop the port 80 dial.
@@ -326,7 +258,7 @@ func (a *Dialer) dialHost(ctx context.Context, optAddr netip.Addr) (*ClientConn,
Host: net.JoinHostPort(a.Hostname, strDef(a.HTTPSPort, "443")),
Path: serverUpgradePath,
}
if a.HTTPSPort == NoPort {
if a.HTTPSPort == NoPort || optACEHost != "" {
u443 = nil
}
@@ -338,11 +270,11 @@ func (a *Dialer) dialHost(ctx context.Context, optAddr netip.Addr) (*ClientConn,
ch := make(chan tryURLRes) // must be unbuffered
try := func(u *url.URL) {
if debugNoiseDial() {
a.logf("trying noise dial (%v, %v) ...", u, optAddr)
a.logf("trying noise dial (%v, %v) ...", u, cmp.Or(optACEHost, optAddr.String()))
}
cbConn, err := a.dialURL(ctx, u, optAddr)
cbConn, err := a.dialURL(ctx, u, optAddr, optACEHost)
if debugNoiseDial() {
a.logf("noise dial (%v, %v) = (%v, %v)", u, optAddr, cbConn, err)
a.logf("noise dial (%v, %v) = (%v, %v)", u, cmp.Or(optACEHost, optAddr.String()), cbConn, err)
}
select {
case ch <- tryURLRes{u, cbConn, err}:
@@ -373,6 +305,9 @@ func (a *Dialer) dialHost(ctx context.Context, optAddr netip.Addr) (*ClientConn,
}
var err80, err443 error
if forceTLS {
err80 = errors.New("TLS forced: no port 80 dialed")
}
for {
select {
case <-ctx.Done():
@@ -408,12 +343,12 @@ func (a *Dialer) dialHost(ctx context.Context, optAddr netip.Addr) (*ClientConn,
//
// If optAddr is valid, then no DNS is used and the connection will be made to the
// provided address.
func (a *Dialer) dialURL(ctx context.Context, u *url.URL, optAddr netip.Addr) (*ClientConn, error) {
func (a *Dialer) dialURL(ctx context.Context, u *url.URL, optAddr netip.Addr, optACEHost string) (*ClientConn, error) {
init, cont, err := controlbase.ClientDeferred(a.MachineKey, a.ControlKey, a.ProtocolVersion)
if err != nil {
return nil, err
}
netConn, err := a.tryURLUpgrade(ctx, u, optAddr, init)
netConn, err := a.tryURLUpgrade(ctx, u, optAddr, optACEHost, init)
if err != nil {
return nil, err
}
@@ -459,13 +394,15 @@ var macOSScreenTime = health.Register(&health.Warnable{
ImpactsConnectivity: true,
})
var HookMakeACEDialer feature.Hook[func(dialer netx.DialFunc, aceHost string, optIP netip.Addr) netx.DialFunc]
// tryURLUpgrade connects to u, and tries to upgrade it to a net.Conn.
//
// If optAddr is valid, then no DNS is used and the connection will be made to
// the provided address.
//
// Only the provided ctx is used, not a.ctx.
func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, optAddr netip.Addr, init []byte) (_ net.Conn, retErr error) {
func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, optAddr netip.Addr, optACEHost string, init []byte) (_ net.Conn, retErr error) {
var dns *dnscache.Resolver
// If we were provided an address to dial, then create a resolver that just
@@ -480,13 +417,24 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, optAddr netip.Ad
dns = a.resolver()
}
var dialer dnscache.DialContextFunc
var dialer netx.DialFunc
if a.Dialer != nil {
dialer = a.Dialer
} else {
dialer = stdDialer.DialContext
}
if optACEHost != "" {
if !buildfeatures.HasACE {
return nil, feature.ErrUnavailable
}
f, ok := HookMakeACEDialer.GetOk()
if !ok {
return nil, feature.ErrUnavailable
}
dialer = f(dialer, optACEHost, optAddr)
}
// On macOS, see if Screen Time is blocking things.
if runtime.GOOS == "darwin" {
var proxydIntercepted atomic.Bool // intercepted by macOS webfilterproxyd
@@ -513,13 +461,25 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, optAddr netip.Ad
tr := http.DefaultTransport.(*http.Transport).Clone()
defer tr.CloseIdleConnections()
tr.Proxy = a.getProxyFunc()
tshttpproxy.SetTransportGetProxyConnectHeader(tr)
tr.DialContext = dnscache.Dialer(dialer, dns)
if optACEHost != "" {
// If using ACE, we don't want to use any HTTP proxy.
// ACE is already a tunnel+proxy.
// TODO(tailscale/corp#32483): use system proxy too?
tr.Proxy = nil
tr.DialContext = dialer
} else {
if buildfeatures.HasUseProxy {
tr.Proxy = a.getProxyFunc()
if set, ok := feature.HookProxySetTransportGetProxyConnectHeader.GetOk(); ok {
set(tr)
}
}
tr.DialContext = dnscache.Dialer(dialer, dns)
}
// Disable HTTP2, since h2 can't do protocol switching.
tr.TLSClientConfig.NextProtos = []string{}
tr.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
tr.TLSClientConfig = tlsdial.Config(a.Hostname, a.HealthTracker, tr.TLSClientConfig)
tr.TLSClientConfig = tlsdial.Config(a.HealthTracker, tr.TLSClientConfig)
if !tr.TLSClientConfig.InsecureSkipVerify {
panic("unexpected") // should be set by tlsdial.Config
}

View File

@@ -12,6 +12,7 @@ import (
"tailscale.com/health"
"tailscale.com/net/dnscache"
"tailscale.com/net/netmon"
"tailscale.com/net/netx"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
@@ -66,7 +67,7 @@ type Dialer struct {
// Dialer is the dialer used to make outbound connections.
//
// If not specified, this defaults to net.Dialer.DialContext.
Dialer dnscache.DialContextFunc
Dialer netx.DialFunc
// DNSCache is the caching Resolver used by this Dialer.
//
@@ -77,8 +78,8 @@ type Dialer struct {
// dropped.
Logf logger.Logf
// NetMon is the [netmon.Monitor] to use for this Dialer. It must be
// non-nil.
// NetMon is the [netmon.Monitor] to use for this Dialer.
// It is optional.
NetMon *netmon.Monitor
// HealthTracker, if non-nil, is the health tracker to use.
@@ -97,7 +98,6 @@ type Dialer struct {
logPort80Failure atomic.Bool
// For tests only
drainFinished chan struct{}
omitCertErrorLogging bool
testFallbackDelay time.Duration

View File

@@ -62,8 +62,9 @@ type Knobs struct {
// netfiltering, unless overridden by the user.
LinuxForceNfTables atomic.Bool
// SeamlessKeyRenewal is whether to enable the alpha functionality of
// renewing node keys without breaking connections.
// 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
@@ -98,10 +99,6 @@ type Knobs struct {
// allows us to disable the new behavior remotely if needed.
DisableLocalDNSOverrideViaNRPT atomic.Bool
// DisableCryptorouting indicates that the node should not use the
// magicsock crypto routing feature.
DisableCryptorouting atomic.Bool
// DisableCaptivePortalDetection is whether the node should not perform captive portal detection
// automatically when the network state changes.
DisableCaptivePortalDetection atomic.Bool
@@ -132,12 +129,12 @@ func (k *Knobs) UpdateFromNodeAttributes(capMap tailcfg.NodeCapMap) {
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)
disableCryptorouting = has(tailcfg.NodeAttrDisableMagicSockCryptoRouting)
disableCaptivePortalDetection = has(tailcfg.NodeAttrDisableCaptivePortalDetection)
disableSkipStatusQueue = has(tailcfg.NodeAttrDisableSkipStatusQueue)
)
@@ -159,15 +156,28 @@ func (k *Knobs) UpdateFromNodeAttributes(capMap tailcfg.NodeCapMap) {
k.SilentDisco.Store(silentDisco)
k.LinuxForceIPTables.Store(forceIPTables)
k.LinuxForceNfTables.Store(forceNfTables)
k.SeamlessKeyRenewal.Store(seamlessKeyRenewal)
k.ProbeUDPLifetime.Store(probeUDPLifetime)
k.AppCStoreRoutes.Store(appCStoreRoutes)
k.UserDialUseRoutes.Store(userDialUseRoutes)
k.DisableSplitDNSWhenNoCustomResolvers.Store(disableSplitDNSWhenNoCustomResolvers)
k.DisableLocalDNSOverrideViaNRPT.Store(disableLocalDNSOverrideViaNRPT)
k.DisableCryptorouting.Store(disableCryptorouting)
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)
}
// AsDebugJSON returns k as something that can be marshalled with json.Marshal

312
vendor/tailscale.com/control/ts2021/client.go generated vendored Normal file
View File

@@ -0,0 +1,312 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ts2021
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"net"
"net/http"
"net/netip"
"net/url"
"sync"
"time"
"tailscale.com/control/controlhttp"
"tailscale.com/health"
"tailscale.com/net/dnscache"
"tailscale.com/net/netmon"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
"tailscale.com/util/set"
)
// Client provides a http.Client to connect to tailcontrol over
// the ts2021 protocol.
type Client struct {
// Client is an HTTP client to talk to the coordination server.
// It automatically makes a new Noise connection as needed.
*http.Client
logf logger.Logf // non-nil
opts ClientOpts
host string // the host part of serverURL
httpPort string // the default port to dial
httpsPort string // the fallback Noise-over-https port or empty if none
// mu protects the following
mu sync.Mutex
closed bool
connPool set.HandleSet[*Conn] // all live connections
}
// ClientOpts contains options for the [NewClient] function. All fields are
// required unless otherwise specified.
type ClientOpts struct {
// ServerURL is the URL of the server to connect to.
ServerURL string
// PrivKey is this node's private key.
PrivKey key.MachinePrivate
// ServerPubKey is the public key of the server.
// It is of the form https://<host>:<port> (no trailing slash).
ServerPubKey key.MachinePublic
// Dialer's SystemDial function is used to connect to the server.
Dialer *tsdial.Dialer
// Optional fields follow
// Logf is the log function to use.
// If nil, log.Printf is used.
Logf logger.Logf
// NetMon is the network monitor that will be used to get the
// network interface state. This field can be nil; if so, the current
// state will be looked up dynamically.
NetMon *netmon.Monitor
// DNSCache is the caching Resolver to use to connect to the server.
//
// This field can be nil.
DNSCache *dnscache.Resolver
// HealthTracker, if non-nil, is the health tracker to use.
HealthTracker *health.Tracker
// DialPlan, if set, is a function that should return an explicit plan
// on how to connect to the server.
DialPlan func() *tailcfg.ControlDialPlan
// ProtocolVersion, if non-zero, specifies an alternate
// protocol version to use instead of the default,
// of [tailcfg.CurrentCapabilityVersion].
ProtocolVersion uint16
}
// NewClient returns a new noiseClient for the provided server and machine key.
//
// netMon may be nil, if non-nil it's used to do faster interface lookups.
// dialPlan may be nil
func NewClient(opts ClientOpts) (*Client, error) {
logf := opts.Logf
if logf == nil {
logf = log.Printf
}
if opts.ServerURL == "" {
return nil, errors.New("ServerURL is required")
}
if opts.PrivKey.IsZero() {
return nil, errors.New("PrivKey is required")
}
if opts.ServerPubKey.IsZero() {
return nil, errors.New("ServerPubKey is required")
}
if opts.Dialer == nil {
return nil, errors.New("Dialer is required")
}
u, err := url.Parse(opts.ServerURL)
if err != nil {
return nil, fmt.Errorf("invalid ClientOpts.ServerURL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, errors.New("invalid ServerURL scheme, must be http or https")
}
httpPort, httpsPort := "80", "443"
addr, _ := netip.ParseAddr(u.Hostname())
isPrivateHost := addr.IsPrivate() || addr.IsLoopback() || u.Hostname() == "localhost"
if port := u.Port(); port != "" {
// If there is an explicit port specified, entirely rely on the scheme,
// unless it's http with a private host in which case we never try using HTTPS.
if u.Scheme == "https" {
httpPort = ""
httpsPort = port
} else if u.Scheme == "http" {
httpPort = port
httpsPort = "443"
if isPrivateHost {
logf("setting empty HTTPS port with http scheme and private host %s", u.Hostname())
httpsPort = ""
}
}
} else if u.Scheme == "http" && isPrivateHost {
// Whenever the scheme is http and the hostname is an IP address, do not set the HTTPS port,
// as there cannot be a TLS certificate issued for an IP, unless it's a public IP.
httpPort = "80"
httpsPort = ""
}
np := &Client{
opts: opts,
host: u.Hostname(),
httpPort: httpPort,
httpsPort: httpsPort,
logf: logf,
}
tr := &http.Transport{
Protocols: new(http.Protocols),
MaxConnsPerHost: 1,
}
// We force only HTTP/2 for this transport, which is what the control server
// speaks inside the ts2021 Noise encryption. But Go doesn't know about that,
// so we use "SetUnencryptedHTTP2" even though it's actually encrypted.
tr.Protocols.SetUnencryptedHTTP2(true)
tr.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return np.dial(ctx)
}
np.Client = &http.Client{Transport: tr}
return np, nil
}
// Close closes all the underlying noise connections.
// It is a no-op and returns nil if the connection is already closed.
func (nc *Client) Close() error {
nc.mu.Lock()
live := nc.connPool
nc.closed = true
nc.connPool = nil // stop noteConnClosed from mutating it as we loop over it (in live) below
nc.mu.Unlock()
for _, c := range live {
c.Close()
}
nc.Client.CloseIdleConnections()
return nil
}
// dial opens a new connection to tailcontrol, fetching the server noise key
// if not cached.
func (nc *Client) dial(ctx context.Context) (*Conn, error) {
if tailcfg.CurrentCapabilityVersion > math.MaxUint16 {
// Panic, because a test should have started failing several
// thousand version numbers before getting to this point.
panic("capability version is too high to fit in the wire protocol")
}
var dialPlan *tailcfg.ControlDialPlan
if nc.opts.DialPlan != nil {
dialPlan = nc.opts.DialPlan()
}
// If we have a dial plan, then set our timeout as slightly longer than
// the maximum amount of time contained therein; we assume that
// explicit instructions on timeouts are more useful than a single
// hard-coded timeout.
//
// The default value of 5 is chosen so that, when there's no dial plan,
// we retain the previous behaviour of 10 seconds end-to-end timeout.
timeoutSec := 5.0
if dialPlan != nil {
for _, c := range dialPlan.Candidates {
if v := c.DialStartDelaySec + c.DialTimeoutSec; v > timeoutSec {
timeoutSec = v
}
}
}
// After we establish a connection, we need some time to actually
// upgrade it into a Noise connection. With a ballpark worst-case RTT
// of 1000ms, give ourselves an extra 5 seconds to complete the
// handshake.
timeoutSec += 5
// Be extremely defensive and ensure that the timeout is in the range
// [5, 60] seconds (e.g. if we accidentally get a negative number).
if timeoutSec > 60 {
timeoutSec = 60
} else if timeoutSec < 5 {
timeoutSec = 5
}
timeout := time.Duration(timeoutSec * float64(time.Second))
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
chd := &controlhttp.Dialer{
Hostname: nc.host,
HTTPPort: nc.httpPort,
HTTPSPort: cmp.Or(nc.httpsPort, controlhttp.NoPort),
MachineKey: nc.opts.PrivKey,
ControlKey: nc.opts.ServerPubKey,
ProtocolVersion: cmp.Or(nc.opts.ProtocolVersion, uint16(tailcfg.CurrentCapabilityVersion)),
Dialer: nc.opts.Dialer.SystemDial,
DNSCache: nc.opts.DNSCache,
DialPlan: dialPlan,
Logf: nc.logf,
NetMon: nc.opts.NetMon,
HealthTracker: nc.opts.HealthTracker,
Clock: tstime.StdClock{},
}
clientConn, err := chd.Dial(ctx)
if err != nil {
return nil, err
}
nc.mu.Lock()
handle := set.NewHandle()
ncc := NewConn(clientConn.Conn, func() { nc.noteConnClosed(handle) })
mak.Set(&nc.connPool, handle, ncc)
if nc.closed {
nc.mu.Unlock()
ncc.Close() // Needs to be called without holding the lock.
return nil, errors.New("noise client closed")
}
defer nc.mu.Unlock()
return ncc, nil
}
// noteConnClosed notes that the *Conn with the given handle has closed and
// should be removed from the live connPool (which is usually of size 0 or 1,
// except perhaps briefly 2 during a network failure and reconnect).
func (nc *Client) noteConnClosed(handle set.Handle) {
nc.mu.Lock()
defer nc.mu.Unlock()
nc.connPool.Delete(handle)
}
// post does a POST to the control server at the given path, JSON-encoding body.
// The provided nodeKey is an optional load balancing hint.
func (nc *Client) Post(ctx context.Context, path string, nodeKey key.NodePublic, body any) (*http.Response, error) {
return nc.DoWithBody(ctx, "POST", path, nodeKey, body)
}
func (nc *Client) DoWithBody(ctx context.Context, method, path string, nodeKey key.NodePublic, body any) (*http.Response, error) {
jbody, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, "https://"+nc.host+path, bytes.NewReader(jbody))
if err != nil {
return nil, err
}
AddLBHeader(req, nodeKey)
req.Header.Set("Content-Type", "application/json")
return nc.Do(req)
}
// AddLBHeader adds the load balancer header to req if nodeKey is non-zero.
func AddLBHeader(req *http.Request, nodeKey key.NodePublic) {
if !nodeKey.IsZero() {
req.Header.Add(tailcfg.LBHeader, nodeKey.String())
}
}

View File

@@ -1,12 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package noiseconn contains an internal-only wrapper around controlbase.Conn
// that properly handles the early payload sent by the server before the HTTP/2
// session begins.
//
// See the documentation on the Conn type for more details.
package noiseconn
// Package ts2021 handles the details of the Tailscale 2021 control protocol
// that are after (above) the Noise layer. In particular, the
// "tailcfg.EarlyNoise" message and the subsequent HTTP/2 connection.
package ts2021
import (
"bytes"
@@ -15,10 +13,8 @@ import (
"encoding/json"
"errors"
"io"
"net/http"
"sync"
"golang.org/x/net/http2"
"tailscale.com/control/controlbase"
"tailscale.com/tailcfg"
)
@@ -29,12 +25,13 @@ import (
// the pool when the connection is closed, properly handles an optional "early
// payload" that's sent prior to beginning the HTTP/2 session, and provides a
// way to return a connection to a pool when the connection is closed.
//
// Use [NewConn] to build a new Conn if you want [Conn.GetEarlyPayload] to work.
// Otherwise making a Conn directly, only setting Conn, is fine.
type Conn struct {
*controlbase.Conn
id int
onClose func(int)
h2cc *http2.ClientConn
onClose func() // or nil
readHeaderOnce sync.Once // guards init of reader field
reader io.Reader // (effectively Conn.Reader after header)
earlyPayloadReady chan struct{} // closed after earlyPayload is set (including set to nil)
@@ -42,31 +39,19 @@ type Conn struct {
earlyPayloadErr error
}
// New creates a new Conn that wraps the given controlbase.Conn.
// NewConn creates a new Conn that wraps the given controlbase.Conn.
//
// h2t is the HTTP/2 transport to use for the connection; a new
// http2.ClientConn will be created that reads from the returned Conn.
//
// connID should be a unique ID for this connection. When the Conn is closed,
// the onClose function will be called with the connID if it is non-nil.
func New(conn *controlbase.Conn, h2t *http2.Transport, connID int, onClose func(int)) (*Conn, error) {
ncc := &Conn{
// the onClose function will be called if it is non-nil.
func NewConn(conn *controlbase.Conn, onClose func()) *Conn {
return &Conn{
Conn: conn,
id: connID,
onClose: onClose,
earlyPayloadReady: make(chan struct{}),
onClose: sync.OnceFunc(onClose),
}
h2cc, err := h2t.NewClientConn(ncc)
if err != nil {
return nil, err
}
ncc.h2cc = h2cc
return ncc, nil
}
// RoundTrip implements the http.RoundTripper interface.
func (c *Conn) RoundTrip(r *http.Request) (*http.Response, error) {
return c.h2cc.RoundTrip(r)
}
// GetEarlyPayload waits for the early Noise payload to arrive.
@@ -76,6 +61,15 @@ func (c *Conn) RoundTrip(r *http.Request) (*http.Response, error) {
// early Noise payload is ready (if any) and will return the same result for
// the lifetime of the Conn.
func (c *Conn) GetEarlyPayload(ctx context.Context) (*tailcfg.EarlyNoise, error) {
if c.earlyPayloadReady == nil {
return nil, errors.New("Conn was not created with NewConn; early payload not supported")
}
select {
case <-c.earlyPayloadReady:
return c.earlyPayload, c.earlyPayloadErr
default:
go c.readHeaderOnce.Do(c.readHeader)
}
select {
case <-c.earlyPayloadReady:
return c.earlyPayload, c.earlyPayloadErr
@@ -84,28 +78,6 @@ func (c *Conn) GetEarlyPayload(ctx context.Context) (*tailcfg.EarlyNoise, error)
}
}
// ReserveNewRequest will reserve a new concurrent request on the connection.
//
// It returns whether the reservation was successful, and any early Noise
// payload if present. If a reservation was not successful, it will return
// false and nil for the early payload.
func (c *Conn) ReserveNewRequest(ctx context.Context) (bool, *tailcfg.EarlyNoise, error) {
earlyPayloadMaybeNil, err := c.GetEarlyPayload(ctx)
if err != nil {
return false, nil, err
}
if c.h2cc.ReserveNewRequest() {
return true, earlyPayloadMaybeNil, nil
}
return false, nil, nil
}
// CanTakeNewRequest reports whether the underlying HTTP/2 connection can take
// a new request, meaning it has not been closed or received or sent a GOAWAY.
func (c *Conn) CanTakeNewRequest() bool {
return c.h2cc.CanTakeNewRequest()
}
// The first 9 bytes from the server to client over Noise are either an HTTP/2
// settings frame (a normal HTTP/2 setup) or, as we added later, an "early payload"
// header that's also 9 bytes long: 5 bytes (EarlyPayloadMagic) followed by 4 bytes
@@ -133,6 +105,14 @@ func (c *Conn) Read(p []byte) (n int, err error) {
return c.reader.Read(p)
}
// Close closes the connection.
func (c *Conn) Close() error {
if c.onClose != nil {
defer c.onClose()
}
return c.Conn.Close()
}
// readHeader reads the optional "early payload" from the server that arrives
// after the Noise handshake but before the HTTP/2 session begins.
//
@@ -140,7 +120,9 @@ func (c *Conn) Read(p []byte) (n int, err error) {
// c.earlyPayload, closing c.earlyPayloadReady, and initializing c.reader for
// future reads.
func (c *Conn) readHeader() {
defer close(c.earlyPayloadReady)
if c.earlyPayloadReady != nil {
defer close(c.earlyPayloadReady)
}
setErr := func(err error) {
c.reader = returnErrReader{err}
@@ -174,14 +156,3 @@ func (c *Conn) readHeader() {
}
c.reader = c.Conn
}
// Close closes the connection.
func (c *Conn) Close() error {
if err := c.Conn.Close(); err != nil {
return err
}
if c.onClose != nil {
c.onClose(c.id)
}
return nil
}

111
vendor/tailscale.com/derp/derp.go generated vendored
View File

@@ -27,27 +27,31 @@ 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
const Magic = "DERP🔑" // 8 bytes: 0x44 45 52 50 f0 9f 94 91
const (
nonceLen = 24
frameHeaderLen = 1 + 4 // frameType byte + 4 byte length
keyLen = 32
maxInfoLen = 1 << 20
keepAlive = 60 * time.Second
NonceLen = 24
FrameHeaderLen = 1 + 4 // frameType byte + 4 byte length
KeyLen = 32
MaxInfoLen = 1 << 20
)
// KeepAlive is the minimum frequency at which the DERP server sends
// keep alive frames. The server adds some jitter, so this timing is not
// exact, but 2x this value can be considered a missed keep alive.
const KeepAlive = 60 * time.Second
// ProtocolVersion is bumped whenever there's a wire-incompatible change.
// - version 1 (zero on wire): consistent box headers, in use by employee dev nodes a bit
// - version 2: received packets have src addrs in frameRecvPacket at beginning
const ProtocolVersion = 2
// frameType is the one byte frame type at the beginning of the frame
// FrameType is the one byte frame type at the beginning of the frame
// header. The second field is a big-endian uint32 describing the
// length of the remaining frame (not including the initial 5 bytes).
type frameType byte
type FrameType byte
/*
Protocol flow:
@@ -65,14 +69,14 @@ Steady state:
* server then sends frameRecvPacket to recipient
*/
const (
frameServerKey = frameType(0x01) // 8B magic + 32B public key + (0+ bytes future use)
frameClientInfo = frameType(0x02) // 32B pub key + 24B nonce + naclbox(json)
frameServerInfo = frameType(0x03) // 24B nonce + naclbox(json)
frameSendPacket = frameType(0x04) // 32B dest pub key + packet bytes
frameForwardPacket = frameType(0x0a) // 32B src pub key + 32B dst pub key + packet bytes
frameRecvPacket = frameType(0x05) // v0/1: packet bytes, v2: 32B src pub key + packet bytes
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
FrameServerKey = FrameType(0x01) // 8B magic + 32B public key + (0+ bytes future use)
FrameClientInfo = FrameType(0x02) // 32B pub key + 24B nonce + naclbox(json)
FrameServerInfo = FrameType(0x03) // 24B nonce + naclbox(json)
FrameSendPacket = FrameType(0x04) // 32B dest pub key + packet bytes
FrameForwardPacket = FrameType(0x0a) // 32B src pub key + 32B dst pub key + packet bytes
FrameRecvPacket = FrameType(0x05) // v0/1: packet bytes, v2: 32B src pub key + packet bytes
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
// a previous sender is no longer connected. That is, if A
@@ -81,7 +85,7 @@ const (
// 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
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
// region when they're meshed up together.
@@ -92,7 +96,7 @@ const (
// 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.
framePeerPresent = frameType(0x09)
FramePeerPresent = FrameType(0x09)
// frameWatchConns is how one DERP node in a regional mesh
// subscribes to the others in the region.
@@ -100,30 +104,30 @@ const (
// 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.
frameWatchConns = frameType(0x10)
FrameWatchConns = FrameType(0x10)
// 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)
frameClosePeer = frameType(0x11) // 32B pub key of peer to close.
FrameClosePeer = FrameType(0x11) // 32B pub key of peer to close.
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
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
// 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)
FrameHealth = FrameType(0x14)
// 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
// more details on how the client should interpret them.
frameRestarting = frameType(0x15)
FrameRestarting = FrameType(0x15)
)
// PeerGoneReasonType is a one byte reason code explaining why a
@@ -150,6 +154,18 @@ const (
PeerPresentNotIdeal = 1 << 3 // client said derp server is not its Region.Nodes[0] ideal node
)
// IdealNodeHeader is the HTTP request header sent on DERP HTTP client requests
// to indicate that they're connecting to their ideal (Region.Nodes[0]) node.
// The HTTP header value is the name of the node they wish they were connected
// to. This is an optional header.
const IdealNodeHeader = "Ideal-Node"
// FastStartHeader is the header (with value "1") that signals to the HTTP
// server that the DERP HTTP client does not want the HTTP 101 response
// headers and it will begin writing & reading the DERP protocol immediately
// following its HTTP request.
const FastStartHeader = "Derp-Fast-Start"
var bin = binary.BigEndian
func writeUint32(bw *bufio.Writer, v uint32) error {
@@ -182,15 +198,24 @@ func readUint32(br *bufio.Reader) (uint32, error) {
return bin.Uint32(b[:]), nil
}
func readFrameTypeHeader(br *bufio.Reader, wantType frameType) (frameLen uint32, err error) {
gotType, frameLen, err := readFrameHeader(br)
// ReadFrameTypeHeader reads a frame header from br and
// verifies that the frame type matches wantType.
//
// If it does, it returns the frame length (not including
// the 5 byte header) and a nil error.
//
// If it doesn't, it returns an error and a zero length.
func ReadFrameTypeHeader(br *bufio.Reader, wantType FrameType) (frameLen uint32, err error) {
gotType, frameLen, err := ReadFrameHeader(br)
if err == nil && wantType != gotType {
err = fmt.Errorf("bad frame type 0x%X, want 0x%X", gotType, wantType)
}
return frameLen, err
}
func readFrameHeader(br *bufio.Reader) (t frameType, frameLen uint32, err error) {
// ReadFrameHeader reads the header of a DERP frame,
// reading 5 bytes from br.
func ReadFrameHeader(br *bufio.Reader) (t FrameType, frameLen uint32, err error) {
tb, err := br.ReadByte()
if err != nil {
return 0, 0, err
@@ -199,7 +224,7 @@ func readFrameHeader(br *bufio.Reader) (t frameType, frameLen uint32, err error)
if err != nil {
return 0, 0, err
}
return frameType(tb), frameLen, nil
return FrameType(tb), frameLen, nil
}
// readFrame reads a frame header and then reads its payload into
@@ -212,8 +237,8 @@ func readFrameHeader(br *bufio.Reader) (t frameType, frameLen uint32, err error)
// bytes are read, err will be io.ErrShortBuffer, and frameLen and t
// will both be set. That is, callers need to explicitly handle when
// they get more data than expected.
func readFrame(br *bufio.Reader, maxSize uint32, b []byte) (t frameType, frameLen uint32, err error) {
t, frameLen, err = readFrameHeader(br)
func readFrame(br *bufio.Reader, maxSize uint32, b []byte) (t FrameType, frameLen uint32, err error) {
t, frameLen, err = ReadFrameHeader(br)
if err != nil {
return 0, 0, err
}
@@ -235,19 +260,26 @@ func readFrame(br *bufio.Reader, maxSize uint32, b []byte) (t frameType, frameLe
return t, frameLen, err
}
func writeFrameHeader(bw *bufio.Writer, t frameType, frameLen uint32) error {
// 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).
//
// It does not flush bw.
func WriteFrameHeader(bw *bufio.Writer, t FrameType, frameLen uint32) error {
if err := bw.WriteByte(byte(t)); err != nil {
return err
}
return writeUint32(bw, frameLen)
}
// writeFrame writes a complete frame & flushes it.
func writeFrame(bw *bufio.Writer, t frameType, b []byte) error {
// WriteFrame writes a complete frame & flushes it.
func WriteFrame(bw *bufio.Writer, t FrameType, b []byte) error {
if len(b) > 10<<20 {
return errors.New("unreasonably large frame write")
}
if err := writeFrameHeader(bw, t, uint32(len(b))); err != nil {
if err := WriteFrameHeader(bw, t, uint32(len(b))); err != nil {
return err
}
if _, err := bw.Write(b); err != nil {
@@ -266,3 +298,12 @@ type Conn interface {
SetReadDeadline(time.Time) error
SetWriteDeadline(time.Time) error
}
// ServerInfo is the message sent from the server to clients during
// the connection setup.
type ServerInfo struct {
Version int `json:"version,omitempty"`
TokenBucketBytesPerSecond int `json:",omitempty"`
TokenBucketBytesBurst int `json:",omitempty"`
}

View File

@@ -30,7 +30,7 @@ type Client struct {
logf logger.Logf
nc Conn
br *bufio.Reader
meshKey string
meshKey key.DERPMesh
canAckPings bool
isProber bool
@@ -56,7 +56,7 @@ func (f clientOptFunc) update(o *clientOpt) { f(o) }
// clientOpt are the options passed to newClient.
type clientOpt struct {
MeshKey string
MeshKey key.DERPMesh
ServerPub key.NodePublic
CanAckPings bool
IsProber bool
@@ -66,7 +66,7 @@ type clientOpt struct {
// access to join the mesh.
//
// An empty key means to not use a mesh key.
func MeshKey(key string) ClientOpt { return clientOptFunc(func(o *clientOpt) { o.MeshKey = key }) }
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.
@@ -133,17 +133,17 @@ func (c *Client) recvServerKey() error {
if err != nil {
return err
}
if flen < uint32(len(buf)) || t != frameServerKey || string(buf[:len(magic)]) != magic {
if flen < uint32(len(buf)) || t != FrameServerKey || string(buf[:len(Magic)]) != Magic {
return errors.New("invalid server greeting")
}
c.serverKey = key.NodePublicFromRaw32(mem.B(buf[len(magic):]))
c.serverKey = key.NodePublicFromRaw32(mem.B(buf[len(Magic):]))
return nil
}
func (c *Client) parseServerInfo(b []byte) (*serverInfo, error) {
const maxLength = nonceLen + maxInfoLen
func (c *Client) parseServerInfo(b []byte) (*ServerInfo, error) {
const maxLength = NonceLen + MaxInfoLen
fl := len(b)
if fl < nonceLen {
if fl < NonceLen {
return nil, fmt.Errorf("short serverInfo frame")
}
if fl > maxLength {
@@ -153,19 +153,21 @@ func (c *Client) parseServerInfo(b []byte) (*serverInfo, error) {
if !ok {
return nil, fmt.Errorf("failed to open naclbox from server key %s", c.serverKey)
}
info := new(serverInfo)
info := new(ServerInfo)
if err := json.Unmarshal(msg, info); err != nil {
return nil, fmt.Errorf("invalid JSON: %v", err)
}
return info, nil
}
type clientInfo struct {
// ClientInfo is the information a DERP client sends to the server
// about itself when it connects.
type ClientInfo struct {
// MeshKey optionally specifies a pre-shared key used by
// trusted clients. It's required to subscribe to the
// connection list & forward packets. It's empty for regular
// users.
MeshKey string `json:"meshKey,omitempty"`
MeshKey key.DERPMesh `json:"meshKey,omitempty,omitzero"`
// Version is the DERP protocol version that the client was built with.
// See the ProtocolVersion const.
@@ -179,8 +181,19 @@ type clientInfo struct {
IsProber bool `json:",omitempty"`
}
// Equal reports if two clientInfo values are equal.
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 {
return false
}
return c.MeshKey.Equal(other.MeshKey)
}
func (c *Client) sendClientKey() error {
msg, err := json.Marshal(clientInfo{
msg, err := json.Marshal(ClientInfo{
Version: ProtocolVersion,
MeshKey: c.meshKey,
CanAckPings: c.canAckPings,
@@ -191,10 +204,10 @@ func (c *Client) sendClientKey() error {
}
msgbox := c.privateKey.SealTo(c.serverKey, msg)
buf := make([]byte, 0, keyLen+len(msgbox))
buf := make([]byte, 0, KeyLen+len(msgbox))
buf = c.publicKey.AppendTo(buf)
buf = append(buf, msgbox...)
return writeFrame(c.bw, frameClientInfo, buf)
return WriteFrame(c.bw, FrameClientInfo, buf)
}
// ServerPublicKey returns the server's public key.
@@ -219,12 +232,12 @@ func (c *Client) send(dstKey key.NodePublic, pkt []byte) (ret error) {
c.wmu.Lock()
defer c.wmu.Unlock()
if c.rate != nil {
pktLen := frameHeaderLen + key.NodePublicRawLen + len(pkt)
pktLen := FrameHeaderLen + key.NodePublicRawLen + len(pkt)
if !c.rate.AllowN(c.clock.Now(), pktLen) {
return nil // drop
}
}
if err := writeFrameHeader(c.bw, frameSendPacket, uint32(key.NodePublicRawLen+len(pkt))); err != nil {
if err := WriteFrameHeader(c.bw, FrameSendPacket, uint32(key.NodePublicRawLen+len(pkt))); err != nil {
return err
}
if _, err := c.bw.Write(dstKey.AppendTo(nil)); err != nil {
@@ -253,7 +266,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+len(pkt))); err != nil {
return err
}
if _, err := c.bw.Write(srcKey.AppendTo(nil)); err != nil {
@@ -271,17 +284,17 @@ func (c *Client) ForwardPacket(srcKey, dstKey key.NodePublic, pkt []byte) (err e
func (c *Client) writeTimeoutFired() { c.nc.Close() }
func (c *Client) SendPing(data [8]byte) error {
return c.sendPingOrPong(framePing, data)
return c.sendPingOrPong(FramePing, data)
}
func (c *Client) SendPong(data [8]byte) error {
return c.sendPingOrPong(framePong, data)
return c.sendPingOrPong(FramePong, data)
}
func (c *Client) sendPingOrPong(typ frameType, data [8]byte) error {
func (c *Client) sendPingOrPong(typ FrameType, data [8]byte) error {
c.wmu.Lock()
defer c.wmu.Unlock()
if err := writeFrameHeader(c.bw, typ, 8); err != nil {
if err := WriteFrameHeader(c.bw, typ, 8); err != nil {
return err
}
if _, err := c.bw.Write(data[:]); err != nil {
@@ -303,7 +316,7 @@ func (c *Client) NotePreferred(preferred bool) (err error) {
c.wmu.Lock()
defer c.wmu.Unlock()
if err := writeFrameHeader(c.bw, frameNotePreferred, 1); err != nil {
if err := WriteFrameHeader(c.bw, FrameNotePreferred, 1); err != nil {
return err
}
var b byte = 0x00
@@ -321,7 +334,7 @@ func (c *Client) NotePreferred(preferred bool) (err error) {
func (c *Client) WatchConnectionChanges() error {
c.wmu.Lock()
defer c.wmu.Unlock()
if err := writeFrameHeader(c.bw, frameWatchConns, 0); err != nil {
if err := WriteFrameHeader(c.bw, FrameWatchConns, 0); err != nil {
return err
}
return c.bw.Flush()
@@ -332,7 +345,7 @@ func (c *Client) WatchConnectionChanges() error {
func (c *Client) ClosePeer(target key.NodePublic) error {
c.wmu.Lock()
defer c.wmu.Unlock()
return writeFrame(c.bw, frameClosePeer, target.AppendTo(nil))
return WriteFrame(c.bw, FrameClosePeer, target.AppendTo(nil))
}
// ReceivedMessage represents a type returned by Client.Recv. Unless
@@ -491,7 +504,7 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
c.peeked = 0
}
t, n, err := readFrameHeader(c.br)
t, n, err := ReadFrameHeader(c.br)
if err != nil {
return nil, err
}
@@ -522,7 +535,7 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
switch t {
default:
continue
case frameServerInfo:
case FrameServerInfo:
// Server sends this at start-up. Currently unused.
// Just has a JSON message saying "version: 2",
// but the protocol seems extensible enough as-is without
@@ -539,29 +552,29 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
}
c.setSendRateLimiter(sm)
return sm, nil
case frameKeepAlive:
case FrameKeepAlive:
// A one-way keep-alive message that doesn't require an acknowledgement.
// This predated framePing/framePong.
return KeepAliveMessage{}, nil
case framePeerGone:
if n < keyLen {
case FramePeerGone:
if n < KeyLen {
c.logf("[unexpected] dropping short peerGone frame from DERP server")
continue
}
// Backward compatibility for the older peerGone without reason byte
reason := PeerGoneReasonDisconnected
if n > keyLen {
reason = PeerGoneReasonType(b[keyLen])
if n > KeyLen {
reason = PeerGoneReasonType(b[KeyLen])
}
pg := PeerGoneMessage{
Peer: key.NodePublicFromRaw32(mem.B(b[:keyLen])),
Peer: key.NodePublicFromRaw32(mem.B(b[:KeyLen])),
Reason: reason,
}
return pg, nil
case framePeerPresent:
case FramePeerPresent:
remain := b
chunk, remain, ok := cutLeadingN(remain, keyLen)
chunk, remain, ok := cutLeadingN(remain, KeyLen)
if !ok {
c.logf("[unexpected] dropping short peerPresent frame from DERP server")
continue
@@ -589,17 +602,17 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
msg.Flags = PeerPresentFlags(chunk[0])
return msg, nil
case frameRecvPacket:
case FrameRecvPacket:
var rp ReceivedPacket
if n < keyLen {
if n < KeyLen {
c.logf("[unexpected] dropping short packet from DERP server")
continue
}
rp.Source = key.NodePublicFromRaw32(mem.B(b[:keyLen]))
rp.Data = b[keyLen:n]
rp.Source = key.NodePublicFromRaw32(mem.B(b[:KeyLen]))
rp.Data = b[KeyLen:n]
return rp, nil
case framePing:
case FramePing:
var pm PingMessage
if n < 8 {
c.logf("[unexpected] dropping short ping frame")
@@ -608,7 +621,7 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
copy(pm[:], b[:])
return pm, nil
case framePong:
case FramePong:
var pm PongMessage
if n < 8 {
c.logf("[unexpected] dropping short ping frame")
@@ -617,10 +630,10 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
copy(pm[:], b[:])
return pm, nil
case frameHealth:
case FrameHealth:
return HealthMessage{Problem: string(b[:])}, nil
case frameRestarting:
case FrameRestarting:
var m ServerRestartingMessage
if n < 8 {
c.logf("[unexpected] dropping short server restarting frame")

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux
package derp
import "context"
func (c *sclient) startStatsLoop(ctx context.Context) {
// Nothing to do
return
}

View File

@@ -1,89 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package derp
import (
"context"
"crypto/tls"
"net"
"time"
"tailscale.com/net/tcpinfo"
)
func (c *sclient) startStatsLoop(ctx context.Context) {
// Get the RTT initially to verify it's supported.
conn := c.tcpConn()
if conn == nil {
c.s.tcpRtt.Add("non-tcp", 1)
return
}
if _, err := tcpinfo.RTT(conn); err != nil {
c.logf("error fetching initial RTT: %v", err)
c.s.tcpRtt.Add("error", 1)
return
}
const statsInterval = 10 * time.Second
// Don't launch a goroutine; use a timer instead.
var gatherStats func()
gatherStats = func() {
// Do nothing if the context is finished.
if ctx.Err() != nil {
return
}
// Reschedule ourselves when this stats gathering is finished.
defer c.s.clock.AfterFunc(statsInterval, gatherStats)
// Gather TCP RTT information.
rtt, err := tcpinfo.RTT(conn)
if err == nil {
c.s.tcpRtt.Add(durationToLabel(rtt), 1)
}
// TODO(andrew): more metrics?
}
// Kick off the initial timer.
c.s.clock.AfterFunc(statsInterval, gatherStats)
}
// tcpConn attempts to get the underlying *net.TCPConn from this client's
// Conn; if it cannot, then it will return nil.
func (c *sclient) tcpConn() *net.TCPConn {
nc := c.nc
for {
switch v := nc.(type) {
case *net.TCPConn:
return v
case *tls.Conn:
nc = v.NetConn()
default:
return nil
}
}
}
func durationToLabel(dur time.Duration) string {
switch {
case dur <= 10*time.Millisecond:
return "10ms"
case dur <= 20*time.Millisecond:
return "20ms"
case dur <= 50*time.Millisecond:
return "50ms"
case dur <= 100*time.Millisecond:
return "100ms"
case dur <= 150*time.Millisecond:
return "150ms"
case dur <= 250*time.Millisecond:
return "250ms"
case dur <= 500*time.Millisecond:
return "500ms"
default:
return "inf"
}
}

11
vendor/tailscale.com/derp/derpconst/derpconst.go generated vendored Normal file
View File

@@ -0,0 +1,11 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package derpconst contains constants used by the DERP client and server.
package derpconst
// MetaCertCommonNamePrefix is the prefix that the DERP server
// puts on for the common name of its "metacert". The suffix of
// the common name after "derpkey" is the hex key.NodePublic
// of the DERP server.
const MetaCertCommonNamePrefix = "derpkey"

View File

@@ -30,14 +30,17 @@ import (
"go4.org/mem"
"tailscale.com/derp"
"tailscale.com/derp/derpconst"
"tailscale.com/envknob"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/net/dnscache"
"tailscale.com/net/netmon"
"tailscale.com/net/netns"
"tailscale.com/net/netx"
"tailscale.com/net/sockstats"
"tailscale.com/net/tlsdial"
"tailscale.com/net/tshttpproxy"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tstime"
@@ -55,7 +58,7 @@ type Client struct {
TLSConfig *tls.Config // optional; nil means default
HealthTracker *health.Tracker // optional; used if non-nil only
DNSCache *dnscache.Resolver // optional; nil means no caching
MeshKey string // optional; for trusted clients
MeshKey key.DERPMesh // optional; for trusted clients
IsProber bool // optional; for probers to optional declare themselves as such
// WatchConnectionChanges is whether the client wishes to subscribe to
@@ -520,7 +523,7 @@ func (c *Client) connect(ctx context.Context, caller string) (client *derp.Clien
// just to get routed into the server's HTTP Handler so it
// can Hijack the request, but we signal with a special header
// that we don't want to deal with its HTTP response.
req.Header.Set(fastStartHeader, "1") // suppresses the server's HTTP response
req.Header.Set(derp.FastStartHeader, "1") // suppresses the server's HTTP response
if err := req.Write(brw); err != nil {
return nil, 0, err
}
@@ -587,7 +590,7 @@ func (c *Client) connect(ctx context.Context, caller string) (client *derp.Clien
//
// The primary use for this is the derper mesh mode to connect to each
// other over a VPC network.
func (c *Client) SetURLDialer(dialer func(ctx context.Context, network, addr string) (net.Conn, error)) {
func (c *Client) SetURLDialer(dialer netx.DialFunc) {
c.dialer = dialer
}
@@ -645,7 +648,10 @@ func (c *Client) dialRegion(ctx context.Context, reg *tailcfg.DERPRegion) (net.C
}
func (c *Client) tlsClient(nc net.Conn, node *tailcfg.DERPNode) *tls.Conn {
tlsConf := tlsdial.Config(c.tlsServerName(node), c.HealthTracker, c.TLSConfig)
tlsConf := tlsdial.Config(c.HealthTracker, c.TLSConfig)
// node is allowed to be nil here, tlsServerName falls back to using the URL
// if node is nil.
tlsConf.ServerName = c.tlsServerName(node)
if node != nil {
if node.InsecureForTests {
tlsConf.InsecureSkipVerify = true
@@ -729,8 +735,12 @@ func (c *Client) dialNode(ctx context.Context, n *tailcfg.DERPNode) (net.Conn, e
Path: "/", // unused
},
}
if proxyURL, err := tshttpproxy.ProxyFromEnvironment(proxyReq); err == nil && proxyURL != nil {
return c.dialNodeUsingProxy(ctx, n, proxyURL)
if buildfeatures.HasUseProxy {
if proxyFromEnv, ok := feature.HookProxyFromEnvironment.GetOk(); ok {
if proxyURL, err := proxyFromEnv(proxyReq); err == nil && proxyURL != nil {
return c.dialNodeUsingProxy(ctx, n, proxyURL)
}
}
}
type res struct {
@@ -860,10 +870,14 @@ func (c *Client) dialNodeUsingProxy(ctx context.Context, n *tailcfg.DERPNode, pr
target := net.JoinHostPort(n.HostName, "443")
var authHeader string
if v, err := tshttpproxy.GetAuthHeader(pu); err != nil {
c.logf("derphttp: error getting proxy auth header for %v: %v", proxyURL, err)
} else if v != "" {
authHeader = fmt.Sprintf("Proxy-Authorization: %s\r\n", v)
if buildfeatures.HasUseProxy {
if getAuthHeader, ok := feature.HookProxyGetAuthHeader.GetOk(); ok {
if v, err := getAuthHeader(pu); err != nil {
c.logf("derphttp: error getting proxy auth header for %v: %v", proxyURL, err)
} else if v != "" {
authHeader = fmt.Sprintf("Proxy-Authorization: %s\r\n", v)
}
}
}
if _, err := fmt.Fprintf(proxyConn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n%s\r\n", target, target, authHeader); err != nil {
@@ -1151,7 +1165,7 @@ var ErrClientClosed = errors.New("derphttp.Client closed")
func parseMetaCert(certs []*x509.Certificate) (serverPub key.NodePublic, serverProtoVersion int) {
for _, cert := range certs {
// Look for derpkey prefix added by initMetacert() on the server side.
if pubHex, ok := strings.CutPrefix(cert.Subject.CommonName, "derpkey"); ok {
if pubHex, ok := strings.CutPrefix(cert.Subject.CommonName, derpconst.MetaCertCommonNamePrefix); ok {
var err error
serverPub, err = key.ParseNodePublicUntyped(mem.S(pubHex))
if err == nil && cert.SerialNumber.BitLen() <= 8 { // supports up to version 255

View File

@@ -1,115 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package derphttp
import (
"fmt"
"log"
"net/http"
"strings"
"tailscale.com/derp"
)
// fastStartHeader is the header (with value "1") that signals to the HTTP
// server that the DERP HTTP client does not want the HTTP 101 response
// headers and it will begin writing & reading the DERP protocol immediately
// following its HTTP request.
const fastStartHeader = "Derp-Fast-Start"
// Handler returns an http.Handler to be mounted at /derp, serving s.
func Handler(s *derp.Server) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// These are installed both here and in cmd/derper. The check here
// catches both cmd/derper run with DERP disabled (STUN only mode) as
// well as DERP being run in tests with derphttp.Handler directly,
// as netcheck still assumes this replies.
switch r.URL.Path {
case "/derp/probe", "/derp/latency-check":
ProbeHandler(w, r)
return
}
up := strings.ToLower(r.Header.Get("Upgrade"))
if up != "websocket" && up != "derp" {
if up != "" {
log.Printf("Weird upgrade: %q", up)
}
http.Error(w, "DERP requires connection upgrade", http.StatusUpgradeRequired)
return
}
fastStart := r.Header.Get(fastStartHeader) == "1"
h, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "HTTP does not support general TCP support", 500)
return
}
netConn, conn, err := h.Hijack()
if err != nil {
log.Printf("Hijack failed: %v", err)
http.Error(w, "HTTP does not support general TCP support", 500)
return
}
if !fastStart {
pubKey := s.PublicKey()
fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\n"+
"Upgrade: DERP\r\n"+
"Connection: Upgrade\r\n"+
"Derp-Version: %v\r\n"+
"Derp-Public-Key: %s\r\n\r\n",
derp.ProtocolVersion,
pubKey.UntypedHexString())
}
if v := r.Header.Get(derp.IdealNodeHeader); v != "" {
ctx = derp.IdealNodeContextKey.WithValue(ctx, v)
}
s.Accept(ctx, netConn, conn, netConn.RemoteAddr().String())
})
}
// ProbeHandler is the endpoint that clients without UDP access (including js/wasm) hit to measure
// DERP latency, as a replacement for UDP STUN queries.
func ProbeHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "HEAD", "GET":
w.Header().Set("Access-Control-Allow-Origin", "*")
default:
http.Error(w, "bogus probe method", http.StatusMethodNotAllowed)
}
}
// ServeNoContent generates the /generate_204 response used by Tailscale's
// captive portal detection.
func ServeNoContent(w http.ResponseWriter, r *http.Request) {
if challenge := r.Header.Get(NoContentChallengeHeader); challenge != "" {
badChar := strings.IndexFunc(challenge, func(r rune) bool {
return !isChallengeChar(r)
}) != -1
if len(challenge) <= 64 && !badChar {
w.Header().Set(NoContentResponseHeader, "response "+challenge)
}
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate, no-transform, max-age=0")
w.WriteHeader(http.StatusNoContent)
}
func isChallengeChar(c rune) bool {
// Semi-randomly chosen as a limited set of valid characters
return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
('0' <= c && c <= '9') ||
c == '.' || c == '-' || c == '_' || c == ':'
}
const (
NoContentChallengeHeader = "X-Tailscale-Challenge"
NoContentResponseHeader = "X-Tailscale-Response"
)

View File

@@ -31,6 +31,9 @@ var testHookWatchLookConnectResult func(connectError error, wasSelfConnect bool)
// This behavior will likely change. Callers should do their own accounting
// and dup suppression as needed.
//
// If set the notifyError func is called with any error that occurs within the ctx
// main loop connection setup, or the inner loop receiving messages via RecvDetail.
//
// infoLogf, if non-nil, is the logger to write periodic status updates about
// how many peers are on the server. Error log output is set to the c's logger,
// regardless of infoLogf's value.
@@ -42,10 +45,11 @@ var testHookWatchLookConnectResult func(connectError error, wasSelfConnect bool)
// initialized Client.WatchConnectionChanges to true.
//
// If the DERP connection breaks and reconnects, remove will be called for all
// previously seen peers, with Reason type PeerGoneReasonSynthetic. Those
// previously seen peers, with Reason type PeerGoneReasonMeshConnBroke. Those
// clients are likely still connected and their add message will appear after
// reconnect.
func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key.NodePublic, infoLogf logger.Logf, add func(derp.PeerPresentMessage), remove func(derp.PeerGoneMessage)) {
func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key.NodePublic, infoLogf logger.Logf,
add func(derp.PeerPresentMessage), remove func(derp.PeerGoneMessage), notifyError func(error)) {
if !c.WatchConnectionChanges {
if c.isStarted() {
panic("invalid use of RunWatchConnectionLoop on already-started Client without setting Client.RunWatchConnectionLoop")
@@ -121,6 +125,10 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
// Make sure we're connected before calling s.ServerPublicKey.
_, _, err := c.connect(ctx, "RunWatchConnectionLoop")
if err != nil {
logf("mesh connect: %v", err)
if notifyError != nil {
notifyError(err)
}
if f := testHookWatchLookConnectResult; f != nil && !f(err, false) {
return
}
@@ -141,6 +149,9 @@ func (c *Client) RunWatchConnectionLoop(ctx context.Context, ignoreServerKey key
if err != nil {
clear()
logf("Recv: %v", err)
if notifyError != nil {
notifyError(err)
}
sleep(retryInterval)
break
}

388
vendor/tailscale.com/disco/disco.go generated vendored
View File

@@ -25,6 +25,7 @@ import (
"fmt"
"net"
"net/netip"
"time"
"go4.org/mem"
"tailscale.com/types/key"
@@ -41,9 +42,15 @@ const NonceLen = 24
type MessageType byte
const (
TypePing = MessageType(0x01)
TypePong = MessageType(0x02)
TypeCallMeMaybe = MessageType(0x03)
TypePing = MessageType(0x01)
TypePong = MessageType(0x02)
TypeCallMeMaybe = MessageType(0x03)
TypeBindUDPRelayEndpoint = MessageType(0x04)
TypeBindUDPRelayEndpointChallenge = MessageType(0x05)
TypeBindUDPRelayEndpointAnswer = MessageType(0x06)
TypeCallMeMaybeVia = MessageType(0x07)
TypeAllocateUDPRelayEndpointRequest = MessageType(0x08)
TypeAllocateUDPRelayEndpointResponse = MessageType(0x09)
)
const v0 = byte(0)
@@ -77,12 +84,25 @@ func Parse(p []byte) (Message, error) {
}
t, ver, p := MessageType(p[0]), p[1], p[2:]
switch t {
// TODO(jwhited): consider using a signature matching encoding.BinaryUnmarshaler
case TypePing:
return parsePing(ver, p)
case TypePong:
return parsePong(ver, p)
case TypeCallMeMaybe:
return parseCallMeMaybe(ver, p)
case TypeBindUDPRelayEndpoint:
return parseBindUDPRelayEndpoint(ver, p)
case TypeBindUDPRelayEndpointChallenge:
return parseBindUDPRelayEndpointChallenge(ver, p)
case TypeBindUDPRelayEndpointAnswer:
return parseBindUDPRelayEndpointAnswer(ver, p)
case TypeCallMeMaybeVia:
return parseCallMeMaybeVia(ver, p)
case TypeAllocateUDPRelayEndpointRequest:
return parseAllocateUDPRelayEndpointRequest(ver, p)
case TypeAllocateUDPRelayEndpointResponse:
return parseAllocateUDPRelayEndpointResponse(ver, p)
default:
return nil, fmt.Errorf("unknown message type 0x%02x", byte(t))
}
@@ -91,6 +111,7 @@ func Parse(p []byte) (Message, error) {
// Message a discovery message.
type Message interface {
// AppendMarshal appends the message's marshaled representation.
// TODO(jwhited): consider using a signature matching encoding.BinaryAppender
AppendMarshal([]byte) []byte
}
@@ -266,7 +287,368 @@ func MessageSummary(m Message) string {
return fmt.Sprintf("pong tx=%x", m.TxID[:6])
case *CallMeMaybe:
return "call-me-maybe"
case *CallMeMaybeVia:
return "call-me-maybe-via"
case *BindUDPRelayEndpoint:
return "bind-udp-relay-endpoint"
case *BindUDPRelayEndpointChallenge:
return "bind-udp-relay-endpoint-challenge"
case *BindUDPRelayEndpointAnswer:
return "bind-udp-relay-endpoint-answer"
case *AllocateUDPRelayEndpointRequest:
return "allocate-udp-relay-endpoint-request"
case *AllocateUDPRelayEndpointResponse:
return "allocate-udp-relay-endpoint-response"
default:
return fmt.Sprintf("%#v", m)
}
}
// 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.
type BindUDPRelayHandshakeState int
const (
// BindUDPRelayHandshakeStateInit represents the initial state prior to any
// message being transmitted.
BindUDPRelayHandshakeStateInit BindUDPRelayHandshakeState = iota
// BindUDPRelayHandshakeStateBindSent is the first client state after
// transmitting a BindUDPRelayEndpoint message to a UDP relay server.
BindUDPRelayHandshakeStateBindSent
// BindUDPRelayHandshakeStateChallengeSent is the first server state after
// receiving a BindUDPRelayEndpoint message from a UDP relay client and
// replying with a BindUDPRelayEndpointChallenge.
BindUDPRelayHandshakeStateChallengeSent
// BindUDPRelayHandshakeStateAnswerSent is a client state that is entered
// after transmitting a BindUDPRelayEndpointAnswer message towards a UDP
// relay server in response to a BindUDPRelayEndpointChallenge message.
BindUDPRelayHandshakeStateAnswerSent
// BindUDPRelayHandshakeStateAnswerReceived is a server state that is
// entered after it has received a correct BindUDPRelayEndpointAnswer
// message from a UDP relay client in response to a
// BindUDPRelayEndpointChallenge message.
BindUDPRelayHandshakeStateAnswerReceived
)
// bindUDPRelayEndpointCommonLen is the length of a marshalled
// [BindUDPRelayEndpointCommon], without the message header.
const bindUDPRelayEndpointCommonLen = 72
// BindUDPRelayChallengeLen is the length of the Challenge field carried in
// [BindUDPRelayEndpointChallenge] & [BindUDPRelayEndpointAnswer] messages.
const BindUDPRelayChallengeLen = 32
// BindUDPRelayEndpointCommon contains fields that are common across all 3
// UDP relay handshake message types. All 4 field values are expected to be
// consistent for the lifetime of a handshake besides Challenge, which is
// irrelevant in a [BindUDPRelayEndpoint] message.
type BindUDPRelayEndpointCommon struct {
// VNI is the Geneve header Virtual Network Identifier field value, which
// must match this disco-sealed value upon reception. If they are
// non-matching it indicates the cleartext Geneve header was tampered with
// and/or mangled.
VNI uint32
// Generation represents the handshake generation. Clients must set a new,
// nonzero value at the start of every handshake.
Generation uint32
// RemoteKey is the disco key of the remote peer participating over this
// relay endpoint.
RemoteKey key.DiscoPublic
// Challenge is set by the server in a [BindUDPRelayEndpointChallenge]
// message, and expected to be echoed back by the client in a
// [BindUDPRelayEndpointAnswer] message. Its value is irrelevant in a
// [BindUDPRelayEndpoint] message, where it simply serves a padding purpose
// ensuring all handshake messages are equal in size.
Challenge [BindUDPRelayChallengeLen]byte
}
// encode encodes m in b. b must be at least bindUDPRelayEndpointCommonLen bytes
// long.
func (m *BindUDPRelayEndpointCommon) encode(b []byte) {
binary.BigEndian.PutUint32(b, m.VNI)
b = b[4:]
binary.BigEndian.PutUint32(b, m.Generation)
b = b[4:]
m.RemoteKey.AppendTo(b[:0])
b = b[key.DiscoPublicRawLen:]
copy(b, m.Challenge[:])
}
// decode decodes m from b.
func (m *BindUDPRelayEndpointCommon) decode(b []byte) error {
if len(b) < bindUDPRelayEndpointCommonLen {
return errShort
}
m.VNI = binary.BigEndian.Uint32(b)
b = b[4:]
m.Generation = binary.BigEndian.Uint32(b)
b = b[4:]
m.RemoteKey = key.DiscoPublicFromRaw32(mem.B(b[:key.DiscoPublicRawLen]))
b = b[key.DiscoPublicRawLen:]
copy(m.Challenge[:], b[:BindUDPRelayChallengeLen])
return nil
}
// BindUDPRelayEndpoint is the first messaged transmitted from UDP relay client
// towards UDP relay server as part of the 3-way bind handshake.
type BindUDPRelayEndpoint struct {
BindUDPRelayEndpointCommon
}
func (m *BindUDPRelayEndpoint) AppendMarshal(b []byte) []byte {
ret, d := appendMsgHeader(b, TypeBindUDPRelayEndpoint, v0, bindUDPRelayEndpointCommonLen)
m.BindUDPRelayEndpointCommon.encode(d)
return ret
}
func parseBindUDPRelayEndpoint(ver uint8, p []byte) (m *BindUDPRelayEndpoint, err error) {
m = new(BindUDPRelayEndpoint)
err = m.BindUDPRelayEndpointCommon.decode(p)
if err != nil {
return nil, err
}
return m, nil
}
// BindUDPRelayEndpointChallenge is transmitted from UDP relay server towards
// UDP relay client in response to a BindUDPRelayEndpoint message as part of the
// 3-way bind handshake.
type BindUDPRelayEndpointChallenge struct {
BindUDPRelayEndpointCommon
}
func (m *BindUDPRelayEndpointChallenge) AppendMarshal(b []byte) []byte {
ret, d := appendMsgHeader(b, TypeBindUDPRelayEndpointChallenge, v0, bindUDPRelayEndpointCommonLen)
m.BindUDPRelayEndpointCommon.encode(d)
return ret
}
func parseBindUDPRelayEndpointChallenge(ver uint8, p []byte) (m *BindUDPRelayEndpointChallenge, err error) {
m = new(BindUDPRelayEndpointChallenge)
err = m.BindUDPRelayEndpointCommon.decode(p)
if err != nil {
return nil, err
}
return m, nil
}
// BindUDPRelayEndpointAnswer is transmitted from UDP relay client to UDP relay
// server in response to a BindUDPRelayEndpointChallenge message.
type BindUDPRelayEndpointAnswer struct {
BindUDPRelayEndpointCommon
}
func (m *BindUDPRelayEndpointAnswer) AppendMarshal(b []byte) []byte {
ret, d := appendMsgHeader(b, TypeBindUDPRelayEndpointAnswer, v0, bindUDPRelayEndpointCommonLen)
m.BindUDPRelayEndpointCommon.encode(d)
return ret
}
func parseBindUDPRelayEndpointAnswer(ver uint8, p []byte) (m *BindUDPRelayEndpointAnswer, err error) {
m = new(BindUDPRelayEndpointAnswer)
err = m.BindUDPRelayEndpointCommon.decode(p)
if err != nil {
return nil, err
}
return m, nil
}
// AllocateUDPRelayEndpointRequest is a message sent only over DERP to request
// allocation of a relay endpoint on a [tailscale.com/net/udprelay.Server]
type AllocateUDPRelayEndpointRequest struct {
// ClientDisco are the Disco public keys of the clients that should be
// permitted to handshake with the endpoint.
ClientDisco [2]key.DiscoPublic
// Generation represents the allocation request generation. The server must
// echo it back in the [AllocateUDPRelayEndpointResponse] to enable request
// and response alignment client-side.
Generation uint32
}
// allocateUDPRelayEndpointRequestLen is the length of a marshaled
// [AllocateUDPRelayEndpointRequest] message without the message header.
const allocateUDPRelayEndpointRequestLen = key.DiscoPublicRawLen*2 + // ClientDisco
4 // Generation
func (m *AllocateUDPRelayEndpointRequest) AppendMarshal(b []byte) []byte {
ret, p := appendMsgHeader(b, TypeAllocateUDPRelayEndpointRequest, v0, allocateUDPRelayEndpointRequestLen)
for i := 0; i < len(m.ClientDisco); i++ {
disco := m.ClientDisco[i].AppendTo(nil)
copy(p, disco)
p = p[key.DiscoPublicRawLen:]
}
binary.BigEndian.PutUint32(p, m.Generation)
return ret
}
func parseAllocateUDPRelayEndpointRequest(ver uint8, p []byte) (m *AllocateUDPRelayEndpointRequest, err error) {
m = new(AllocateUDPRelayEndpointRequest)
if ver != 0 {
return
}
if len(p) < allocateUDPRelayEndpointRequestLen {
return m, errShort
}
for i := 0; i < len(m.ClientDisco); i++ {
m.ClientDisco[i] = key.DiscoPublicFromRaw32(mem.B(p[:key.DiscoPublicRawLen]))
p = p[key.DiscoPublicRawLen:]
}
m.Generation = binary.BigEndian.Uint32(p)
return m, nil
}
// AllocateUDPRelayEndpointResponse is a message sent only over DERP in response
// to a [AllocateUDPRelayEndpointRequest].
type AllocateUDPRelayEndpointResponse struct {
// Generation represents the allocation request generation. The server must
// echo back the [AllocateUDPRelayEndpointRequest.Generation] here to enable
// request and response alignment client-side.
Generation uint32
UDPRelayEndpoint
}
func (m *AllocateUDPRelayEndpointResponse) AppendMarshal(b []byte) []byte {
endpointsLen := epLength * len(m.AddrPorts)
generationLen := 4
ret, d := appendMsgHeader(b, TypeAllocateUDPRelayEndpointResponse, v0, generationLen+udpRelayEndpointLenMinusAddrPorts+endpointsLen)
binary.BigEndian.PutUint32(d, m.Generation)
m.encode(d[4:])
return ret
}
func parseAllocateUDPRelayEndpointResponse(ver uint8, p []byte) (m *AllocateUDPRelayEndpointResponse, err error) {
m = new(AllocateUDPRelayEndpointResponse)
if ver != 0 {
return m, nil
}
if len(p) < 4 {
return m, errShort
}
m.Generation = binary.BigEndian.Uint32(p)
err = m.decode(p[4:])
return m, err
}
const udpRelayEndpointLenMinusAddrPorts = key.DiscoPublicRawLen + // ServerDisco
(key.DiscoPublicRawLen * 2) + // ClientDisco
8 + // LamportID
4 + // VNI
8 + // BindLifetime
8 // SteadyStateLifetime
// UDPRelayEndpoint is a mirror of [tailscale.com/net/udprelay/endpoint.ServerEndpoint],
// refer to it for field documentation. [UDPRelayEndpoint] is carried in both
// [CallMeMaybeVia] and [AllocateUDPRelayEndpointResponse] messages.
type UDPRelayEndpoint struct {
// ServerDisco is [tailscale.com/net/udprelay/endpoint.ServerEndpoint.ServerDisco]
ServerDisco key.DiscoPublic
// ClientDisco is [tailscale.com/net/udprelay/endpoint.ServerEndpoint.ClientDisco]
ClientDisco [2]key.DiscoPublic
// LamportID is [tailscale.com/net/udprelay/endpoint.ServerEndpoint.LamportID]
LamportID uint64
// VNI is [tailscale.com/net/udprelay/endpoint.ServerEndpoint.VNI]
VNI uint32
// BindLifetime is [tailscale.com/net/udprelay/endpoint.ServerEndpoint.BindLifetime]
BindLifetime time.Duration
// SteadyStateLifetime is [tailscale.com/net/udprelay/endpoint.ServerEndpoint.SteadyStateLifetime]
SteadyStateLifetime time.Duration
// AddrPorts is [tailscale.com/net/udprelay/endpoint.ServerEndpoint.AddrPorts]
AddrPorts []netip.AddrPort
}
// encode encodes m in b. b must be at least [udpRelayEndpointLenMinusAddrPorts]
// + [epLength] * len(m.AddrPorts) bytes long.
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++ {
disco = m.ClientDisco[i].AppendTo(nil)
copy(b, disco)
b = b[key.DiscoPublicRawLen:]
}
binary.BigEndian.PutUint64(b[:8], m.LamportID)
b = b[8:]
binary.BigEndian.PutUint32(b[:4], m.VNI)
b = b[4:]
binary.BigEndian.PutUint64(b[:8], uint64(m.BindLifetime))
b = b[8:]
binary.BigEndian.PutUint64(b[:8], uint64(m.SteadyStateLifetime))
b = b[8:]
for _, ipp := range m.AddrPorts {
a := ipp.Addr().As16()
copy(b, a[:])
binary.BigEndian.PutUint16(b[16:18], ipp.Port())
b = b[epLength:]
}
}
// decode decodes m from b.
func (m *UDPRelayEndpoint) decode(b []byte) error {
if len(b) < udpRelayEndpointLenMinusAddrPorts+epLength ||
(len(b)-udpRelayEndpointLenMinusAddrPorts)%epLength != 0 {
return errShort
}
m.ServerDisco = key.DiscoPublicFromRaw32(mem.B(b[:key.DiscoPublicRawLen]))
b = b[key.DiscoPublicRawLen:]
for i := 0; i < len(m.ClientDisco); i++ {
m.ClientDisco[i] = key.DiscoPublicFromRaw32(mem.B(b[:key.DiscoPublicRawLen]))
b = b[key.DiscoPublicRawLen:]
}
m.LamportID = binary.BigEndian.Uint64(b[:8])
b = b[8:]
m.VNI = binary.BigEndian.Uint32(b[:4])
b = b[4:]
m.BindLifetime = time.Duration(binary.BigEndian.Uint64(b[:8]))
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)
for len(b) > 0 {
var a [16]byte
copy(a[:], b)
m.AddrPorts = append(m.AddrPorts, netip.AddrPortFrom(
netip.AddrFrom16(a).Unmap(),
binary.BigEndian.Uint16(b[16:18])))
b = b[epLength:]
}
return nil
}
// CallMeMaybeVia is a message sent only over DERP to request that the recipient
// try to open up a magicsock path back to the sender. The 'Via' in
// CallMeMaybeVia highlights that candidate paths are served through an
// intermediate relay, likely a [tailscale.com/net/udprelay.Server].
//
// Usage of the candidate paths in magicsock requires a 3-way handshake
// involving [BindUDPRelayEndpoint], [BindUDPRelayEndpointChallenge], and
// [BindUDPRelayEndpointAnswer].
//
// CallMeMaybeVia mirrors [tailscale.com/net/udprelay/endpoint.ServerEndpoint],
// which contains field documentation.
//
// The recipient may choose to not open a path back if it's already happy with
// its path. Direct connections, e.g. [CallMeMaybe]-signaled, take priority over
// CallMeMaybeVia paths.
type CallMeMaybeVia struct {
UDPRelayEndpoint
}
func (m *CallMeMaybeVia) AppendMarshal(b []byte) []byte {
endpointsLen := epLength * len(m.AddrPorts)
ret, p := appendMsgHeader(b, TypeCallMeMaybeVia, v0, udpRelayEndpointLenMinusAddrPorts+endpointsLen)
m.encode(p)
return ret
}
func parseCallMeMaybeVia(ver uint8, p []byte) (m *CallMeMaybeVia, err error) {
m = new(CallMeMaybeVia)
if ver != 0 {
return m, nil
}
err = m.decode(p)
return m, err
}

View File

@@ -1,79 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package doctor contains more in-depth healthchecks that can be run to aid in
// diagnosing Tailscale issues.
package doctor
import (
"context"
"sync"
"tailscale.com/types/logger"
)
// Check is the interface defining a singular check.
//
// A check should log information that it gathers using the provided log
// function, and should attempt to make as much progress as possible in error
// conditions.
type Check interface {
// Name should return a name describing this check, in lower-kebab-case
// (i.e. "my-check", not "MyCheck" or "my_check").
Name() string
// Run executes the check, logging diagnostic information to the
// provided logger function.
Run(context.Context, logger.Logf) error
}
// RunChecks runs a list of checks in parallel, and logs any returned errors
// after all checks have returned.
func RunChecks(ctx context.Context, log logger.Logf, checks ...Check) {
if len(checks) == 0 {
return
}
type namedErr struct {
name string
err error
}
errs := make(chan namedErr, len(checks))
var wg sync.WaitGroup
wg.Add(len(checks))
for _, check := range checks {
go func(c Check) {
defer wg.Done()
plog := logger.WithPrefix(log, c.Name()+": ")
errs <- namedErr{
name: c.Name(),
err: c.Run(ctx, plog),
}
}(check)
}
wg.Wait()
close(errs)
for n := range errs {
if n.err == nil {
continue
}
log("check %s: %v", n.name, n.err)
}
}
// CheckFunc creates a Check from a name and a function.
func CheckFunc(name string, run func(context.Context, logger.Logf) error) Check {
return checkFunc{name, run}
}
type checkFunc struct {
name string
run func(context.Context, logger.Logf) error
}
func (c checkFunc) Name() string { return c.name }
func (c checkFunc) Run(ctx context.Context, log logger.Logf) error { return c.run(ctx, log) }

View File

@@ -1,23 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package ethtool provides a doctor.Check that prints diagnostic information
// obtained from the 'ethtool' utility on the current system.
package ethtool
import (
"context"
"tailscale.com/types/logger"
)
// Check implements the doctor.Check interface.
type Check struct{}
func (Check) Name() string {
return "ethtool"
}
func (Check) Run(_ context.Context, logf logger.Logf) error {
return ethtoolImpl(logf)
}

View File

@@ -1,78 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ethtool
import (
"net/netip"
"sort"
"github.com/safchain/ethtool"
"tailscale.com/net/netmon"
"tailscale.com/types/logger"
"tailscale.com/util/set"
)
func ethtoolImpl(logf logger.Logf) error {
et, err := ethtool.NewEthtool()
if err != nil {
logf("could not create ethtool: %v", err)
return nil
}
defer et.Close()
netmon.ForeachInterface(func(iface netmon.Interface, _ []netip.Prefix) {
ilogf := logger.WithPrefix(logf, iface.Name+": ")
features, err := et.Features(iface.Name)
if err == nil {
enabled := []string{}
for feature, value := range features {
if value {
enabled = append(enabled, feature)
}
}
sort.Strings(enabled)
ilogf("features: %v", enabled)
} else {
ilogf("features: error: %v", err)
}
stats, err := et.Stats(iface.Name)
if err == nil {
printStats(ilogf, stats)
} else {
ilogf("stats: error: %v", err)
}
})
return nil
}
// Stats that should be printed if non-zero
var nonzeroStats = set.SetOf([]string{
// AWS ENA driver statistics; see:
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/monitoring-network-performance-ena.html
"bw_in_allowance_exceeded",
"bw_out_allowance_exceeded",
"conntrack_allowance_exceeded",
"linklocal_allowance_exceeded",
"pps_allowance_exceeded",
})
// Stats that should be printed if zero
var zeroStats = set.SetOf([]string{
// AWS ENA driver statistics; see:
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/monitoring-network-performance-ena.html
"conntrack_allowance_available",
})
func printStats(logf logger.Logf, stats map[string]uint64) {
for name, value := range stats {
if value != 0 && nonzeroStats.Contains(name) {
logf("stats: warning: %s = %d > 0", name, value)
}
if value == 0 && zeroStats.Contains(name) {
logf("stats: warning: %s = %d == 0", name, value)
}
}
}

View File

@@ -1,17 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !linux
package ethtool
import (
"runtime"
"tailscale.com/types/logger"
)
func ethtoolImpl(logf logger.Logf) error {
logf("unsupported on %s/%s", runtime.GOOS, runtime.GOARCH)
return nil
}

View File

@@ -1,59 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package permissions provides a doctor.Check that prints the process
// permissions for the running process.
package permissions
import (
"context"
"fmt"
"os/user"
"strings"
"golang.org/x/exp/constraints"
"tailscale.com/types/logger"
)
// Check implements the doctor.Check interface.
type Check struct{}
func (Check) Name() string {
return "permissions"
}
func (Check) Run(_ context.Context, logf logger.Logf) error {
return permissionsImpl(logf)
}
//lint:ignore U1000 used in non-windows implementations.
func formatUserID[T constraints.Integer](id T) string {
idStr := fmt.Sprint(id)
if uu, err := user.LookupId(idStr); err != nil {
return idStr + "(<unknown>)"
} else {
return fmt.Sprintf("%s(%q)", idStr, uu.Username)
}
}
//lint:ignore U1000 used in non-windows implementations.
func formatGroupID[T constraints.Integer](id T) string {
idStr := fmt.Sprint(id)
if g, err := user.LookupGroupId(idStr); err != nil {
return idStr + "(<unknown>)"
} else {
return fmt.Sprintf("%s(%q)", idStr, g.Name)
}
}
//lint:ignore U1000 used in non-windows implementations.
func formatGroups[T constraints.Integer](groups []T) string {
var buf strings.Builder
for i, group := range groups {
if i > 0 {
buf.WriteByte(',')
}
buf.WriteString(formatGroupID(group))
}
return buf.String()
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build darwin || freebsd || openbsd
package permissions
import (
"golang.org/x/sys/unix"
"tailscale.com/types/logger"
)
func permissionsImpl(logf logger.Logf) error {
groups, _ := unix.Getgroups()
logf("uid=%s euid=%s gid=%s egid=%s groups=%s",
formatUserID(unix.Getuid()),
formatUserID(unix.Geteuid()),
formatGroupID(unix.Getgid()),
formatGroupID(unix.Getegid()),
formatGroups(groups),
)
return nil
}

View File

@@ -1,62 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package permissions
import (
"fmt"
"strings"
"unsafe"
"golang.org/x/sys/unix"
"tailscale.com/types/logger"
)
func permissionsImpl(logf logger.Logf) error {
// NOTE: getresuid and getresgid never fail unless passed an
// invalid address.
var ruid, euid, suid uint64
unix.Syscall(unix.SYS_GETRESUID,
uintptr(unsafe.Pointer(&ruid)),
uintptr(unsafe.Pointer(&euid)),
uintptr(unsafe.Pointer(&suid)),
)
var rgid, egid, sgid uint64
unix.Syscall(unix.SYS_GETRESGID,
uintptr(unsafe.Pointer(&rgid)),
uintptr(unsafe.Pointer(&egid)),
uintptr(unsafe.Pointer(&sgid)),
)
groups, _ := unix.Getgroups()
var buf strings.Builder
fmt.Fprintf(&buf, "ruid=%s euid=%s suid=%s rgid=%s egid=%s sgid=%s groups=%s",
formatUserID(ruid), formatUserID(euid), formatUserID(suid),
formatGroupID(rgid), formatGroupID(egid), formatGroupID(sgid),
formatGroups(groups),
)
// Get process capabilities
var (
capHeader = unix.CapUserHeader{
Version: unix.LINUX_CAPABILITY_VERSION_3,
Pid: 0, // 0 means 'ourselves'
}
capData unix.CapUserData
)
if err := unix.Capget(&capHeader, &capData); err != nil {
fmt.Fprintf(&buf, " caperr=%v", err)
} else {
fmt.Fprintf(&buf, " cap_effective=%08x cap_permitted=%08x cap_inheritable=%08x",
capData.Effective, capData.Permitted, capData.Inheritable,
)
}
logf("%s", buf.String())
return nil
}

View File

@@ -1,17 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !(linux || darwin || freebsd || openbsd)
package permissions
import (
"runtime"
"tailscale.com/types/logger"
)
func permissionsImpl(logf logger.Logf) error {
logf("unsupported on %s/%s", runtime.GOOS, runtime.GOARCH)
return nil
}

View File

@@ -1,34 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package routetable provides a doctor.Check that dumps the current system's
// route table to the log.
package routetable
import (
"context"
"tailscale.com/net/routetable"
"tailscale.com/types/logger"
)
// MaxRoutes is the maximum number of routes that will be displayed.
const MaxRoutes = 1000
// Check implements the doctor.Check interface.
type Check struct{}
func (Check) Name() string {
return "routetable"
}
func (Check) Run(_ context.Context, logf logger.Logf) error {
rs, err := routetable.Get(MaxRoutes)
if err != nil {
return err
}
for _, r := range rs {
logf("%s", r)
}
return nil
}

View File

@@ -6,9 +6,11 @@
package drive
import (
"encoding/json"
jsonv1 "encoding/json"
"errors"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"tailscale.com/types/views"
)
@@ -42,8 +44,17 @@ func (v ShareView) AsStruct() *Share {
return v.ж.Clone()
}
func (v ShareView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
// MarshalJSON implements [jsonv1.Marshaler].
func (v ShareView) MarshalJSON() ([]byte, error) {
return jsonv1.Marshal(v.ж)
}
// MarshalJSONTo implements [jsonv2.MarshalerTo].
func (v ShareView) MarshalJSONTo(enc *jsontext.Encoder) error {
return jsonv2.MarshalEncode(enc, v.ж)
}
// UnmarshalJSON implements [jsonv1.Unmarshaler].
func (v *ShareView) UnmarshalJSON(b []byte) error {
if v.ж != nil {
return errors.New("already initialized")
@@ -52,16 +63,44 @@ func (v *ShareView) UnmarshalJSON(b []byte) error {
return nil
}
var x Share
if err := json.Unmarshal(b, &x); err != nil {
if err := jsonv1.Unmarshal(b, &x); err != nil {
return err
}
v.ж = &x
return nil
}
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
func (v *ShareView) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
if v.ж != nil {
return errors.New("already initialized")
}
var x Share
if err := jsonv2.UnmarshalDecode(dec, &x); err != nil {
return err
}
v.ж = &x
return nil
}
// Name is how this share appears on remote nodes.
func (v ShareView) Name() string { return v.ж.Name }
// Path is the path to the directory on this machine that's being shared.
func (v ShareView) Path() string { return v.ж.Path }
func (v ShareView) As() string { return v.ж.As }
// As is the UNIX or Windows username of the local account used for this
// share. File read/write permissions are enforced based on this username.
// Can be left blank to use the default value of "whoever is running the
// Tailscale GUI".
func (v ShareView) As() string { return v.ж.As }
// BookmarkData contains security-scoped bookmark data for the Sandboxed
// Mac application. The Sandboxed Mac application gains permission to
// access the Share's folder as a result of a user selecting it in a file
// picker. In order to retain access to it across restarts, it needs to
// hold on to a security-scoped bookmark. That bookmark is stored here. See
// https://developer.apple.com/documentation/security/app_sandbox/accessing_files_from_the_macos_app_sandbox#4144043
func (v ShareView) BookmarkData() views.ByteSlice[[]byte] {
return views.ByteSliceOf(v.ж.BookmarkData)
}

View File

@@ -17,7 +17,7 @@ import (
// Remote represents a remote Taildrive node.
type Remote struct {
Name string
URL string
URL func() string
Available func() bool
}

24
vendor/tailscale.com/drive/remote.go generated vendored
View File

@@ -9,7 +9,6 @@ import (
"bytes"
"errors"
"net/http"
"regexp"
"strings"
)
@@ -21,10 +20,6 @@ var (
ErrInvalidShareName = errors.New("Share names may only contain the letters a-z, underscore _, parentheses (), or spaces")
)
var (
shareNameRegex = regexp.MustCompile(`^[a-z0-9_\(\) ]+$`)
)
// AllowShareAs reports whether sharing files as a specific user is allowed.
func AllowShareAs() bool {
return !DisallowShareAs && doAllowShareAs()
@@ -125,9 +120,26 @@ func NormalizeShareName(name string) (string, error) {
// Trim whitespace
name = strings.TrimSpace(name)
if !shareNameRegex.MatchString(name) {
if !validShareName(name) {
return "", ErrInvalidShareName
}
return name, nil
}
func validShareName(name string) bool {
if name == "" {
return false
}
for _, r := range name {
if 'a' <= r && r <= 'z' || '0' <= r && r <= '9' {
continue
}
switch r {
case '_', ' ', '(', ')':
continue
}
return false
}
return true
}

View File

@@ -32,7 +32,7 @@ type grant struct {
Access string
}
// ParsePermissions builds a Permissions map from a lis of raw grants.
// ParsePermissions builds a Permissions map from a list of raw grants.
func ParsePermissions(rawGrants [][]byte) (Permissions, error) {
permissions := make(Permissions)
for _, rawGrant := range rawGrants {

View File

@@ -28,18 +28,19 @@ import (
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"tailscale.com/feature/buildfeatures"
"tailscale.com/kube/kubetypes"
"tailscale.com/syncs"
"tailscale.com/types/opt"
"tailscale.com/version"
"tailscale.com/version/distro"
)
var (
mu sync.Mutex
mu syncs.Mutex
// +checklocks:mu
set = map[string]string{}
// +checklocks:mu
@@ -463,7 +464,12 @@ var allowRemoteUpdate = RegisterBool("TS_ALLOW_ADMIN_CONSOLE_REMOTE_UPDATE")
// AllowsRemoteUpdate reports whether this node has opted-in to letting the
// Tailscale control plane initiate a Tailscale update (e.g. on behalf of an
// admin on the admin console).
func AllowsRemoteUpdate() bool { return allowRemoteUpdate() }
func AllowsRemoteUpdate() bool {
if !buildfeatures.HasClientUpdate {
return false
}
return allowRemoteUpdate()
}
// SetNoLogsNoSupport enables no-logs-no-support mode.
func SetNoLogsNoSupport() {
@@ -474,6 +480,9 @@ func SetNoLogsNoSupport() {
var notInInit atomic.Bool
func assertNotInInit() {
if !buildfeatures.HasDebug {
return
}
if notInInit.Load() {
return
}
@@ -533,6 +542,11 @@ func ApplyDiskConfigError() error { return applyDiskConfigErr }
// for App Store builds
// - /etc/tailscale/tailscaled-env.txt for tailscaled-on-macOS (homebrew, etc)
func ApplyDiskConfig() (err error) {
if runtime.GOOS == "linux" && !(buildfeatures.HasDebug || buildfeatures.HasSynology) {
// This function does nothing on Linux, unless you're
// using TS_DEBUG_ENV_FILE or are on Synology.
return nil
}
var f *os.File
defer func() {
if err != nil {
@@ -593,7 +607,7 @@ func getPlatformEnvFiles() []string {
filepath.Join(os.Getenv("ProgramData"), "Tailscale", "tailscaled-env.txt"),
}
case "linux":
if distro.Get() == distro.Synology {
if buildfeatures.HasSynology && distro.Get() == distro.Synology {
return []string{"/etc/tailscale/tailscaled-env.txt"}
}
case "darwin":

View File

@@ -10,7 +10,6 @@ import (
"runtime"
"tailscale.com/envknob"
"tailscale.com/hostinfo"
"tailscale.com/version"
"tailscale.com/version/distro"
)
@@ -26,21 +25,13 @@ func CanRunTailscaleSSH() error {
if distro.Get() == distro.QNAP && !envknob.UseWIPCode() {
return errors.New("The Tailscale SSH server does not run on QNAP.")
}
// Setting SSH on Home Assistant causes trouble on startup
// (since the flag is not being passed to `tailscale up`).
// Although Tailscale SSH does work here,
// it's not terribly useful since it's running in a separate container.
if hostinfo.GetEnvType() == hostinfo.HomeAssistantAddOn {
return errors.New("The Tailscale SSH server does not run on HomeAssistant.")
}
// otherwise okay
case "darwin":
// okay only in tailscaled mode for now.
if version.IsSandboxedMacOS() {
return errors.New("The Tailscale SSH server does not run in sandboxed Tailscale GUI builds.")
}
case "freebsd", "openbsd":
case "freebsd", "openbsd", "plan9":
default:
return errors.New("The Tailscale SSH server is not supported on " + runtime.GOOS)
}
@@ -58,10 +49,5 @@ func CanUseExitNode() error {
distro.QNAP:
return errors.New("Tailscale exit nodes cannot be used on " + string(dist))
}
if hostinfo.GetEnvType() == hostinfo.HomeAssistantAddOn {
return errors.New("Tailscale exit nodes cannot be used on HomeAssistant.")
}
return nil
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:generate go run gen.go
// The buildfeatures package contains boolean constants indicating which
// features were included in the binary (via build tags), for use in dead code
// elimination when using separate build tag protected files is impractical
// or undesirable.
package buildfeatures

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_ace
package buildfeatures
// HasACE is whether the binary was built with support for modular feature "Alternate Connectivity Endpoints".
// Specifically, it's whether the binary was NOT built with the "ts_omit_ace" build tag.
// It's a const so it can be used for dead code elimination.
const HasACE = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_ace
package buildfeatures
// HasACE is whether the binary was built with support for modular feature "Alternate Connectivity Endpoints".
// Specifically, it's whether the binary was NOT built with the "ts_omit_ace" build tag.
// It's a const so it can be used for dead code elimination.
const HasACE = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_acme
package buildfeatures
// HasACME is whether the binary was built with support for modular feature "ACME TLS certificate management".
// Specifically, it's whether the binary was NOT built with the "ts_omit_acme" build tag.
// It's a const so it can be used for dead code elimination.
const HasACME = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_acme
package buildfeatures
// HasACME is whether the binary was built with support for modular feature "ACME TLS certificate management".
// Specifically, it's whether the binary was NOT built with the "ts_omit_acme" build tag.
// It's a const so it can be used for dead code elimination.
const HasACME = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_advertiseexitnode
package buildfeatures
// HasAdvertiseExitNode is whether the binary was built with support for modular feature "Run an exit node".
// Specifically, it's whether the binary was NOT built with the "ts_omit_advertiseexitnode" build tag.
// It's a const so it can be used for dead code elimination.
const HasAdvertiseExitNode = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_advertiseexitnode
package buildfeatures
// HasAdvertiseExitNode is whether the binary was built with support for modular feature "Run an exit node".
// Specifically, it's whether the binary was NOT built with the "ts_omit_advertiseexitnode" build tag.
// It's a const so it can be used for dead code elimination.
const HasAdvertiseExitNode = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_advertiseroutes
package buildfeatures
// HasAdvertiseRoutes is whether the binary was built with support for modular feature "Advertise routes for other nodes to use".
// Specifically, it's whether the binary was NOT built with the "ts_omit_advertiseroutes" build tag.
// It's a const so it can be used for dead code elimination.
const HasAdvertiseRoutes = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_advertiseroutes
package buildfeatures
// HasAdvertiseRoutes is whether the binary was built with support for modular feature "Advertise routes for other nodes to use".
// Specifically, it's whether the binary was NOT built with the "ts_omit_advertiseroutes" build tag.
// It's a const so it can be used for dead code elimination.
const HasAdvertiseRoutes = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_appconnectors
package buildfeatures
// HasAppConnectors is whether the binary was built with support for modular feature "App Connectors support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_appconnectors" build tag.
// It's a const so it can be used for dead code elimination.
const HasAppConnectors = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_appconnectors
package buildfeatures
// HasAppConnectors is whether the binary was built with support for modular feature "App Connectors support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_appconnectors" build tag.
// It's a const so it can be used for dead code elimination.
const HasAppConnectors = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_aws
package buildfeatures
// HasAWS is whether the binary was built with support for modular feature "AWS integration".
// Specifically, it's whether the binary was NOT built with the "ts_omit_aws" build tag.
// It's a const so it can be used for dead code elimination.
const HasAWS = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_aws
package buildfeatures
// HasAWS is whether the binary was built with support for modular feature "AWS integration".
// Specifically, it's whether the binary was NOT built with the "ts_omit_aws" build tag.
// It's a const so it can be used for dead code elimination.
const HasAWS = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_bakedroots
package buildfeatures
// HasBakedRoots is whether the binary was built with support for modular feature "Embed CA (LetsEncrypt) x509 roots to use as fallback".
// Specifically, it's whether the binary was NOT built with the "ts_omit_bakedroots" build tag.
// It's a const so it can be used for dead code elimination.
const HasBakedRoots = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_bakedroots
package buildfeatures
// HasBakedRoots is whether the binary was built with support for modular feature "Embed CA (LetsEncrypt) x509 roots to use as fallback".
// Specifically, it's whether the binary was NOT built with the "ts_omit_bakedroots" build tag.
// It's a const so it can be used for dead code elimination.
const HasBakedRoots = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_bird
package buildfeatures
// HasBird is whether the binary was built with support for modular feature "Bird BGP integration".
// Specifically, it's whether the binary was NOT built with the "ts_omit_bird" build tag.
// It's a const so it can be used for dead code elimination.
const HasBird = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_bird
package buildfeatures
// HasBird is whether the binary was built with support for modular feature "Bird BGP integration".
// Specifically, it's whether the binary was NOT built with the "ts_omit_bird" build tag.
// It's a const so it can be used for dead code elimination.
const HasBird = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_c2n
package buildfeatures
// HasC2N is whether the binary was built with support for modular feature "Control-to-node (C2N) support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_c2n" build tag.
// It's a const so it can be used for dead code elimination.
const HasC2N = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_c2n
package buildfeatures
// HasC2N is whether the binary was built with support for modular feature "Control-to-node (C2N) support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_c2n" build tag.
// It's a const so it can be used for dead code elimination.
const HasC2N = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_cachenetmap
package buildfeatures
// HasCacheNetMap is whether the binary was built with support for modular feature "Cache the netmap on disk between runs".
// Specifically, it's whether the binary was NOT built with the "ts_omit_cachenetmap" build tag.
// It's a const so it can be used for dead code elimination.
const HasCacheNetMap = false

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