Compare commits

..

1 Commits

Author SHA1 Message Date
Andrew Lytvynov
776ab357b1 cmd/tailscale: add --json-docs flag
This prints all command and flag docs as JSON. To be used for generating
the contents of https://tailscale.com/kb/1080/cli.

Updates https://github.com/tailscale/tailscale-www/issues/4722
2024-08-08 08:06:18 -07:00
1167 changed files with 54868 additions and 101921 deletions

View File

@@ -1 +1 @@
suppress_failure_on_regression: true
suppress_failure_on_regression: true

4
.gitattributes vendored
View File

@@ -1,2 +1,2 @@
go.mod filter=go-mod
*.go diff=golang
go.mod filter=go-mod
*.go diff=golang

View File

@@ -1,81 +1,81 @@
name: Bug report
description: File a bug report. If you need help, contact support instead
labels: [needs-triage, bug]
body:
- type: markdown
attributes:
value: |
Need help with your tailnet? [Contact support](https://tailscale.com/contact/support) instead.
Otherwise, please check if your bug is [already filed](https://github.com/tailscale/tailscale/issues) before filing a new one.
- type: textarea
id: what-happened
attributes:
label: What is the issue?
description: What happened? What did you expect to happen?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: What are the steps you took that hit this issue?
validations:
required: false
- type: textarea
id: changes
attributes:
label: Are there any recent changes that introduced the issue?
description: If so, what are those changes?
validations:
required: false
- type: dropdown
id: os
attributes:
label: OS
description: What OS are you using? You may select more than one.
multiple: true
options:
- Linux
- macOS
- Windows
- iOS
- Android
- Synology
- Other
validations:
required: false
- type: input
id: os-version
attributes:
label: OS version
description: What OS version are you using?
placeholder: e.g., Debian 11.0, macOS Big Sur 11.6, Synology DSM 7
validations:
required: false
- type: input
id: ts-version
attributes:
label: Tailscale version
description: What Tailscale version are you using?
placeholder: e.g., 1.14.4
validations:
required: false
- type: textarea
id: other-software
attributes:
label: Other software
description: What [other software](https://github.com/tailscale/tailscale/wiki/OtherSoftwareInterop) (networking, security, etc) are you running?
validations:
required: false
- type: input
id: bug-report
attributes:
label: Bug report
description: Please run [`tailscale bugreport`](https://tailscale.com/kb/1080/cli/?q=Cli#bugreport) and share the bug identifier. The identifier is a random string which allows Tailscale support to locate your account and gives a point to focus on when looking for errors.
placeholder: e.g., BUG-1b7641a16971a9cd75822c0ed8043fee70ae88cf05c52981dc220eb96a5c49a8-20210427151443Z-fbcd4fd3a4b7ad94
validations:
required: false
- type: markdown
attributes:
value: |
Thanks for filing a bug report!
name: Bug report
description: File a bug report. If you need help, contact support instead
labels: [needs-triage, bug]
body:
- type: markdown
attributes:
value: |
Need help with your tailnet? [Contact support](https://tailscale.com/contact/support) instead.
Otherwise, please check if your bug is [already filed](https://github.com/tailscale/tailscale/issues) before filing a new one.
- type: textarea
id: what-happened
attributes:
label: What is the issue?
description: What happened? What did you expect to happen?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: What are the steps you took that hit this issue?
validations:
required: false
- type: textarea
id: changes
attributes:
label: Are there any recent changes that introduced the issue?
description: If so, what are those changes?
validations:
required: false
- type: dropdown
id: os
attributes:
label: OS
description: What OS are you using? You may select more than one.
multiple: true
options:
- Linux
- macOS
- Windows
- iOS
- Android
- Synology
- Other
validations:
required: false
- type: input
id: os-version
attributes:
label: OS version
description: What OS version are you using?
placeholder: e.g., Debian 11.0, macOS Big Sur 11.6, Synology DSM 7
validations:
required: false
- type: input
id: ts-version
attributes:
label: Tailscale version
description: What Tailscale version are you using?
placeholder: e.g., 1.14.4
validations:
required: false
- type: textarea
id: other-software
attributes:
label: Other software
description: What [other software](https://github.com/tailscale/tailscale/wiki/OtherSoftwareInterop) (networking, security, etc) are you running?
validations:
required: false
- type: input
id: bug-report
attributes:
label: Bug report
description: Please run [`tailscale bugreport`](https://tailscale.com/kb/1080/cli/?q=Cli#bugreport) and share the bug identifier. The identifier is a random string which allows Tailscale support to locate your account and gives a point to focus on when looking for errors.
placeholder: e.g., BUG-1b7641a16971a9cd75822c0ed8043fee70ae88cf05c52981dc220eb96a5c49a8-20210427151443Z-fbcd4fd3a4b7ad94
validations:
required: false
- type: markdown
attributes:
value: |
Thanks for filing a bug report!

View File

@@ -1,8 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Support
url: https://tailscale.com/contact/support/
about: Contact us for support
- name: Troubleshooting
url: https://tailscale.com/kb/1023/troubleshooting
blank_issues_enabled: true
contact_links:
- name: Support
url: https://tailscale.com/contact/support/
about: Contact us for support
- name: Troubleshooting
url: https://tailscale.com/kb/1023/troubleshooting
about: See the troubleshooting guide for help addressing common issues

View File

@@ -1,42 +1,42 @@
name: Feature request
description: Propose a new feature
title: "FR: "
labels: [needs-triage, fr]
body:
- type: markdown
attributes:
value: |
Please check if your feature request is [already filed](https://github.com/tailscale/tailscale/issues).
Tell us about your idea!
- type: textarea
id: problem
attributes:
label: What are you trying to do?
description: Tell us about the problem you're trying to solve.
validations:
required: false
- type: textarea
id: solution
attributes:
label: How should we solve this?
description: If you have an idea of how you'd like to see this feature work, let us know.
validations:
required: false
- type: textarea
id: alternative
attributes:
label: What is the impact of not solving this?
description: (How) Are you currently working around the issue?
validations:
required: false
- type: textarea
id: context
attributes:
label: Anything else?
description: Any additional context to share, e.g., links
validations:
required: false
- type: markdown
attributes:
value: |
Thanks for filing a feature request!
name: Feature request
description: Propose a new feature
title: "FR: "
labels: [needs-triage, fr]
body:
- type: markdown
attributes:
value: |
Please check if your feature request is [already filed](https://github.com/tailscale/tailscale/issues).
Tell us about your idea!
- type: textarea
id: problem
attributes:
label: What are you trying to do?
description: Tell us about the problem you're trying to solve.
validations:
required: false
- type: textarea
id: solution
attributes:
label: How should we solve this?
description: If you have an idea of how you'd like to see this feature work, let us know.
validations:
required: false
- type: textarea
id: alternative
attributes:
label: What is the impact of not solving this?
description: (How) Are you currently working around the issue?
validations:
required: false
- type: textarea
id: context
attributes:
label: Anything else?
description: Any additional context to share, e.g., links
validations:
required: false
- type: markdown
attributes:
value: |
Thanks for filing a feature request!

View File

@@ -1,21 +1,21 @@
# Documentation for this file can be found at:
# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates
version: 2
updates:
## Disabled between releases. We reenable it briefly after every
## stable release, pull in all changes, and close it again so that
## the tree remains more stable during development and the upstream
## changes have time to soak before the next release.
# - package-ecosystem: "gomod"
# directory: "/"
# schedule:
# interval: "daily"
# commit-message:
# prefix: "go.mod:"
# open-pull-requests-limit: 100
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: ".github:"
# Documentation for this file can be found at:
# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates
version: 2
updates:
## Disabled between releases. We reenable it briefly after every
## stable release, pull in all changes, and close it again so that
## the tree remains more stable during development and the upstream
## changes have time to soak before the next release.
# - package-ecosystem: "gomod"
# directory: "/"
# schedule:
# interval: "daily"
# commit-message:
# prefix: "go.mod:"
# open-pull-requests-limit: 100
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: ".github:"

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: [ ubuntu-latest ]
steps:
- name: Check out code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Build checklocks
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks

View File

@@ -45,17 +45,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
# Install a more recent Go that understands modern go.mod content.
- name: Install Go
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
uses: actions/setup-go@v4
with:
go-version-file: go.mod
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -66,7 +66,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -80,4 +80,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
uses: github/codeql-action/analyze@v2

View File

@@ -10,6 +10,6 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- uses: actions/checkout@v4
- name: "Build Docker image"
run: docker build .

View File

@@ -17,7 +17,7 @@ jobs:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- uses: "actions/checkout@v4"
with:
ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}"
- uses: "DeterminateSystems/nix-installer-action@main"

View File

@@ -23,18 +23,18 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- uses: actions/checkout@v4
- uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
- uses: actions/setup-go@v4
with:
go-version-file: go.mod
cache: false
- name: golangci-lint
# Note: this is the 'v6.1.0' tag as of 2024-08-21
uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86
# Note: this is the 'v3' tag as of 2023-08-14
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
with:
version: v1.60
version: v1.56
# Show only new issues if it's a pull request.
only-new-issues: true

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Install govulncheck
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
@@ -24,7 +24,7 @@ jobs:
- name: Post to slack
if: failure() && github.event_name == 'schedule'
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
uses: slackapi/slack-github-action@v1.24.0
env:
SLACK_BOT_TOKEN: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
with:

View File

@@ -98,7 +98,7 @@ jobs:
# We cannot use v4, as it requires a newer glibc version than some of the
# tested images provide. See
# https://github.com/actions/checkout/issues/1487
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
uses: actions/checkout@v3
- name: run installer
run: scripts/installer.sh
# Package installation can fail in docker because systemd is not running

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: [ ubuntu-latest ]
steps:
- name: Check out code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Build and lint Helm chart
run: |
eval `./tool/go run ./cmd/mkversion`

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Run SSH integration tests
run: |
make sshintegrationtest

View File

@@ -50,7 +50,7 @@ jobs:
- shard: '4/4'
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: build test wrapper
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: integration tests as root
@@ -78,9 +78,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@v3
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -150,16 +150,16 @@ jobs:
runs-on: windows-2022
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
uses: actions/setup-go@v4
with:
go-version-file: go.mod
cache: false
- name: Restore Cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@v3
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -190,7 +190,7 @@ jobs:
options: --privileged
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: chown
run: chown -R $(id -u):$(id -g) $PWD
- name: privileged tests
@@ -202,7 +202,7 @@ jobs:
if: github.repository == 'tailscale/tailscale'
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Run VM tests
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
env:
@@ -214,7 +214,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: build all
run: ./tool/go install -race ./cmd/...
- name: build tests
@@ -258,9 +258,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@v3
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -295,7 +295,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: build some
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
env:
@@ -317,9 +317,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@v3
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -350,7 +350,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
# Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed
# and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch
# some Android breakages early.
@@ -365,9 +365,9 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Restore Cache
uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
uses: actions/cache@v3
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
@@ -399,7 +399,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: test tailscale_go
run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/...
@@ -456,22 +456,18 @@ jobs:
fuzz-seconds: 300
dry-run: false
language: go
- name: Set artifacts_path in env (workaround for actions/upload-artifact#176)
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
run: |
echo "artifacts_path=$(realpath .)" >> $GITHUB_ENV
- name: upload crash
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
uses: actions/upload-artifact@v3
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
with:
name: artifacts
path: ${{ env.artifacts_path }}/out/artifacts
path: ./out/artifacts
depaware:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: check depaware
run: |
export PATH=$(./tool/go env GOROOT)/bin:$PATH
@@ -481,7 +477,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: check that 'go generate' is clean
run: |
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator|xdp')
@@ -494,7 +490,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: check that 'go mod tidy' is clean
run: |
./tool/go mod tidy
@@ -506,7 +502,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: check licenses
run: ./scripts/check_license_headers.sh .
@@ -522,7 +518,7 @@ jobs:
goarch: "386"
steps:
- name: checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: install staticcheck
run: GOBIN=~/.local/bin ./tool/go install honnef.co/go/tools/cmd/staticcheck
- name: run staticcheck
@@ -563,7 +559,7 @@ jobs:
# By having the job always run, but skipping its only step as needed, we
# let the CI output collapse nicely in PRs.
if: failure() && github.event_name == 'push'
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
uses: ruby/action-slack@v3.2.1
with:
payload: |
{
@@ -578,7 +574,6 @@ jobs:
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
check_mergeability:
if: always()
@@ -601,6 +596,6 @@ jobs:
steps:
- name: Decide if change is okay to merge
if: github.event_name != 'push'
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}

View File

@@ -21,22 +21,21 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Run update-flakes
run: ./update-flake.sh
- name: Get access token
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
id: generate-token
with:
app_id: ${{ secrets.LICENSING_APP_ID }}
installation_retrieval_mode: "id"
installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
- name: Send pull request
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
author: Flakes Updater <noreply+flakes-updater@tailscale.com>

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Run go get
run: |
@@ -23,19 +23,18 @@ jobs:
./tool/go mod tidy
- name: Get access token
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
id: generate-token
with:
# TODO(will): this should use the code updater app rather than licensing.
# It has the same permissions, so not a big deal, but still.
app_id: ${{ secrets.LICENSING_APP_ID }}
installation_retrieval_mode: "id"
installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
- name: Send pull request
id: pull-request
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
author: OSS Updater <noreply+oss-updater@tailscale.com>

View File

@@ -24,7 +24,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
uses: actions/checkout@v4
- name: Install deps
run: ./tool/yarn --cwd client/web
- name: Run lint

6
.gitignore vendored
View File

@@ -43,9 +43,3 @@ client/web/build/assets
/gocross
/dist
# Ignore xcode userstate and workspace data
*.xcuserstate
*.xcworkspacedata
/tstest/tailmac/bin
/tstest/tailmac/build

34
AUTHORS
View File

@@ -1,17 +1,17 @@
# This is the official list of Tailscale
# authors for copyright purposes.
#
# Names should be added to this file as one of
# Organization's name
# Individual's name <submission email address>
# Individual's name <submission email address> <email2> <emailN>
#
# Please keep the list sorted.
#
# You do not need to add entries to this list, and we don't actively
# populate this list. If you do want to be acknowledged explicitly as
# a copyright holder, though, then please send a PR referencing your
# earlier contributions and clarifying whether it's you or your
# company that owns the rights to your contribution.
Tailscale Inc.
# This is the official list of Tailscale
# authors for copyright purposes.
#
# Names should be added to this file as one of
# Organization's name
# Individual's name <submission email address>
# Individual's name <submission email address> <email2> <emailN>
#
# Please keep the list sorted.
#
# You do not need to add entries to this list, and we don't actively
# populate this list. If you do want to be acknowledged explicitly as
# a copyright holder, though, then please send a PR referencing your
# earlier contributions and clarifying whether it's you or your
# company that owns the rights to your contribution.
Tailscale Inc.

View File

@@ -1 +1 @@
/tailcfg/ @tailscale/control-protocol-owners
/tailcfg/ @tailscale/control-protocol-owners

View File

@@ -1,135 +1,135 @@
# Contributor Covenant 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.
## Our Standards
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
Examples of unacceptable behavior include:
* 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
## Enforcement Responsibilities
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.
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.
## 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.
## 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:
### 1. Correction
**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.
### 2. Warning
**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.
### 3. Temporary Ban
**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.
### 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.
**Consequence**: A permanent ban from any sort of public interaction
within the community.
## 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.
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.
# Contributor Covenant 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.
## Our Standards
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
Examples of unacceptable behavior include:
* 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
## Enforcement Responsibilities
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.
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.
## 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.
## 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:
### 1. Correction
**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.
### 2. Warning
**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.
### 3. Temporary Ban
**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.
### 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.
**Consequence**: A permanent ban from any sort of public interaction
within the community.
## 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.
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.

View File

@@ -27,7 +27,7 @@
# $ docker exec tailscaled tailscale status
FROM golang:1.23-alpine AS build-env
FROM golang:1.22-alpine AS build-env
WORKDIR /go/src/tailscale
@@ -42,7 +42,7 @@ RUN go install \
gvisor.dev/gvisor/pkg/tcpip/stack \
golang.org/x/crypto/ssh \
golang.org/x/crypto/acme \
github.com/coder/websocket \
nhooyr.io/websocket \
github.com/mdlayher/netlink
COPY . .

56
LICENSE
View File

@@ -1,28 +1,28 @@
BSD 3-Clause License
Copyright (c) 2020 Tailscale Inc & AUTHORS.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
BSD 3-Clause License
Copyright (c) 2020 Tailscale Inc & AUTHORS.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -100,7 +100,7 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie
@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)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-operator ./build_docker.sh
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=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)
@@ -117,8 +117,7 @@ sshintegrationtest: ## Run the SSH integration tests in various Docker container
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:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"

48
PATENTS
View File

@@ -1,24 +1,24 @@
Additional IP Rights Grant (Patents)
"This implementation" means the copyrightable works distributed by
Tailscale Inc. as part of the Tailscale project.
Tailscale Inc. hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable (except as stated
in this section) patent license to make, have made, use, offer to
sell, sell, import, transfer and otherwise run, modify and propagate
the contents of this implementation of Tailscale, where such license
applies only to those patent claims, both currently owned or
controlled by Tailscale Inc. and acquired in the future, licensable
by Tailscale Inc. that are necessarily infringed by this
implementation of Tailscale. This grant does not include claims that
would be infringed only as a consequence of further modification of
this implementation. If you or your agent or exclusive licensee
institute or order or agree to the institution of patent litigation
against any entity (including a cross-claim or counterclaim in a
lawsuit) alleging that this implementation of Tailscale or any code
incorporated within this implementation of Tailscale constitutes
direct or contributory patent infringement, or inducement of patent
infringement, then any patent rights granted to you under this License
for this implementation of Tailscale shall terminate as of the date
such litigation is filed.
Additional IP Rights Grant (Patents)
"This implementation" means the copyrightable works distributed by
Tailscale Inc. as part of the Tailscale project.
Tailscale Inc. hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable (except as stated
in this section) patent license to make, have made, use, offer to
sell, sell, import, transfer and otherwise run, modify and propagate
the contents of this implementation of Tailscale, where such license
applies only to those patent claims, both currently owned or
controlled by Tailscale Inc. and acquired in the future, licensable
by Tailscale Inc. that are necessarily infringed by this
implementation of Tailscale. This grant does not include claims that
would be infringed only as a consequence of further modification of
this implementation. If you or your agent or exclusive licensee
institute or order or agree to the institution of patent litigation
against any entity (including a cross-claim or counterclaim in a
lawsuit) alleging that this implementation of Tailscale or any code
incorporated within this implementation of Tailscale constitutes
direct or contributory patent infringement, or inducement of patent
infringement, then any patent rights granted to you under this License
for this implementation of Tailscale shall terminate as of the date
such litigation is filed.

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.22. (While we build
releases with our [Go fork](https://github.com/tailscale/go/), its use is not
required.)

View File

@@ -1,8 +1,8 @@
# Security Policy
## Reporting a Vulnerability
You can report vulnerabilities privately to
[security@tailscale.com](mailto:security@tailscale.com). Tailscale
staff will triage the issue, and work with you on a coordinated
disclosure timeline.
# Security Policy
## Reporting a Vulnerability
You can report vulnerabilities privately to
[security@tailscale.com](mailto:security@tailscale.com). Tailscale
staff will triage the issue, and work with you on a coordinated
disclosure timeline.

View File

@@ -1 +1 @@
1.78.0
1.71.0

102
api.md
View File

@@ -1,2 +1,104 @@
> [!IMPORTANT]
> The Tailscale API documentation has moved to https://tailscale.com/api
# Tailscale API
The Tailscale API documentation is located in **[tailscale/publicapi](./publicapi/readme.md#tailscale-api)**.
# APIs
**[Overview](./publicapi/readme.md)**
**[Device](./publicapi/device.md#device)**
<a href="device-delete"></a>
<a href="expire-device-key"></a>
<a href="device-routes-get">
<a href="device-routes-post"></a>
<a href="#device-authorized-post"></a>
<a href="device-tags-post"></a>
<a href="device-key-post"></a>
<a href="tailnet-acl-get"></a>
- Get a device: [`GET /api/v2/device/{deviceid}`](./publicapi/device.md#get-device)
- Delete a device: [`DELETE /api/v2/device/{deviceID}`](./publicapi/device.md#delete-device)
- Expire device key: [`POST /api/v2/device/{deviceID}/expire`](./publicapi/device.md#expire-device-key)
- [**Routes**](./publicapi/device.md#routes)
- Get device routes: [`GET /api/v2/device/{deviceID}/routes`](./publicapi/device.md#get-device-routes)
- Set device routes: [`POST /api/v2/device/{deviceID}/routes`](./publicapi/device.md#set-device-routes)
- [**Authorize**](./publicapi/device.md#authorize)
- Authorize a device: [`POST /api/v2/device/{deviceID}/authorized`](./publicapi/device.md#authorize-device)
- [**Tags**](./publicapi/device.md#tags)
- Update tags: [`POST /api/v2/device/{deviceID}/tags`](./publicapi/device.md#update-device-tags)
- [**Keys**](./publicapi/device.md#keys)
- Update device key: [`POST /api/v2/device/{deviceID}/key`](./publicapi/device.md#update-device-key)
- [**IP Addresses**](./publicapi/device.md#ip-addresses)
- Set device IPv4 address: [`POST /api/v2/device/{deviceID}/ip`](./publicapi/device.md#set-device-ipv4-address)
- [**Device posture attributes**](./publicapi/device.md#device-posture-attributes)
- Get device posture attributes: [`GET /api/v2/device/{deviceID}/attributes`](./publicapi/device.md#get-device-posture-attributes)
- Set custom device posture attributes: [`POST /api/v2/device/{deviceID}/attributes/{attributeKey}`](./publicapi/device.md#set-device-posture-attributes)
- Delete custom device posture attributes: [`DELETE /api/v2/device/{deviceID}/attributes/{attributeKey}`](./publicapi/device.md#delete-custom-device-posture-attributes)
- [**Device invites**](./publicapi/device.md#invites-to-a-device)
- List device invites: [`GET /api/v2/device/{deviceID}/device-invites`](./publicapi/device.md#list-device-invites)
- Create device invites: [`POST /api/v2/device/{deviceID}/device-invites`](./publicapi/device.md#create-device-invites)
**[Tailnet](./publicapi/tailnet.md#tailnet)**
<a href="tailnet-acl-post"></a>
<a href="tailnet-acl-preview-post"></a>
<a href="tailnet-acl-validate-post"></a>
<a href="tailnet-devices"></a>
<a href="tailnet-keys-get"></a>
<a href="tailnet-keys-post"></a>
<a href="tailnet-keys-key-get"></a>
<a href="tailnet-keys-key-delete"></a>
<a href="tailnet-dns"></a>
<a href="tailnet-dns-nameservers-get"></a>
<a href="tailnet-dns-nameservers-post"></a>
<a href="tailnet-dns-preferences-get"></a>
<a href="tailnet-dns-preferences-post"></a>
<a href="tailnet-dns-searchpaths-get"></a>
<a href="tailnet-dns-searchpaths-post"></a>
- [**Policy File**](./publicapi/tailnet.md#policy-file)
- Get policy file: [`GET /api/v2/tailnet/{tailnet}/acl`](./publicapi/tailnet.md#get-policy-file)
- Update policy file: [`POST /api/v2/tailnet/{tailnet}/acl`](./publicapi/tailnet.md#update-policy-file)
- Preview rule matches: [`POST /api/v2/tailnet/{tailnet}/acl/preview`](./publicapi/tailnet.md#preview-policy-file-rule-matches)
- Validate and test policy file: [`POST /api/v2/tailnet/{tailnet}/acl/validate`](./publicapi/tailnet.md#validate-and-test-policy-file)
- [**Devices**](./publicapi/tailnet.md#devices)
- List tailnet devices: [`GET /api/v2/tailnet/{tailnet}/devices`](./publicapi/tailnet.md#list-tailnet-devices)
- [**Keys**](./publicapi/tailnet.md#tailnet-keys)
- List tailnet keys: [`GET /api/v2/tailnet/{tailnet}/keys`](./publicapi/tailnet.md#list-tailnet-keys)
- Create an auth key: [`POST /api/v2/tailnet/{tailnet}/keys`](./publicapi/tailnet.md#create-auth-key)
- Get a key: [`GET /api/v2/tailnet/{tailnet}/keys/{keyid}`](./publicapi/tailnet.md#get-key)
- Delete a key: [`DELETE /api/v2/tailnet/{tailnet}/keys/{keyid}`](./publicapi/tailnet.md#delete-key)
- [**DNS**](./publicapi/tailnet.md#dns)
- [**Nameservers**](./publicapi/tailnet.md#nameservers)
- Get nameservers: [`GET /api/v2/tailnet/{tailnet}/dns/nameservers`](./publicapi/tailnet.md#get-nameservers)
- Set nameservers: [`POST /api/v2/tailnet/{tailnet}/dns/nameservers`](./publicapi/tailnet.md#set-nameservers)
- [**Preferences**](./publicapi/tailnet.md#preferences)
- Get DNS preferences: [`GET /api/v2/tailnet/{tailnet}/dns/preferences`](./publicapi/tailnet.md#get-dns-preferences)
- Set DNS preferences: [`POST /api/v2/tailnet/{tailnet}/dns/preferences`](./publicapi/tailnet.md#set-dns-preferences)
- [**Search Paths**](./publicapi/tailnet.md#search-paths)
- Get search paths: [`GET /api/v2/tailnet/{tailnet}/dns/searchpaths`](./publicapi/tailnet.md#get-search-paths)
- Set search paths: [`POST /api/v2/tailnet/{tailnet}/dns/searchpaths`](./publicapi/tailnet.md#set-search-paths)
- [**Split DNS**](./publicapi/tailnet.md#split-dns)
- Get split DNS: [`GET /api/v2/tailnet/{tailnet}/dns/split-dns`](./publicapi/tailnet.md#get-split-dns)
- Update split DNS: [`PATCH /api/v2/tailnet/{tailnet}/dns/split-dns`](./publicapi/tailnet.md#update-split-dns)
- Set split DNS: [`PUT /api/v2/tailnet/{tailnet}/dns/split-dns`](./publicapi/tailnet.md#set-split-dns)
- [**User invites**](./publicapi/tailnet.md#tailnet-user-invites)
- List user invites: [`GET /api/v2/tailnet/{tailnet}/user-invites`](./publicapi/tailnet.md#list-user-invites)
- Create user invites: [`POST /api/v2/tailnet/{tailnet}/user-invites`](./publicapi/tailnet.md#create-user-invites)
**[User invites](./publicapi/userinvites.md#user-invites)**
- Get user invite: [`GET /api/v2/user-invites/{userInviteId}`](./publicapi/userinvites.md#get-user-invite)
- Delete user invite: [`DELETE /api/v2/user-invites/{userInviteId}`](./publicapi/userinvites.md#delete-user-invite)
- Resend user invite (by email): [`POST /api/v2/user-invites/{userInviteId}/resend`](#resend-user-invite)
**[Device invites](./publicapi/deviceinvites.md#device-invites)**
- Get device invite: [`GET /api/v2/device-invites/{deviceInviteId}`](./publicapi/deviceinvites.md#get-device-invite)
- Delete device invite: [`DELETE /api/v2/device-invites/{deviceInviteId}`](./publicapi/deviceinvites.md#delete-device-invite)
- Resend device invite (by email): [`POST /api/v2/device-invites/{deviceInviteId}/resend`](./publicapi/deviceinvites.md#resend-device-invite)
- Accept device invite [`POST /api/v2/device-invites/-/accept`](#accept-device-invite)

View File

@@ -1,27 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build tailscale_go
package tailscaleroot
import (
"fmt"
"os"
"strings"
)
func init() {
tsRev, ok := tailscaleToolchainRev()
if !ok {
panic("binary built with tailscale_go build tag but failed to read build info or find tailscale.toolchain.rev in build info")
}
want := strings.TrimSpace(GoToolchainRev)
if tsRev != want {
if os.Getenv("TS_PERMIT_TOOLCHAIN_MISMATCH") == "1" {
fmt.Fprintf(os.Stderr, "tailscale.toolchain.rev = %q, want %q; but ignoring due to TS_PERMIT_TOOLCHAIN_MISMATCH=1\n", tsRev, want)
return
}
panic(fmt.Sprintf("binary built with tailscale_go build tag but Go toolchain %q doesn't match github.com/tailscale/tailscale expected value %q; override this failure with TS_PERMIT_TOOLCHAIN_MISMATCH=1", tsRev, want))
}
}

View File

@@ -1,51 +1,51 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package atomicfile contains code related to writing to filesystems
// atomically.
//
// This package should be considered internal; its API is not stable.
package atomicfile // import "tailscale.com/atomicfile"
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
// WriteFile writes data to filename+some suffix, then renames it into filename.
// The perm argument is ignored on Windows. If the target filename already
// exists but is not a regular file, WriteFile returns an error.
func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
fi, err := os.Stat(filename)
if err == nil && !fi.Mode().IsRegular() {
return fmt.Errorf("%s already exists and is not a regular file", filename)
}
f, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp")
if err != nil {
return err
}
tmpName := f.Name()
defer func() {
if err != nil {
f.Close()
os.Remove(tmpName)
}
}()
if _, err := f.Write(data); err != nil {
return err
}
if runtime.GOOS != "windows" {
if err := f.Chmod(perm); err != nil {
return err
}
}
if err := f.Sync(); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmpName, filename)
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package atomicfile contains code related to writing to filesystems
// atomically.
//
// This package should be considered internal; its API is not stable.
package atomicfile // import "tailscale.com/atomicfile"
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
// WriteFile writes data to filename+some suffix, then renames it into filename.
// The perm argument is ignored on Windows. If the target filename already
// exists but is not a regular file, WriteFile returns an error.
func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
fi, err := os.Stat(filename)
if err == nil && !fi.Mode().IsRegular() {
return fmt.Errorf("%s already exists and is not a regular file", filename)
}
f, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp")
if err != nil {
return err
}
tmpName := f.Name()
defer func() {
if err != nil {
f.Close()
os.Remove(tmpName)
}
}()
if _, err := f.Write(data); err != nil {
return err
}
if runtime.GOOS != "windows" {
if err := f.Chmod(perm); err != nil {
return err
}
}
if err := f.Sync(); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmpName, filename)
}

View File

@@ -1,47 +1,47 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !windows
package atomicfile
import (
"net"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestDoesNotOverwriteIrregularFiles(t *testing.T) {
// Per tailscale/tailscale#7658 as one example, almost any imagined use of
// atomicfile.Write should likely not attempt to overwrite an irregular file
// such as a device node, socket, or named pipe.
const filename = "TestDoesNotOverwriteIrregularFiles"
var path string
// macOS private temp does not allow unix socket creation, but /tmp does.
if runtime.GOOS == "darwin" {
path = filepath.Join("/tmp", filename)
t.Cleanup(func() { os.Remove(path) })
} else {
path = filepath.Join(t.TempDir(), filename)
}
// The least troublesome thing to make that is not a file is a unix socket.
// Making a null device sadly requires root.
l, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
if err != nil {
t.Fatal(err)
}
defer l.Close()
err = WriteFile(path, []byte("hello"), 0644)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "is not a regular file") {
t.Fatalf("unexpected error: %v", err)
}
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !windows
package atomicfile
import (
"net"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestDoesNotOverwriteIrregularFiles(t *testing.T) {
// Per tailscale/tailscale#7658 as one example, almost any imagined use of
// atomicfile.Write should likely not attempt to overwrite an irregular file
// such as a device node, socket, or named pipe.
const filename = "TestDoesNotOverwriteIrregularFiles"
var path string
// macOS private temp does not allow unix socket creation, but /tmp does.
if runtime.GOOS == "darwin" {
path = filepath.Join("/tmp", filename)
t.Cleanup(func() { os.Remove(path) })
} else {
path = filepath.Join(t.TempDir(), filename)
}
// The least troublesome thing to make that is not a file is a unix socket.
// Making a null device sadly requires root.
l, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
if err != nil {
t.Fatal(err)
}
defer l.Close()
err = WriteFile(path, []byte("hello"), 0644)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "is not a regular file") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -17,20 +17,12 @@ eval "$(./build_dist.sh shellvars)"
DEFAULT_TARGET="client"
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
DEFAULT_BASE="tailscale/alpine-base:3.18"
# 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.
# https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
DEFAULT_ANNOTATIONS="org.opencontainers.image.source=https://github.com/tailscale/tailscale/blob/main/build_docker.sh,org.opencontainers.image.vendor=Tailscale"
PUSH="${PUSH:-false}"
TARGET="${TARGET:-${DEFAULT_TARGET}}"
TAGS="${TAGS:-${DEFAULT_TAGS}}"
BASE="${BASE:-${DEFAULT_BASE}}"
PLATFORM="${PLATFORM:-}" # default to all platforms
# OCI annotations that will be added to the image.
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
ANNOTATIONS="${ANNOTATIONS:-${DEFAULT_ANNOTATIONS}}"
case "$TARGET" in
client)
@@ -51,10 +43,9 @@ case "$TARGET" in
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
--annotations="${ANNOTATIONS}" \
/usr/local/bin/containerboot
;;
k8s-operator)
operator)
DEFAULT_REPOS="tailscale/k8s-operator"
REPOS="${REPOS:-${DEFAULT_REPOS}}"
go run github.com/tailscale/mkctr \
@@ -65,11 +56,9 @@ case "$TARGET" in
-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}" \
/usr/local/bin/operator
;;
k8s-nameserver)
@@ -83,11 +72,9 @@ case "$TARGET" in
-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}" \
/usr/local/bin/k8s-nameserver
;;
*)

View File

@@ -1,163 +1,163 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package chirp implements a client to communicate with the BIRD Internet
// Routing Daemon.
package chirp
import (
"bufio"
"fmt"
"net"
"strings"
"time"
)
const (
// Maximum amount of time we should wait when reading a response from BIRD.
responseTimeout = 10 * time.Second
)
// New creates a BIRDClient.
func New(socket string) (*BIRDClient, error) {
return newWithTimeout(socket, responseTimeout)
}
func newWithTimeout(socket string, timeout time.Duration) (_ *BIRDClient, err error) {
conn, err := net.Dial("unix", socket)
if err != nil {
return nil, fmt.Errorf("failed to connect to BIRD: %w", err)
}
defer func() {
if err != nil {
conn.Close()
}
}()
b := &BIRDClient{
socket: socket,
conn: conn,
scanner: bufio.NewScanner(conn),
timeNow: time.Now,
timeout: timeout,
}
// Read and discard the first line as that is the welcome message.
if _, err := b.readResponse(); err != nil {
return nil, err
}
return b, nil
}
// BIRDClient handles communication with the BIRD Internet Routing Daemon.
type BIRDClient struct {
socket string
conn net.Conn
scanner *bufio.Scanner
timeNow func() time.Time
timeout time.Duration
}
// Close closes the underlying connection to BIRD.
func (b *BIRDClient) Close() error { return b.conn.Close() }
// DisableProtocol disables the provided protocol.
func (b *BIRDClient) DisableProtocol(protocol string) error {
out, err := b.exec("disable %s", protocol)
if err != nil {
return err
}
if strings.Contains(out, fmt.Sprintf("%s: already disabled", protocol)) {
return nil
} else if strings.Contains(out, fmt.Sprintf("%s: disabled", protocol)) {
return nil
}
return fmt.Errorf("failed to disable %s: %v", protocol, out)
}
// EnableProtocol enables the provided protocol.
func (b *BIRDClient) EnableProtocol(protocol string) error {
out, err := b.exec("enable %s", protocol)
if err != nil {
return err
}
if strings.Contains(out, fmt.Sprintf("%s: already enabled", protocol)) {
return nil
} else if strings.Contains(out, fmt.Sprintf("%s: enabled", protocol)) {
return nil
}
return fmt.Errorf("failed to enable %s: %v", protocol, out)
}
// BIRD CLI docs from https://bird.network.cz/?get_doc&v=20&f=prog-2.html#ss2.9
// Each session of the CLI consists of a sequence of request and replies,
// slightly resembling the FTP and SMTP protocols.
// Requests are commands encoded as a single line of text,
// replies are sequences of lines starting with a four-digit code
// followed by either a space (if it's the last line of the reply) or
// a minus sign (when the reply is going to continue with the next line),
// the rest of the line contains a textual message semantics of which depends on the numeric code.
// If a reply line has the same code as the previous one and it's a continuation line,
// the whole prefix can be replaced by a single white space character.
//
// Reply codes starting with 0 stand for action successfully completed messages,
// 1 means table entry, 8 runtime error and 9 syntax error.
func (b *BIRDClient) exec(cmd string, args ...any) (string, error) {
if err := b.conn.SetWriteDeadline(b.timeNow().Add(b.timeout)); err != nil {
return "", err
}
if _, err := fmt.Fprintf(b.conn, cmd, args...); err != nil {
return "", err
}
if _, err := fmt.Fprintln(b.conn); err != nil {
return "", err
}
return b.readResponse()
}
// hasResponseCode reports whether the provided byte slice is
// prefixed with a BIRD response code.
// Equivalent regex: `^\d{4}[ -]`.
func hasResponseCode(s []byte) bool {
if len(s) < 5 {
return false
}
for _, b := range s[:4] {
if '0' <= b && b <= '9' {
continue
}
return false
}
return s[4] == ' ' || s[4] == '-'
}
func (b *BIRDClient) readResponse() (string, error) {
// Set the read timeout before we start reading anything.
if err := b.conn.SetReadDeadline(b.timeNow().Add(b.timeout)); err != nil {
return "", err
}
var resp strings.Builder
var done bool
for !done {
if !b.scanner.Scan() {
if err := b.scanner.Err(); err != nil {
return "", err
}
return "", fmt.Errorf("reading response from bird failed (EOF): %q", resp.String())
}
out := b.scanner.Bytes()
if _, err := resp.Write(out); err != nil {
return "", err
}
if hasResponseCode(out) {
done = out[4] == ' '
}
if !done {
resp.WriteRune('\n')
}
}
return resp.String(), nil
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package chirp implements a client to communicate with the BIRD Internet
// Routing Daemon.
package chirp
import (
"bufio"
"fmt"
"net"
"strings"
"time"
)
const (
// Maximum amount of time we should wait when reading a response from BIRD.
responseTimeout = 10 * time.Second
)
// New creates a BIRDClient.
func New(socket string) (*BIRDClient, error) {
return newWithTimeout(socket, responseTimeout)
}
func newWithTimeout(socket string, timeout time.Duration) (_ *BIRDClient, err error) {
conn, err := net.Dial("unix", socket)
if err != nil {
return nil, fmt.Errorf("failed to connect to BIRD: %w", err)
}
defer func() {
if err != nil {
conn.Close()
}
}()
b := &BIRDClient{
socket: socket,
conn: conn,
scanner: bufio.NewScanner(conn),
timeNow: time.Now,
timeout: timeout,
}
// Read and discard the first line as that is the welcome message.
if _, err := b.readResponse(); err != nil {
return nil, err
}
return b, nil
}
// BIRDClient handles communication with the BIRD Internet Routing Daemon.
type BIRDClient struct {
socket string
conn net.Conn
scanner *bufio.Scanner
timeNow func() time.Time
timeout time.Duration
}
// Close closes the underlying connection to BIRD.
func (b *BIRDClient) Close() error { return b.conn.Close() }
// DisableProtocol disables the provided protocol.
func (b *BIRDClient) DisableProtocol(protocol string) error {
out, err := b.exec("disable %s", protocol)
if err != nil {
return err
}
if strings.Contains(out, fmt.Sprintf("%s: already disabled", protocol)) {
return nil
} else if strings.Contains(out, fmt.Sprintf("%s: disabled", protocol)) {
return nil
}
return fmt.Errorf("failed to disable %s: %v", protocol, out)
}
// EnableProtocol enables the provided protocol.
func (b *BIRDClient) EnableProtocol(protocol string) error {
out, err := b.exec("enable %s", protocol)
if err != nil {
return err
}
if strings.Contains(out, fmt.Sprintf("%s: already enabled", protocol)) {
return nil
} else if strings.Contains(out, fmt.Sprintf("%s: enabled", protocol)) {
return nil
}
return fmt.Errorf("failed to enable %s: %v", protocol, out)
}
// BIRD CLI docs from https://bird.network.cz/?get_doc&v=20&f=prog-2.html#ss2.9
// Each session of the CLI consists of a sequence of request and replies,
// slightly resembling the FTP and SMTP protocols.
// Requests are commands encoded as a single line of text,
// replies are sequences of lines starting with a four-digit code
// followed by either a space (if it's the last line of the reply) or
// a minus sign (when the reply is going to continue with the next line),
// the rest of the line contains a textual message semantics of which depends on the numeric code.
// If a reply line has the same code as the previous one and it's a continuation line,
// the whole prefix can be replaced by a single white space character.
//
// Reply codes starting with 0 stand for action successfully completed messages,
// 1 means table entry, 8 runtime error and 9 syntax error.
func (b *BIRDClient) exec(cmd string, args ...any) (string, error) {
if err := b.conn.SetWriteDeadline(b.timeNow().Add(b.timeout)); err != nil {
return "", err
}
if _, err := fmt.Fprintf(b.conn, cmd, args...); err != nil {
return "", err
}
if _, err := fmt.Fprintln(b.conn); err != nil {
return "", err
}
return b.readResponse()
}
// hasResponseCode reports whether the provided byte slice is
// prefixed with a BIRD response code.
// Equivalent regex: `^\d{4}[ -]`.
func hasResponseCode(s []byte) bool {
if len(s) < 5 {
return false
}
for _, b := range s[:4] {
if '0' <= b && b <= '9' {
continue
}
return false
}
return s[4] == ' ' || s[4] == '-'
}
func (b *BIRDClient) readResponse() (string, error) {
// Set the read timeout before we start reading anything.
if err := b.conn.SetReadDeadline(b.timeNow().Add(b.timeout)); err != nil {
return "", err
}
var resp strings.Builder
var done bool
for !done {
if !b.scanner.Scan() {
if err := b.scanner.Err(); err != nil {
return "", err
}
return "", fmt.Errorf("reading response from bird failed (EOF): %q", resp.String())
}
out := b.scanner.Bytes()
if _, err := resp.Write(out); err != nil {
return "", err
}
if hasResponseCode(out) {
done = out[4] == ' '
}
if !done {
resp.WriteRune('\n')
}
}
return resp.String(), nil
}

View File

@@ -1,192 +1,192 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package chirp
import (
"bufio"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
type fakeBIRD struct {
net.Listener
protocolsEnabled map[string]bool
sock string
}
func newFakeBIRD(t *testing.T, protocols ...string) *fakeBIRD {
sock := filepath.Join(t.TempDir(), "sock")
l, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
pe := make(map[string]bool)
for _, p := range protocols {
pe[p] = false
}
return &fakeBIRD{
Listener: l,
protocolsEnabled: pe,
sock: sock,
}
}
func (fb *fakeBIRD) listen() error {
for {
c, err := fb.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
go fb.handle(c)
}
}
func (fb *fakeBIRD) handle(c net.Conn) {
fmt.Fprintln(c, "0001 BIRD 2.0.8 ready.")
sc := bufio.NewScanner(c)
for sc.Scan() {
cmd := sc.Text()
args := strings.Split(cmd, " ")
switch args[0] {
case "enable":
en, ok := fb.protocolsEnabled[args[1]]
if !ok {
fmt.Fprintln(c, "9001 syntax error, unexpected CF_SYM_UNDEFINED, expecting CF_SYM_KNOWN or TEXT or ALL")
} else if en {
fmt.Fprintf(c, "0010-%s: already enabled\n", args[1])
} else {
fmt.Fprintf(c, "0011-%s: enabled\n", args[1])
}
fmt.Fprintln(c, "0000 ")
fb.protocolsEnabled[args[1]] = true
case "disable":
en, ok := fb.protocolsEnabled[args[1]]
if !ok {
fmt.Fprintln(c, "9001 syntax error, unexpected CF_SYM_UNDEFINED, expecting CF_SYM_KNOWN or TEXT or ALL")
} else if !en {
fmt.Fprintf(c, "0008-%s: already disabled\n", args[1])
} else {
fmt.Fprintf(c, "0009-%s: disabled\n", args[1])
}
fmt.Fprintln(c, "0000 ")
fb.protocolsEnabled[args[1]] = false
}
}
}
func TestChirp(t *testing.T) {
fb := newFakeBIRD(t, "tailscale")
defer fb.Close()
go fb.listen()
c, err := New(fb.sock)
if err != nil {
t.Fatal(err)
}
if err := c.EnableProtocol("tailscale"); err != nil {
t.Fatal(err)
}
if err := c.EnableProtocol("tailscale"); err != nil {
t.Fatal(err)
}
if err := c.DisableProtocol("tailscale"); err != nil {
t.Fatal(err)
}
if err := c.DisableProtocol("tailscale"); err != nil {
t.Fatal(err)
}
if err := c.EnableProtocol("rando"); err == nil {
t.Fatalf("enabling %q succeeded", "rando")
}
if err := c.DisableProtocol("rando"); err == nil {
t.Fatalf("disabling %q succeeded", "rando")
}
}
type hangingListener struct {
net.Listener
t *testing.T
done chan struct{}
wg sync.WaitGroup
sock string
}
func newHangingListener(t *testing.T) *hangingListener {
sock := filepath.Join(t.TempDir(), "sock")
l, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
return &hangingListener{
Listener: l,
t: t,
done: make(chan struct{}),
sock: sock,
}
}
func (hl *hangingListener) Stop() {
hl.Close()
close(hl.done)
hl.wg.Wait()
}
func (hl *hangingListener) listen() error {
for {
c, err := hl.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
hl.wg.Add(1)
go hl.handle(c)
}
}
func (hl *hangingListener) handle(c net.Conn) {
defer hl.wg.Done()
// Write our fake first line of response so that we get into the read loop
fmt.Fprintln(c, "0001 BIRD 2.0.8 ready.")
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
hl.t.Logf("connection still hanging")
case <-hl.done:
return
}
}
}
func TestChirpTimeout(t *testing.T) {
fb := newHangingListener(t)
defer fb.Stop()
go fb.listen()
c, err := newWithTimeout(fb.sock, 500*time.Millisecond)
if err != nil {
t.Fatal(err)
}
err = c.EnableProtocol("tailscale")
if err == nil {
t.Fatal("got err=nil, want timeout")
}
if !os.IsTimeout(err) {
t.Fatalf("got err=%v, want os.IsTimeout(err)=true", err)
}
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package chirp
import (
"bufio"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
type fakeBIRD struct {
net.Listener
protocolsEnabled map[string]bool
sock string
}
func newFakeBIRD(t *testing.T, protocols ...string) *fakeBIRD {
sock := filepath.Join(t.TempDir(), "sock")
l, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
pe := make(map[string]bool)
for _, p := range protocols {
pe[p] = false
}
return &fakeBIRD{
Listener: l,
protocolsEnabled: pe,
sock: sock,
}
}
func (fb *fakeBIRD) listen() error {
for {
c, err := fb.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
go fb.handle(c)
}
}
func (fb *fakeBIRD) handle(c net.Conn) {
fmt.Fprintln(c, "0001 BIRD 2.0.8 ready.")
sc := bufio.NewScanner(c)
for sc.Scan() {
cmd := sc.Text()
args := strings.Split(cmd, " ")
switch args[0] {
case "enable":
en, ok := fb.protocolsEnabled[args[1]]
if !ok {
fmt.Fprintln(c, "9001 syntax error, unexpected CF_SYM_UNDEFINED, expecting CF_SYM_KNOWN or TEXT or ALL")
} else if en {
fmt.Fprintf(c, "0010-%s: already enabled\n", args[1])
} else {
fmt.Fprintf(c, "0011-%s: enabled\n", args[1])
}
fmt.Fprintln(c, "0000 ")
fb.protocolsEnabled[args[1]] = true
case "disable":
en, ok := fb.protocolsEnabled[args[1]]
if !ok {
fmt.Fprintln(c, "9001 syntax error, unexpected CF_SYM_UNDEFINED, expecting CF_SYM_KNOWN or TEXT or ALL")
} else if !en {
fmt.Fprintf(c, "0008-%s: already disabled\n", args[1])
} else {
fmt.Fprintf(c, "0009-%s: disabled\n", args[1])
}
fmt.Fprintln(c, "0000 ")
fb.protocolsEnabled[args[1]] = false
}
}
}
func TestChirp(t *testing.T) {
fb := newFakeBIRD(t, "tailscale")
defer fb.Close()
go fb.listen()
c, err := New(fb.sock)
if err != nil {
t.Fatal(err)
}
if err := c.EnableProtocol("tailscale"); err != nil {
t.Fatal(err)
}
if err := c.EnableProtocol("tailscale"); err != nil {
t.Fatal(err)
}
if err := c.DisableProtocol("tailscale"); err != nil {
t.Fatal(err)
}
if err := c.DisableProtocol("tailscale"); err != nil {
t.Fatal(err)
}
if err := c.EnableProtocol("rando"); err == nil {
t.Fatalf("enabling %q succeeded", "rando")
}
if err := c.DisableProtocol("rando"); err == nil {
t.Fatalf("disabling %q succeeded", "rando")
}
}
type hangingListener struct {
net.Listener
t *testing.T
done chan struct{}
wg sync.WaitGroup
sock string
}
func newHangingListener(t *testing.T) *hangingListener {
sock := filepath.Join(t.TempDir(), "sock")
l, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
return &hangingListener{
Listener: l,
t: t,
done: make(chan struct{}),
sock: sock,
}
}
func (hl *hangingListener) Stop() {
hl.Close()
close(hl.done)
hl.wg.Wait()
}
func (hl *hangingListener) listen() error {
for {
c, err := hl.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
hl.wg.Add(1)
go hl.handle(c)
}
}
func (hl *hangingListener) handle(c net.Conn) {
defer hl.wg.Done()
// Write our fake first line of response so that we get into the read loop
fmt.Fprintln(c, "0001 BIRD 2.0.8 ready.")
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
hl.t.Logf("connection still hanging")
case <-hl.done:
return
}
}
}
func TestChirpTimeout(t *testing.T) {
fb := newHangingListener(t)
defer fb.Stop()
go fb.listen()
c, err := newWithTimeout(fb.sock, 500*time.Millisecond)
if err != nil {
t.Fatal(err)
}
err = c.EnableProtocol("tailscale")
if err == nil {
t.Fatal("got err=nil, want timeout")
}
if !os.IsTimeout(err) {
t.Fatalf("got err=%v, want os.IsTimeout(err)=true", err)
}
}

View File

@@ -19,7 +19,6 @@ import (
// Only one of Src/Dst or Users/Ports may be specified.
type ACLRow struct {
Action string `json:"action,omitempty"` // valid values: "accept"
Proto string `json:"proto,omitempty"` // protocol
Users []string `json:"users,omitempty"` // old name for src
Ports []string `json:"ports,omitempty"` // old name for dst
Src []string `json:"src,omitempty"`
@@ -32,7 +31,6 @@ type ACLRow struct {
type ACLTest struct {
Src string `json:"src,omitempty"` // source
User string `json:"user,omitempty"` // old name for source
Proto string `json:"proto,omitempty"` // protocol
Accept []string `json:"accept,omitempty"` // expected destination ip:port that user can access
Deny []string `json:"deny,omitempty"` // expected destination ip:port that user cannot access
@@ -288,9 +286,6 @@ type UserRuleMatch struct {
Users []string `json:"users"`
Ports []string `json:"ports"`
LineNumber int `json:"lineNumber"`
// Via is the list of targets through which Users can access Ports.
// See https://tailscale.com/kb/1378/via for more information.
Via []string `json:"via,omitempty"`
// Postures is a list of posture policies that are
// associated with this match. The rules can be looked

View File

@@ -4,10 +4,7 @@
// Package apitype contains types for the Tailscale LocalAPI and control plane API.
package apitype
import (
"tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
)
import "tailscale.com/tailcfg"
// LocalAPIHost is the Host header value used by the LocalAPI.
const LocalAPIHost = "local-tailscaled.sock"
@@ -60,19 +57,3 @@ type ExitNodeSuggestionResponse struct {
Name string
Location tailcfg.LocationView `json:",omitempty"`
}
// DNSOSConfig mimics dns.OSConfig without forcing us to import the entire dns package
// into the CLI.
type DNSOSConfig struct {
Nameservers []string
SearchDomains []string
MatchDomains []string
}
// DNSQueryResponse is the response to a DNS query request sent via LocalAPI.
type DNSQueryResponse struct {
// Bytes is the raw DNS response bytes.
Bytes []byte
// Resolvers is the list of resolvers that the forwarder deemed able to resolve the query.
Resolvers []*dnstype.Resolver
}

View File

@@ -1,19 +1,19 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package apitype
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"`
}
type DNSResolver struct {
Addr string `json:"addr"`
BootstrapResolution []string `json:"bootstrapResolution,omitempty"`
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package apitype
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"`
}
type DNSResolver struct {
Addr string `json:"addr"`
BootstrapResolution []string `json:"bootstrapResolution,omitempty"`
}

View File

@@ -1,233 +1,233 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
package tailscale
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"tailscale.com/client/tailscale/apitype"
)
// DNSNameServers is returned when retrieving the list of nameservers.
// It is also the structure provided when setting nameservers.
type DNSNameServers struct {
DNS []string `json:"dns"` // DNS name servers
}
// DNSNameServersPostResponse is returned when setting the list of DNS nameservers.
//
// It includes the MagicDNS status since nameservers changes may affect MagicDNS.
type DNSNameServersPostResponse struct {
DNS []string `json:"dns"` // DNS name servers
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
}
// DNSSearchpaths is the list of search paths for a given domain.
type DNSSearchPaths struct {
SearchPaths []string `json:"searchPaths"` // DNS search paths
}
// DNSPreferences is the preferences set for a given tailnet.
//
// It includes MagicDNS which can be turned on or off. To enable MagicDNS,
// there must be at least one nameserver. When all nameservers are removed,
// MagicDNS is disabled.
type DNSPreferences struct {
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
}
func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
return b, nil
}
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
data, err := json.Marshal(&postData)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
req.Header.Set("Content-Type", "application/json")
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
return b, nil
}
// DNSConfig retrieves the DNSConfig settings for a domain.
func (c *Client) DNSConfig(ctx context.Context) (cfg *apitype.DNSConfig, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.DNSConfig: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "config")
if err != nil {
return nil, err
}
var dnsResp apitype.DNSConfig
err = json.Unmarshal(b, &dnsResp)
return &dnsResp, err
}
func (c *Client) SetDNSConfig(ctx context.Context, cfg apitype.DNSConfig) (resp *apitype.DNSConfig, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetDNSConfig: %w", err)
}
}()
var dnsResp apitype.DNSConfig
b, err := c.dnsPOSTRequest(ctx, "config", cfg)
if err != nil {
return nil, err
}
err = json.Unmarshal(b, &dnsResp)
return &dnsResp, err
}
// NameServers retrieves the list of nameservers set for a domain.
func (c *Client) NameServers(ctx context.Context) (nameservers []string, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.NameServers: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "nameservers")
if err != nil {
return nil, err
}
var dnsResp DNSNameServers
err = json.Unmarshal(b, &dnsResp)
return dnsResp.DNS, err
}
// SetNameServers sets the list of nameservers for a tailnet to the list provided
// by the user.
//
// It returns the new list of nameservers and the MagicDNS status in case it was
// affected by the change. For example, removing all nameservers will turn off
// MagicDNS.
func (c *Client) SetNameServers(ctx context.Context, nameservers []string) (dnsResp *DNSNameServersPostResponse, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetNameServers: %w", err)
}
}()
dnsReq := DNSNameServers{DNS: nameservers}
b, err := c.dnsPOSTRequest(ctx, "nameservers", dnsReq)
if err != nil {
return nil, err
}
err = json.Unmarshal(b, &dnsResp)
return dnsResp, err
}
// DNSPreferences retrieves the DNS preferences set for a tailnet.
//
// It returns the status of MagicDNS.
func (c *Client) DNSPreferences(ctx context.Context) (dnsResp *DNSPreferences, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.DNSPreferences: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "preferences")
if err != nil {
return nil, err
}
err = json.Unmarshal(b, &dnsResp)
return dnsResp, err
}
// SetDNSPreferences sets the DNS preferences for a tailnet.
//
// MagicDNS can only be enabled when there is at least one nameserver provided.
// When all nameservers are removed, MagicDNS is disabled and will stay disabled,
// unless explicitly enabled by a user again.
func (c *Client) SetDNSPreferences(ctx context.Context, magicDNS bool) (dnsResp *DNSPreferences, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetDNSPreferences: %w", err)
}
}()
dnsReq := DNSPreferences{MagicDNS: magicDNS}
b, err := c.dnsPOSTRequest(ctx, "preferences", dnsReq)
if err != nil {
return
}
err = json.Unmarshal(b, &dnsResp)
return dnsResp, err
}
// SearchPaths retrieves the list of searchpaths set for a tailnet.
func (c *Client) SearchPaths(ctx context.Context) (searchpaths []string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SearchPaths: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "searchpaths")
if err != nil {
return nil, err
}
var dnsResp *DNSSearchPaths
err = json.Unmarshal(b, &dnsResp)
return dnsResp.SearchPaths, err
}
// SetSearchPaths sets the list of searchpaths for a tailnet.
func (c *Client) SetSearchPaths(ctx context.Context, searchpaths []string) (newSearchPaths []string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetSearchPaths: %w", err)
}
}()
dnsReq := DNSSearchPaths{SearchPaths: searchpaths}
b, err := c.dnsPOSTRequest(ctx, "searchpaths", dnsReq)
if err != nil {
return nil, err
}
var dnsResp DNSSearchPaths
err = json.Unmarshal(b, &dnsResp)
return dnsResp.SearchPaths, err
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
package tailscale
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"tailscale.com/client/tailscale/apitype"
)
// DNSNameServers is returned when retrieving the list of nameservers.
// It is also the structure provided when setting nameservers.
type DNSNameServers struct {
DNS []string `json:"dns"` // DNS name servers
}
// DNSNameServersPostResponse is returned when setting the list of DNS nameservers.
//
// It includes the MagicDNS status since nameservers changes may affect MagicDNS.
type DNSNameServersPostResponse struct {
DNS []string `json:"dns"` // DNS name servers
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
}
// DNSSearchpaths is the list of search paths for a given domain.
type DNSSearchPaths struct {
SearchPaths []string `json:"searchPaths"` // DNS search paths
}
// DNSPreferences is the preferences set for a given tailnet.
//
// It includes MagicDNS which can be turned on or off. To enable MagicDNS,
// there must be at least one nameserver. When all nameservers are removed,
// MagicDNS is disabled.
type DNSPreferences struct {
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
}
func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
return b, nil
}
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
data, err := json.Marshal(&postData)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
req.Header.Set("Content-Type", "application/json")
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
return b, nil
}
// DNSConfig retrieves the DNSConfig settings for a domain.
func (c *Client) DNSConfig(ctx context.Context) (cfg *apitype.DNSConfig, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.DNSConfig: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "config")
if err != nil {
return nil, err
}
var dnsResp apitype.DNSConfig
err = json.Unmarshal(b, &dnsResp)
return &dnsResp, err
}
func (c *Client) SetDNSConfig(ctx context.Context, cfg apitype.DNSConfig) (resp *apitype.DNSConfig, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetDNSConfig: %w", err)
}
}()
var dnsResp apitype.DNSConfig
b, err := c.dnsPOSTRequest(ctx, "config", cfg)
if err != nil {
return nil, err
}
err = json.Unmarshal(b, &dnsResp)
return &dnsResp, err
}
// NameServers retrieves the list of nameservers set for a domain.
func (c *Client) NameServers(ctx context.Context) (nameservers []string, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.NameServers: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "nameservers")
if err != nil {
return nil, err
}
var dnsResp DNSNameServers
err = json.Unmarshal(b, &dnsResp)
return dnsResp.DNS, err
}
// SetNameServers sets the list of nameservers for a tailnet to the list provided
// by the user.
//
// It returns the new list of nameservers and the MagicDNS status in case it was
// affected by the change. For example, removing all nameservers will turn off
// MagicDNS.
func (c *Client) SetNameServers(ctx context.Context, nameservers []string) (dnsResp *DNSNameServersPostResponse, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetNameServers: %w", err)
}
}()
dnsReq := DNSNameServers{DNS: nameservers}
b, err := c.dnsPOSTRequest(ctx, "nameservers", dnsReq)
if err != nil {
return nil, err
}
err = json.Unmarshal(b, &dnsResp)
return dnsResp, err
}
// DNSPreferences retrieves the DNS preferences set for a tailnet.
//
// It returns the status of MagicDNS.
func (c *Client) DNSPreferences(ctx context.Context) (dnsResp *DNSPreferences, err error) {
// Format return errors to be descriptive.
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.DNSPreferences: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "preferences")
if err != nil {
return nil, err
}
err = json.Unmarshal(b, &dnsResp)
return dnsResp, err
}
// SetDNSPreferences sets the DNS preferences for a tailnet.
//
// MagicDNS can only be enabled when there is at least one nameserver provided.
// When all nameservers are removed, MagicDNS is disabled and will stay disabled,
// unless explicitly enabled by a user again.
func (c *Client) SetDNSPreferences(ctx context.Context, magicDNS bool) (dnsResp *DNSPreferences, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetDNSPreferences: %w", err)
}
}()
dnsReq := DNSPreferences{MagicDNS: magicDNS}
b, err := c.dnsPOSTRequest(ctx, "preferences", dnsReq)
if err != nil {
return
}
err = json.Unmarshal(b, &dnsResp)
return dnsResp, err
}
// SearchPaths retrieves the list of searchpaths set for a tailnet.
func (c *Client) SearchPaths(ctx context.Context) (searchpaths []string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SearchPaths: %w", err)
}
}()
b, err := c.dnsGETRequest(ctx, "searchpaths")
if err != nil {
return nil, err
}
var dnsResp *DNSSearchPaths
err = json.Unmarshal(b, &dnsResp)
return dnsResp.SearchPaths, err
}
// SetSearchPaths sets the list of searchpaths for a tailnet.
func (c *Client) SetSearchPaths(ctx context.Context, searchpaths []string) (newSearchPaths []string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetSearchPaths: %w", err)
}
}()
dnsReq := DNSSearchPaths{SearchPaths: searchpaths}
b, err := c.dnsPOSTRequest(ctx, "searchpaths", dnsReq)
if err != nil {
return nil, err
}
var dnsResp DNSSearchPaths
err = json.Unmarshal(b, &dnsResp)
return dnsResp.SearchPaths, err
}

View File

@@ -1,28 +1,28 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// The servetls program shows how to run an HTTPS server
// using a Tailscale cert via LetsEncrypt.
package main
import (
"crypto/tls"
"io"
"log"
"net/http"
"tailscale.com/client/tailscale"
)
func main() {
s := &http.Server{
TLSConfig: &tls.Config{
GetCertificate: tailscale.GetCertificate,
},
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "<h1>Hello from Tailscale!</h1> It works.")
}),
}
log.Printf("Running TLS server on :443 ...")
log.Fatal(s.ListenAndServeTLS("", ""))
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// The servetls program shows how to run an HTTPS server
// using a Tailscale cert via LetsEncrypt.
package main
import (
"crypto/tls"
"io"
"log"
"net/http"
"tailscale.com/client/tailscale"
)
func main() {
s := &http.Server{
TLSConfig: &tls.Config{
GetCertificate: tailscale.GetCertificate,
},
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "<h1>Hello from Tailscale!</h1> It works.")
}),
}
log.Printf("Running TLS server on :443 ...")
log.Fatal(s.ListenAndServeTLS("", ""))
}

View File

@@ -1,166 +1,166 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailscale
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Key represents a Tailscale API or auth key.
type Key struct {
ID string `json:"id"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
Capabilities KeyCapabilities `json:"capabilities"`
}
// KeyCapabilities are the capabilities of a Key.
type KeyCapabilities struct {
Devices KeyDeviceCapabilities `json:"devices,omitempty"`
}
// KeyDeviceCapabilities are the device-related capabilities of a Key.
type KeyDeviceCapabilities struct {
Create KeyDeviceCreateCapabilities `json:"create"`
}
// KeyDeviceCreateCapabilities are the device creation capabilities of a Key.
type KeyDeviceCreateCapabilities struct {
Reusable bool `json:"reusable"`
Ephemeral bool `json:"ephemeral"`
Preauthorized bool `json:"preauthorized"`
Tags []string `json:"tags,omitempty"`
}
// Keys returns the list of keys for the current user.
func (c *Client) Keys(ctx context.Context) ([]string, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
var keys struct {
Keys []*Key `json:"keys"`
}
if err := json.Unmarshal(b, &keys); err != nil {
return nil, err
}
ret := make([]string, 0, len(keys.Keys))
for _, k := range keys.Keys {
ret = append(ret, k.ID)
}
return ret, nil
}
// CreateKey creates a new key for the current user. Currently, only auth keys
// can be created. It returns the secret key itself, which cannot be retrieved again
// later, and the key metadata.
//
// To create a key with a specific expiry, use CreateKeyWithExpiry.
func (c *Client) CreateKey(ctx context.Context, caps KeyCapabilities) (keySecret string, keyMeta *Key, _ error) {
return c.CreateKeyWithExpiry(ctx, caps, 0)
}
// CreateKeyWithExpiry is like CreateKey, but allows specifying a expiration time.
//
// The time is truncated to a whole number of seconds. If zero, that means no expiration.
func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities, expiry time.Duration) (keySecret string, keyMeta *Key, _ error) {
// convert expirySeconds to an int64 (seconds)
expirySeconds := int64(expiry.Seconds())
if expirySeconds < 0 {
return "", nil, fmt.Errorf("expiry must be positive")
}
if expirySeconds == 0 && expiry != 0 {
return "", nil, fmt.Errorf("non-zero expiry must be at least one second")
}
keyRequest := struct {
Capabilities KeyCapabilities `json:"capabilities"`
ExpirySeconds int64 `json:"expirySeconds,omitempty"`
}{caps, int64(expirySeconds)}
bs, err := json.Marshal(keyRequest)
if err != nil {
return "", nil, err
}
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(bs))
if err != nil {
return "", nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return "", nil, err
}
if resp.StatusCode != http.StatusOK {
return "", nil, handleErrorResponse(b, resp)
}
var key struct {
Key
Secret string `json:"key"`
}
if err := json.Unmarshal(b, &key); err != nil {
return "", nil, err
}
return key.Secret, &key.Key, nil
}
// Key returns the metadata for the given key ID. Currently, capabilities are
// only returned for auth keys, API keys only return general metadata.
func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
var key Key
if err := json.Unmarshal(b, &key); err != nil {
return nil, err
}
return &key, nil
}
// DeleteKey deletes the key with the given ID.
func (c *Client) DeleteKey(ctx context.Context, id string) error {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
if err != nil {
return err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return handleErrorResponse(b, resp)
}
return nil
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package tailscale
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Key represents a Tailscale API or auth key.
type Key struct {
ID string `json:"id"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
Capabilities KeyCapabilities `json:"capabilities"`
}
// KeyCapabilities are the capabilities of a Key.
type KeyCapabilities struct {
Devices KeyDeviceCapabilities `json:"devices,omitempty"`
}
// KeyDeviceCapabilities are the device-related capabilities of a Key.
type KeyDeviceCapabilities struct {
Create KeyDeviceCreateCapabilities `json:"create"`
}
// KeyDeviceCreateCapabilities are the device creation capabilities of a Key.
type KeyDeviceCreateCapabilities struct {
Reusable bool `json:"reusable"`
Ephemeral bool `json:"ephemeral"`
Preauthorized bool `json:"preauthorized"`
Tags []string `json:"tags,omitempty"`
}
// Keys returns the list of keys for the current user.
func (c *Client) Keys(ctx context.Context) ([]string, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
var keys struct {
Keys []*Key `json:"keys"`
}
if err := json.Unmarshal(b, &keys); err != nil {
return nil, err
}
ret := make([]string, 0, len(keys.Keys))
for _, k := range keys.Keys {
ret = append(ret, k.ID)
}
return ret, nil
}
// CreateKey creates a new key for the current user. Currently, only auth keys
// can be created. It returns the secret key itself, which cannot be retrieved again
// later, and the key metadata.
//
// To create a key with a specific expiry, use CreateKeyWithExpiry.
func (c *Client) CreateKey(ctx context.Context, caps KeyCapabilities) (keySecret string, keyMeta *Key, _ error) {
return c.CreateKeyWithExpiry(ctx, caps, 0)
}
// CreateKeyWithExpiry is like CreateKey, but allows specifying a expiration time.
//
// The time is truncated to a whole number of seconds. If zero, that means no expiration.
func (c *Client) CreateKeyWithExpiry(ctx context.Context, caps KeyCapabilities, expiry time.Duration) (keySecret string, keyMeta *Key, _ error) {
// convert expirySeconds to an int64 (seconds)
expirySeconds := int64(expiry.Seconds())
if expirySeconds < 0 {
return "", nil, fmt.Errorf("expiry must be positive")
}
if expirySeconds == 0 && expiry != 0 {
return "", nil, fmt.Errorf("non-zero expiry must be at least one second")
}
keyRequest := struct {
Capabilities KeyCapabilities `json:"capabilities"`
ExpirySeconds int64 `json:"expirySeconds,omitempty"`
}{caps, int64(expirySeconds)}
bs, err := json.Marshal(keyRequest)
if err != nil {
return "", nil, err
}
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys", c.baseURL(), c.tailnet)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(bs))
if err != nil {
return "", nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return "", nil, err
}
if resp.StatusCode != http.StatusOK {
return "", nil, handleErrorResponse(b, resp)
}
var key struct {
Key
Secret string `json:"key"`
}
if err := json.Unmarshal(b, &key); err != nil {
return "", nil, err
}
return key.Secret, &key.Key, nil
}
// Key returns the metadata for the given key ID. Currently, capabilities are
// only returned for auth keys, API keys only return general metadata.
func (c *Client) Key(ctx context.Context, id string) (*Key, error) {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
var key Key
if err := json.Unmarshal(b, &key); err != nil {
return nil, err
}
return &key, nil
}
// DeleteKey deletes the key with the given ID.
func (c *Client) DeleteKey(ctx context.Context, id string) error {
path := fmt.Sprintf("%s/api/v2/tailnet/%s/keys/%s", c.baseURL(), c.tailnet, id)
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
if err != nil {
return err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return handleErrorResponse(b, resp)
}
return nil
}

View File

@@ -37,10 +37,8 @@ import (
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/dnstype"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
"tailscale.com/util/syspolicy/setting"
)
// defaultLocalClient is the default LocalClient when using the legacy
@@ -71,14 +69,6 @@ type LocalClient struct {
// connecting to the GUI client variants.
UseSocketOnly bool
// OmitAuth, if true, omits sending the local Tailscale daemon any
// authentication token that might be required by the platform.
//
// As of 2024-08-12, only macOS uses an authentication token. OmitAuth is
// meant for when Dial is set and the LocalAPI is being proxied to a
// different operating system, such as in integration tests.
OmitAuth bool
// tsClient does HTTP requests to the local Tailscale daemon.
// It's lazily initialized on first use.
tsClient *http.Client
@@ -134,10 +124,8 @@ func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error)
},
}
})
if !lc.OmitAuth {
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
req.SetBasicAuth("", token)
}
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
req.SetBasicAuth("", token)
}
return lc.tsClient.Do(req)
}
@@ -355,12 +343,6 @@ func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/metrics")
}
// UserMetrics returns the user metrics in
// the Prometheus text exposition format.
func (lc *LocalClient) UserMetrics(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/usermetrics")
}
// IncrementCounter increments the value of a Tailscale daemon's counter
// metric by the given delta. If the metric has yet to exist, a new counter
// metric is created and initialized to delta.
@@ -493,17 +475,6 @@ func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
return nil
}
// DebugActionBody invokes a debug action with a body parameter, such as
// "debug-force-prefer-derp".
// These are development tools and subject to change or removal over time.
func (lc *LocalClient) DebugActionBody(ctx context.Context, action string, rbody io.Reader) error {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, rbody)
if err != nil {
return fmt.Errorf("error %w: %s", err, body)
}
return nil
}
// DebugResultJSON invokes a debug action and returns its result as something JSON-able.
// These are development tools and subject to change or removal over time.
func (lc *LocalClient) DebugResultJSON(ctx context.Context, action string) (any, error) {
@@ -826,62 +797,6 @@ func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn
return decodeJSON[*ipn.Prefs](body)
}
// GetEffectivePolicy returns the effective policy for the specified scope.
func (lc *LocalClient) 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 *LocalClient) 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 *LocalClient) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {
body, err := lc.get200(ctx, "/localapi/v0/dns-osconfig")
if err != nil {
return nil, err
}
var osCfg apitype.DNSOSConfig
if err := json.Unmarshal(body, &osCfg); err != nil {
return nil, fmt.Errorf("invalid dns.OSConfig: %w", err)
}
return &osCfg, nil
}
// QueryDNS executes a DNS query for a name (`google.com.`) and query type (`CNAME`).
// 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 *LocalClient) QueryDNS(ctx context.Context, name string, queryType string) (bytes []byte, resolvers []*dnstype.Resolver, err error) {
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
}
var res apitype.DNSQueryResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, nil, fmt.Errorf("invalid query response: %w", err)
}
return res.Bytes, res.Resolvers, nil
}
// StartLoginInteractive starts an interactive login.
func (lc *LocalClient) StartLoginInteractive(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil)
@@ -1338,17 +1253,6 @@ func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConf
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.
func (lc *LocalClient) DisconnectControl(ctx context.Context) error {
_, _, err := lc.sendWithHeaders(ctx, "POST", "/localapi/v0/disconnect-control", 200, nil, nil)
if err != nil {
return fmt.Errorf("error disconnecting control: %w", err)
}
return nil
}
// NetworkLockDisable shuts down network-lock across the tailnet.
func (lc *LocalClient) NetworkLockDisable(ctx context.Context, secret []byte) error {
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {

View File

@@ -1,10 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !go1.23
//go:build !go1.21
package tailscale
func init() {
you_need_Go_1_23_to_compile_Tailscale()
you_need_Go_1_21_to_compile_Tailscale()
}

View File

@@ -1,95 +1,95 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
package tailscale
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
)
// Routes contains the lists of subnet routes that are currently advertised by a device,
// as well as the subnets that are enabled to be routed by the device.
type Routes struct {
AdvertisedRoutes []netip.Prefix `json:"advertisedRoutes"`
EnabledRoutes []netip.Prefix `json:"enabledRoutes"`
}
// Routes retrieves the list of subnet routes that have been enabled for a device.
// The routes that are returned are not necessarily advertised by the device,
// they have only been preapproved.
func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.Routes: %w", err)
}
}()
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
var sr Routes
err = json.Unmarshal(b, &sr)
return &sr, err
}
type postRoutesParams struct {
Routes []netip.Prefix `json:"routes"`
}
// SetRoutes updates the list of subnets that are enabled for a device.
// Subnets must be parsable by net/netip.ParsePrefix.
// Subnets do not have to be currently advertised by a device, they may be pre-enabled.
// Returns the updated list of enabled and advertised subnet routes in a *Routes object.
func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netip.Prefix) (routes *Routes, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetRoutes: %w", err)
}
}()
params := &postRoutesParams{Routes: subnets}
data, err := json.Marshal(params)
if err != nil {
return nil, err
}
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
var srr *Routes
if err := json.Unmarshal(b, &srr); err != nil {
return nil, err
}
return srr, err
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
package tailscale
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
)
// Routes contains the lists of subnet routes that are currently advertised by a device,
// as well as the subnets that are enabled to be routed by the device.
type Routes struct {
AdvertisedRoutes []netip.Prefix `json:"advertisedRoutes"`
EnabledRoutes []netip.Prefix `json:"enabledRoutes"`
}
// Routes retrieves the list of subnet routes that have been enabled for a device.
// The routes that are returned are not necessarily advertised by the device,
// they have only been preapproved.
func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.Routes: %w", err)
}
}()
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
var sr Routes
err = json.Unmarshal(b, &sr)
return &sr, err
}
type postRoutesParams struct {
Routes []netip.Prefix `json:"routes"`
}
// SetRoutes updates the list of subnets that are enabled for a device.
// Subnets must be parsable by net/netip.ParsePrefix.
// Subnets do not have to be currently advertised by a device, they may be pre-enabled.
// Returns the updated list of enabled and advertised subnet routes in a *Routes object.
func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netip.Prefix) (routes *Routes, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.SetRoutes: %w", err)
}
}()
params := &postRoutesParams{Routes: subnets}
data, err := json.Marshal(params)
if err != nil {
return nil, err
}
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
if err != nil {
return nil, err
}
b, resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {
return nil, handleErrorResponse(b, resp)
}
var srr *Routes
if err := json.Unmarshal(b, &srr); err != nil {
return nil, err
}
return srr, err
}

View File

@@ -1,42 +1,42 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
package tailscale
import (
"context"
"fmt"
"net/http"
"net/url"
"tailscale.com/util/httpm"
)
// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.DeleteTailnet: %w", err)
}
}()
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
if err != nil {
return err
}
c.setAuth(req)
b, resp, err := c.sendRequest(req)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return handleErrorResponse(b, resp)
}
return nil
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19
package tailscale
import (
"context"
"fmt"
"net/http"
"net/url"
"tailscale.com/util/httpm"
)
// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("tailscale.DeleteTailnet: %w", err)
}
}()
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
if err != nil {
return err
}
c.setAuth(req)
b, resp, err := c.sendRequest(req)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return handleErrorResponse(b, resp)
}
return nil
}

View File

@@ -51,9 +51,6 @@ type Client struct {
// HTTPClient optionally specifies an alternate HTTP client to use.
// If nil, http.DefaultClient is used.
HTTPClient *http.Client
// UserAgent optionally specifies an alternate User-Agent header
UserAgent string
}
func (c *Client) httpClient() *http.Client {
@@ -100,9 +97,8 @@ func (c *Client) setAuth(r *http.Request) {
// and can be changed manually by the user.
func NewClient(tailnet string, auth AuthMethod) *Client {
return &Client{
tailnet: tailnet,
auth: auth,
UserAgent: "tailscale-client-oss",
tailnet: tailnet,
auth: auth,
}
}
@@ -114,16 +110,17 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
return nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
}
c.setAuth(req)
if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}
return c.httpClient().Do(req)
}
// sendRequest add the authentication key to the request and sends it. It
// receives the response and reads up to 10MB of it.
func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
resp, err := c.Do(req)
if !I_Acknowledge_This_API_Is_Unstable {
return nil, nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
}
c.setAuth(req)
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, resp, err
}

View File

@@ -1,127 +1,127 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// qnap.go contains handlers and logic, such as authentication,
// that is specific to running the web client on QNAP.
package web
import (
"crypto/tls"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
)
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
// are authorized to use the web client.
// If the user is not authorized to use the client, an error is returned.
func authorizeQNAP(r *http.Request) (authorized bool, err error) {
_, resp, err := qnapAuthn(r)
if err != nil {
return false, err
}
if resp.IsAdmin == 0 {
return false, errors.New("user is not an admin")
}
return true, nil
}
type qnapAuthResponse struct {
AuthPassed int `xml:"authPassed"`
IsAdmin int `xml:"isAdmin"`
AuthSID string `xml:"authSid"`
ErrorValue int `xml:"errorValue"`
}
func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
user, err := r.Cookie("NAS_USER")
if err != nil {
return "", nil, err
}
token, err := r.Cookie("qtoken")
if err == nil {
return qnapAuthnQtoken(r, user.Value, token.Value)
}
sid, err := r.Cookie("NAS_SID")
if err == nil {
return qnapAuthnSid(r, user.Value, sid.Value)
}
return "", nil, fmt.Errorf("not authenticated by any mechanism")
}
// qnapAuthnURL returns the auth URL to use by inferring where the UI is
// running based on the request URL. This is necessary because QNAP has so
// many options, see https://github.com/tailscale/tailscale/issues/7108
// and https://github.com/tailscale/tailscale/issues/6903
func qnapAuthnURL(requestUrl string, query url.Values) string {
in, err := url.Parse(requestUrl)
scheme := ""
host := ""
if err != nil || in.Scheme == "" {
log.Printf("Cannot parse QNAP login URL %v", err)
// try localhost and hope for the best
scheme = "http"
host = "localhost"
} else {
scheme = in.Scheme
host = in.Host
}
u := url.URL{
Scheme: scheme,
Host: host,
Path: "/cgi-bin/authLogin.cgi",
RawQuery: query.Encode(),
}
return u.String()
}
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
query := url.Values{
"qtoken": []string{token},
"user": []string{user},
}
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
}
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
query := url.Values{
"sid": []string{sid},
}
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
}
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
// QNAP Force HTTPS mode uses a self-signed certificate. Even importing
// the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
// SAN. See https://github.com/tailscale/tailscale/issues/6903
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
resp, err := client.Get(url)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, err
}
authResp := &qnapAuthResponse{}
if err := xml.Unmarshal(out, authResp); err != nil {
return "", nil, err
}
if authResp.AuthPassed == 0 {
return "", nil, fmt.Errorf("not authenticated")
}
return user, authResp, nil
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// qnap.go contains handlers and logic, such as authentication,
// that is specific to running the web client on QNAP.
package web
import (
"crypto/tls"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
)
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
// are authorized to use the web client.
// If the user is not authorized to use the client, an error is returned.
func authorizeQNAP(r *http.Request) (authorized bool, err error) {
_, resp, err := qnapAuthn(r)
if err != nil {
return false, err
}
if resp.IsAdmin == 0 {
return false, errors.New("user is not an admin")
}
return true, nil
}
type qnapAuthResponse struct {
AuthPassed int `xml:"authPassed"`
IsAdmin int `xml:"isAdmin"`
AuthSID string `xml:"authSid"`
ErrorValue int `xml:"errorValue"`
}
func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
user, err := r.Cookie("NAS_USER")
if err != nil {
return "", nil, err
}
token, err := r.Cookie("qtoken")
if err == nil {
return qnapAuthnQtoken(r, user.Value, token.Value)
}
sid, err := r.Cookie("NAS_SID")
if err == nil {
return qnapAuthnSid(r, user.Value, sid.Value)
}
return "", nil, fmt.Errorf("not authenticated by any mechanism")
}
// qnapAuthnURL returns the auth URL to use by inferring where the UI is
// running based on the request URL. This is necessary because QNAP has so
// many options, see https://github.com/tailscale/tailscale/issues/7108
// and https://github.com/tailscale/tailscale/issues/6903
func qnapAuthnURL(requestUrl string, query url.Values) string {
in, err := url.Parse(requestUrl)
scheme := ""
host := ""
if err != nil || in.Scheme == "" {
log.Printf("Cannot parse QNAP login URL %v", err)
// try localhost and hope for the best
scheme = "http"
host = "localhost"
} else {
scheme = in.Scheme
host = in.Host
}
u := url.URL{
Scheme: scheme,
Host: host,
Path: "/cgi-bin/authLogin.cgi",
RawQuery: query.Encode(),
}
return u.String()
}
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
query := url.Values{
"qtoken": []string{token},
"user": []string{user},
}
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
}
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
query := url.Values{
"sid": []string{sid},
}
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
}
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
// QNAP Force HTTPS mode uses a self-signed certificate. Even importing
// the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
// SAN. See https://github.com/tailscale/tailscale/issues/6903
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
resp, err := client.Get(url)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
if err != nil {
return "", nil, err
}
authResp := &qnapAuthResponse{}
if err := xml.Unmarshal(out, authResp); err != nil {
return "", nil, err
}
if authResp.AuthPassed == 0 {
return "", nil, fmt.Errorf("not authenticated")
}
return user, authResp, nil
}

View File

@@ -1,4 +1,4 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12.5H19" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 5.5L19 12.5L12 19.5" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12.5H19" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 5.5L19 12.5L12 19.5" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 328 B

After

Width:  |  Height:  |  Size: 324 B

View File

@@ -1,5 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 12L12 8L8 12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 16V8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 12L12 8L8 12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 16V8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 527 B

After

Width:  |  Height:  |  Size: 522 B

View File

@@ -1,4 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 4L12 14.01L9 11.01" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 4L12 14.01L9 11.01" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 704 B

View File

@@ -1,3 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 5L7.50065 14.1667L3.33398 10" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 5L7.50065 14.1667L3.33398 10" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 239 B

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -1,3 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 7.5L10 12.5L15 7.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 7.5L10 12.5L15 7.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 206 B

After

Width:  |  Height:  |  Size: 203 B

View File

@@ -1,11 +1,11 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_15367_14595)">
<path d="M0.625 8C0.625 8 3.125 3 7.5 3C11.875 3 14.375 8 14.375 8C14.375 8 11.875 13 7.5 13C3.125 13 0.625 8 0.625 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 9.875C8.53553 9.875 9.375 9.03553 9.375 8C9.375 6.96447 8.53553 6.125 7.5 6.125C6.46447 6.125 5.625 6.96447 5.625 8C5.625 9.03553 6.46447 9.875 7.5 9.875Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_15367_14595">
<rect width="15" height="15" fill="white" transform="translate(0 0.5)"/>
</clipPath>
</defs>
</svg>
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_15367_14595)">
<path d="M0.625 8C0.625 8 3.125 3 7.5 3C11.875 3 14.375 8 14.375 8C14.375 8 11.875 13 7.5 13C3.125 13 0.625 8 0.625 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 9.875C8.53553 9.875 9.375 9.03553 9.375 8C9.375 6.96447 8.53553 6.125 7.5 6.125C6.46447 6.125 5.625 6.96447 5.625 8C5.625 9.03553 6.46447 9.875 7.5 9.875Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_15367_14595">
<rect width="15" height="15" fill="white" transform="translate(0 0.5)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 738 B

View File

@@ -1,4 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.5 17.5L13.875 13.875" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.5 17.5L13.875 13.875" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 500 B

View File

@@ -1,18 +1,18 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_13627_11860)">
<path opacity="0.2" d="M3.8696 6.77137C5.56662 6.77137 6.94233 5.39567 6.94233 3.69865C6.94233 2.00163 5.56662 0.625919 3.8696 0.625919C2.17258 0.625919 0.796875 2.00163 0.796875 3.69865C0.796875 5.39567 2.17258 6.77137 3.8696 6.77137Z" fill="black"/>
<path d="M3.8696 15.9327C5.56662 15.9327 6.94233 14.5569 6.94233 12.8599C6.94233 11.1629 5.56662 9.7872 3.8696 9.7872C2.17258 9.7872 0.796875 11.1629 0.796875 12.8599C0.796875 14.5569 2.17258 15.9327 3.8696 15.9327Z" fill="black"/>
<path opacity="0.2" d="M3.8696 25.2646C5.56662 25.2646 6.94233 23.8889 6.94233 22.1919C6.94233 20.4949 5.56662 19.1192 3.8696 19.1192C2.17258 19.1192 0.796875 20.4949 0.796875 22.1919C0.796875 23.8889 2.17258 25.2646 3.8696 25.2646Z" fill="black"/>
<path d="M13.0879 15.9327C14.7849 15.9327 16.1606 14.5569 16.1606 12.8599C16.1606 11.1629 14.7849 9.7872 13.0879 9.7872C11.3908 9.7872 10.0151 11.1629 10.0151 12.8599C10.0151 14.5569 11.3908 15.9327 13.0879 15.9327Z" fill="black"/>
<path d="M13.0879 25.2646C14.7849 25.2646 16.1606 23.8889 16.1606 22.1919C16.1606 20.4949 14.7849 19.1192 13.0879 19.1192C11.3908 19.1192 10.0151 20.4949 10.0151 22.1919C10.0151 23.8889 11.3908 25.2646 13.0879 25.2646Z" fill="black"/>
<path opacity="0.2" d="M13.0879 6.77137C14.7849 6.77137 16.1606 5.39567 16.1606 3.69865C16.1606 2.00163 14.7849 0.625919 13.0879 0.625919C11.3908 0.625919 10.0151 2.00163 10.0151 3.69865C10.0151 5.39567 11.3908 6.77137 13.0879 6.77137Z" fill="black"/>
<path opacity="0.2" d="M22.1919 6.77137C23.8889 6.77137 25.2646 5.39567 25.2646 3.69865C25.2646 2.00163 23.8889 0.625919 22.1919 0.625919C20.4948 0.625919 19.1191 2.00163 19.1191 3.69865C19.1191 5.39567 20.4948 6.77137 22.1919 6.77137Z" fill="black"/>
<path d="M22.1919 15.9327C23.8889 15.9327 25.2646 14.5569 25.2646 12.8599C25.2646 11.1629 23.8889 9.7872 22.1919 9.7872C20.4948 9.7872 19.1191 11.1629 19.1191 12.8599C19.1191 14.5569 20.4948 15.9327 22.1919 15.9327Z" fill="black"/>
<path opacity="0.2" d="M22.1919 25.2646C23.8889 25.2646 25.2646 23.8889 25.2646 22.1919C25.2646 20.4949 23.8889 19.1192 22.1919 19.1192C20.4948 19.1192 19.1191 20.4949 19.1191 22.1919C19.1191 23.8889 20.4948 25.2646 22.1919 25.2646Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_13627_11860">
<rect width="26" height="26" fill="white"/>
</clipPath>
</defs>
</svg>
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_13627_11860)">
<path opacity="0.2" d="M3.8696 6.77137C5.56662 6.77137 6.94233 5.39567 6.94233 3.69865C6.94233 2.00163 5.56662 0.625919 3.8696 0.625919C2.17258 0.625919 0.796875 2.00163 0.796875 3.69865C0.796875 5.39567 2.17258 6.77137 3.8696 6.77137Z" fill="black"/>
<path d="M3.8696 15.9327C5.56662 15.9327 6.94233 14.5569 6.94233 12.8599C6.94233 11.1629 5.56662 9.7872 3.8696 9.7872C2.17258 9.7872 0.796875 11.1629 0.796875 12.8599C0.796875 14.5569 2.17258 15.9327 3.8696 15.9327Z" fill="black"/>
<path opacity="0.2" d="M3.8696 25.2646C5.56662 25.2646 6.94233 23.8889 6.94233 22.1919C6.94233 20.4949 5.56662 19.1192 3.8696 19.1192C2.17258 19.1192 0.796875 20.4949 0.796875 22.1919C0.796875 23.8889 2.17258 25.2646 3.8696 25.2646Z" fill="black"/>
<path d="M13.0879 15.9327C14.7849 15.9327 16.1606 14.5569 16.1606 12.8599C16.1606 11.1629 14.7849 9.7872 13.0879 9.7872C11.3908 9.7872 10.0151 11.1629 10.0151 12.8599C10.0151 14.5569 11.3908 15.9327 13.0879 15.9327Z" fill="black"/>
<path d="M13.0879 25.2646C14.7849 25.2646 16.1606 23.8889 16.1606 22.1919C16.1606 20.4949 14.7849 19.1192 13.0879 19.1192C11.3908 19.1192 10.0151 20.4949 10.0151 22.1919C10.0151 23.8889 11.3908 25.2646 13.0879 25.2646Z" fill="black"/>
<path opacity="0.2" d="M13.0879 6.77137C14.7849 6.77137 16.1606 5.39567 16.1606 3.69865C16.1606 2.00163 14.7849 0.625919 13.0879 0.625919C11.3908 0.625919 10.0151 2.00163 10.0151 3.69865C10.0151 5.39567 11.3908 6.77137 13.0879 6.77137Z" fill="black"/>
<path opacity="0.2" d="M22.1919 6.77137C23.8889 6.77137 25.2646 5.39567 25.2646 3.69865C25.2646 2.00163 23.8889 0.625919 22.1919 0.625919C20.4948 0.625919 19.1191 2.00163 19.1191 3.69865C19.1191 5.39567 20.4948 6.77137 22.1919 6.77137Z" fill="black"/>
<path d="M22.1919 15.9327C23.8889 15.9327 25.2646 14.5569 25.2646 12.8599C25.2646 11.1629 23.8889 9.7872 22.1919 9.7872C20.4948 9.7872 19.1191 11.1629 19.1191 12.8599C19.1191 14.5569 20.4948 15.9327 22.1919 15.9327Z" fill="black"/>
<path opacity="0.2" d="M22.1919 25.2646C23.8889 25.2646 25.2646 23.8889 25.2646 22.1919C25.2646 20.4949 23.8889 19.1192 22.1919 19.1192C20.4948 19.1192 19.1191 20.4949 19.1191 22.1919C19.1191 23.8889 20.4948 25.2646 22.1919 25.2646Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_13627_11860">
<rect width="26" height="26" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,20 +1,20 @@
<svg width="121" height="22" viewBox="0 0 121 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="2.69191" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
<ellipse cx="10.7676" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
<ellipse opacity="0.2" cx="2.69191" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
<circle opacity="0.2" cx="18.8433" cy="18.8434" r="2.69191" fill="#141414"/>
<ellipse cx="10.7676" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
<circle cx="18.8433" cy="10.7677" r="2.69191" fill="#141414"/>
<ellipse opacity="0.2" cx="2.69191" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
<ellipse opacity="0.2" cx="10.7676" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
<circle opacity="0.2" cx="18.8433" cy="2.69191" r="2.69191" fill="#141414"/>
<path d="M37.8847 19.9603C38.6525 19.9603 39.2764 19.8883 40.0202 19.7443V16.9609C39.5643 17.1289 39.0605 17.1769 38.5806 17.1769C37.4048 17.1769 36.9729 16.601 36.9729 15.4973V9.83453H40.0202V7.05116H36.9729V2.92409H33.6137V7.05116H31.4302V9.83453H33.6137V15.8092C33.6137 18.4486 35.0054 19.9603 37.8847 19.9603Z" fill="#141414"/>
<path d="M45.5064 19.9603C47.306 19.9603 48.5057 19.3604 49.1056 18.4246C49.1536 18.8325 49.2975 19.3844 49.4895 19.7203H52.5128C52.3448 19.1444 52.2249 18.2326 52.2249 17.6328V11.0583C52.2249 8.34687 50.2813 6.81121 46.994 6.81121C44.4986 6.81121 42.555 7.747 41.4753 9.1147L43.3949 11.0103C44.2587 10.0505 45.3624 9.5466 46.7061 9.5466C48.3377 9.5466 49.0576 10.0985 49.0576 10.9143C49.0576 11.6101 48.5777 12.09 45.9863 12.09C43.4908 12.09 40.9714 13.1218 40.9714 16.0011C40.9714 18.6645 42.891 19.9603 45.5064 19.9603ZM46.1782 17.4168C44.8825 17.4168 44.2827 16.8649 44.2827 15.8812C44.2827 15.0174 45.0025 14.4415 46.2022 14.4415C48.1218 14.4415 48.6497 14.3215 49.0576 13.9136V14.9454C49.0576 16.3131 47.9058 17.4168 46.1782 17.4168Z" fill="#141414"/>
<path d="M54.4086 5.44352H57.9118V2.30023H54.4086V5.44352ZM54.4805 19.7203H57.8398V7.05116H54.4805V19.7203Z" fill="#141414"/>
<path d="M60.287 19.7203H63.6463V2.68414H60.287V19.7203Z" fill="#141414"/>
<path d="M70.6285 19.9603C74.3237 19.9603 76.2193 18.0167 76.2193 15.9771C76.2193 14.1296 75.2835 12.7619 72.2122 12.21C70.0527 11.8261 68.709 11.3462 68.709 10.6024C68.709 9.95451 69.4768 9.49861 70.7725 9.49861C71.9242 9.49861 72.884 9.88252 73.6038 10.7223L75.7394 8.92274C74.6596 7.57904 72.884 6.81121 70.7725 6.81121C67.5332 6.81121 65.5177 8.53883 65.5177 10.6503C65.5177 12.9538 67.6292 13.9856 69.9087 14.3935C71.8043 14.7294 72.86 15.0893 72.86 15.9052C72.86 16.601 72.1162 17.1769 70.7005 17.1769C69.3088 17.1769 68.2291 16.529 67.7252 15.5692L64.8938 16.9129C65.5897 18.6405 67.9651 19.9603 70.6285 19.9603Z" fill="#141414"/>
<path d="M83.7294 19.9603C86.1288 19.9603 87.8564 19.0005 89.1521 16.841L86.4648 15.4733C85.9609 16.481 85.1451 17.1769 83.7294 17.1769C81.5939 17.1769 80.4421 15.4493 80.4421 13.3617C80.4421 11.2742 81.6658 9.59459 83.7294 9.59459C85.0251 9.59459 85.8889 10.2904 86.3928 11.3462L89.1042 9.90652C88.1924 7.91497 86.3928 6.81121 83.7294 6.81121C79.3384 6.81121 77.0829 10.0265 77.0829 13.3617C77.0829 16.9849 79.8183 19.9603 83.7294 19.9603Z" fill="#141414"/>
<path d="M94.5031 19.9603C96.3027 19.9603 97.5025 19.3604 98.1023 18.4246C98.1503 18.8325 98.2943 19.3844 98.4862 19.7203H101.51C101.342 19.1444 101.222 18.2326 101.222 17.6328V11.0583C101.222 8.34687 99.2781 6.81121 95.9908 6.81121C93.4954 6.81121 91.5518 7.747 90.472 9.1147L92.3916 11.0103C93.2554 10.0505 94.3592 9.5466 95.7029 9.5466C97.3345 9.5466 98.0543 10.0985 98.0543 10.9143C98.0543 11.6101 97.5744 12.09 94.983 12.09C92.4876 12.09 89.9682 13.1218 89.9682 16.0011C89.9682 18.6645 91.8877 19.9603 94.5031 19.9603ZM95.175 17.4168C93.8793 17.4168 93.2794 16.8649 93.2794 15.8812C93.2794 15.0174 93.9992 14.4415 95.199 14.4415C97.1185 14.4415 97.6464 14.3215 98.0543 13.9136V14.9454C98.0543 16.3131 96.9026 17.4168 95.175 17.4168Z" fill="#141414"/>
<path d="M103.196 19.7203H106.555V2.68414H103.196V19.7203Z" fill="#141414"/>
<path d="M114.617 19.9603C117.089 19.9603 119.08 18.9765 120.184 17.2249L117.641 15.5932C116.969 16.649 116.081 17.2249 114.617 17.2249C112.962 17.2249 111.762 16.3131 111.45 14.5375H121V13.3617C121 10.0265 118.96 6.81121 114.593 6.81121C110.442 6.81121 108.187 10.0505 108.187 13.3857C108.187 18.1367 111.762 19.9603 114.617 19.9603ZM111.57 11.8981C112.098 10.2904 113.202 9.5466 114.665 9.5466C116.321 9.5466 117.329 10.5304 117.665 11.8981H111.57Z" fill="#141414"/>
</svg>
<svg width="121" height="22" viewBox="0 0 121 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="2.69191" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
<ellipse cx="10.7676" cy="10.7677" rx="2.69191" ry="2.69191" fill="#141414"/>
<ellipse opacity="0.2" cx="2.69191" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
<circle opacity="0.2" cx="18.8433" cy="18.8434" r="2.69191" fill="#141414"/>
<ellipse cx="10.7676" cy="18.8434" rx="2.69191" ry="2.69191" fill="#141414"/>
<circle cx="18.8433" cy="10.7677" r="2.69191" fill="#141414"/>
<ellipse opacity="0.2" cx="2.69191" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
<ellipse opacity="0.2" cx="10.7676" cy="2.69191" rx="2.69191" ry="2.69191" fill="#141414"/>
<circle opacity="0.2" cx="18.8433" cy="2.69191" r="2.69191" fill="#141414"/>
<path d="M37.8847 19.9603C38.6525 19.9603 39.2764 19.8883 40.0202 19.7443V16.9609C39.5643 17.1289 39.0605 17.1769 38.5806 17.1769C37.4048 17.1769 36.9729 16.601 36.9729 15.4973V9.83453H40.0202V7.05116H36.9729V2.92409H33.6137V7.05116H31.4302V9.83453H33.6137V15.8092C33.6137 18.4486 35.0054 19.9603 37.8847 19.9603Z" fill="#141414"/>
<path d="M45.5064 19.9603C47.306 19.9603 48.5057 19.3604 49.1056 18.4246C49.1536 18.8325 49.2975 19.3844 49.4895 19.7203H52.5128C52.3448 19.1444 52.2249 18.2326 52.2249 17.6328V11.0583C52.2249 8.34687 50.2813 6.81121 46.994 6.81121C44.4986 6.81121 42.555 7.747 41.4753 9.1147L43.3949 11.0103C44.2587 10.0505 45.3624 9.5466 46.7061 9.5466C48.3377 9.5466 49.0576 10.0985 49.0576 10.9143C49.0576 11.6101 48.5777 12.09 45.9863 12.09C43.4908 12.09 40.9714 13.1218 40.9714 16.0011C40.9714 18.6645 42.891 19.9603 45.5064 19.9603ZM46.1782 17.4168C44.8825 17.4168 44.2827 16.8649 44.2827 15.8812C44.2827 15.0174 45.0025 14.4415 46.2022 14.4415C48.1218 14.4415 48.6497 14.3215 49.0576 13.9136V14.9454C49.0576 16.3131 47.9058 17.4168 46.1782 17.4168Z" fill="#141414"/>
<path d="M54.4086 5.44352H57.9118V2.30023H54.4086V5.44352ZM54.4805 19.7203H57.8398V7.05116H54.4805V19.7203Z" fill="#141414"/>
<path d="M60.287 19.7203H63.6463V2.68414H60.287V19.7203Z" fill="#141414"/>
<path d="M70.6285 19.9603C74.3237 19.9603 76.2193 18.0167 76.2193 15.9771C76.2193 14.1296 75.2835 12.7619 72.2122 12.21C70.0527 11.8261 68.709 11.3462 68.709 10.6024C68.709 9.95451 69.4768 9.49861 70.7725 9.49861C71.9242 9.49861 72.884 9.88252 73.6038 10.7223L75.7394 8.92274C74.6596 7.57904 72.884 6.81121 70.7725 6.81121C67.5332 6.81121 65.5177 8.53883 65.5177 10.6503C65.5177 12.9538 67.6292 13.9856 69.9087 14.3935C71.8043 14.7294 72.86 15.0893 72.86 15.9052C72.86 16.601 72.1162 17.1769 70.7005 17.1769C69.3088 17.1769 68.2291 16.529 67.7252 15.5692L64.8938 16.9129C65.5897 18.6405 67.9651 19.9603 70.6285 19.9603Z" fill="#141414"/>
<path d="M83.7294 19.9603C86.1288 19.9603 87.8564 19.0005 89.1521 16.841L86.4648 15.4733C85.9609 16.481 85.1451 17.1769 83.7294 17.1769C81.5939 17.1769 80.4421 15.4493 80.4421 13.3617C80.4421 11.2742 81.6658 9.59459 83.7294 9.59459C85.0251 9.59459 85.8889 10.2904 86.3928 11.3462L89.1042 9.90652C88.1924 7.91497 86.3928 6.81121 83.7294 6.81121C79.3384 6.81121 77.0829 10.0265 77.0829 13.3617C77.0829 16.9849 79.8183 19.9603 83.7294 19.9603Z" fill="#141414"/>
<path d="M94.5031 19.9603C96.3027 19.9603 97.5025 19.3604 98.1023 18.4246C98.1503 18.8325 98.2943 19.3844 98.4862 19.7203H101.51C101.342 19.1444 101.222 18.2326 101.222 17.6328V11.0583C101.222 8.34687 99.2781 6.81121 95.9908 6.81121C93.4954 6.81121 91.5518 7.747 90.472 9.1147L92.3916 11.0103C93.2554 10.0505 94.3592 9.5466 95.7029 9.5466C97.3345 9.5466 98.0543 10.0985 98.0543 10.9143C98.0543 11.6101 97.5744 12.09 94.983 12.09C92.4876 12.09 89.9682 13.1218 89.9682 16.0011C89.9682 18.6645 91.8877 19.9603 94.5031 19.9603ZM95.175 17.4168C93.8793 17.4168 93.2794 16.8649 93.2794 15.8812C93.2794 15.0174 93.9992 14.4415 95.199 14.4415C97.1185 14.4415 97.6464 14.3215 98.0543 13.9136V14.9454C98.0543 16.3131 96.9026 17.4168 95.175 17.4168Z" fill="#141414"/>
<path d="M103.196 19.7203H106.555V2.68414H103.196V19.7203Z" fill="#141414"/>
<path d="M114.617 19.9603C117.089 19.9603 119.08 18.9765 120.184 17.2249L117.641 15.5932C116.969 16.649 116.081 17.2249 114.617 17.2249C112.962 17.2249 111.762 16.3131 111.45 14.5375H121V13.3617C121 10.0265 118.96 6.81121 114.593 6.81121C110.442 6.81121 108.187 10.0505 108.187 13.3857C108.187 18.1367 111.762 19.9603 114.617 19.9603ZM111.57 11.8981C112.098 10.2904 113.202 9.5466 114.665 9.5466C116.321 9.5466 117.329 10.5304 117.665 11.8981H111.57Z" fill="#141414"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1,4 +1,4 @@
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 13.625V12.375C12.5 11.712 12.2366 11.0761 11.7678 10.6072C11.2989 10.1384 10.663 9.875 10 9.875H5C4.33696 9.875 3.70107 10.1384 3.23223 10.6072C2.76339 11.0761 2.5 11.712 2.5 12.375V13.625" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 7.375C8.88071 7.375 10 6.25571 10 4.875C10 3.49429 8.88071 2.375 7.5 2.375C6.11929 2.375 5 3.49429 5 4.875C5 6.25571 6.11929 7.375 7.5 7.375Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 13.625V12.375C12.5 11.712 12.2366 11.0761 11.7678 10.6072C11.2989 10.1384 10.663 9.875 10 9.875H5C4.33696 9.875 3.70107 10.1384 3.23223 10.6072C2.76339 11.0761 2.5 11.712 2.5 12.375V13.625" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 7.375C8.88071 7.375 10 6.25571 10 4.875C10 3.49429 8.88071 2.375 7.5 2.375C6.11929 2.375 5 3.49429 5 4.875C5 6.25571 6.11929 7.375 7.5 7.375Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 639 B

After

Width:  |  Height:  |  Size: 635 B

View File

@@ -1,5 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 9L9 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 9L15 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 9L9 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 9L15 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 511 B

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -1,59 +1,59 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// synology.go contains handlers and logic, such as authentication,
// that is specific to running the web client on Synology.
package web
import (
"errors"
"fmt"
"net/http"
"os/exec"
"strings"
"tailscale.com/util/groupmember"
)
// authorizeSynology authenticates the logged-in Synology user and verifies
// that they are authorized to use the web client.
// If the user is authenticated, but not authorized to use the client, an error is returned.
func authorizeSynology(r *http.Request) (authorized bool, err error) {
if !hasSynoToken(r) {
return false, nil
}
// authenticate the Synology user
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
out, err := cmd.CombinedOutput()
if err != nil {
return false, fmt.Errorf("auth: %v: %s", err, out)
}
user := strings.TrimSpace(string(out))
// check if the user is in the administrators group
isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
if err != nil {
return false, err
}
if !isAdmin {
return false, errors.New("not a member of administrators group")
}
return true, nil
}
// hasSynoToken returns true if the request include a SynoToken used for synology auth.
func hasSynoToken(r *http.Request) bool {
if r.Header.Get("X-Syno-Token") != "" {
return true
}
if r.URL.Query().Get("SynoToken") != "" {
return true
}
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
return true
}
return false
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// synology.go contains handlers and logic, such as authentication,
// that is specific to running the web client on Synology.
package web
import (
"errors"
"fmt"
"net/http"
"os/exec"
"strings"
"tailscale.com/util/groupmember"
)
// authorizeSynology authenticates the logged-in Synology user and verifies
// that they are authorized to use the web client.
// If the user is authenticated, but not authorized to use the client, an error is returned.
func authorizeSynology(r *http.Request) (authorized bool, err error) {
if !hasSynoToken(r) {
return false, nil
}
// authenticate the Synology user
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
out, err := cmd.CombinedOutput()
if err != nil {
return false, fmt.Errorf("auth: %v: %s", err, out)
}
user := strings.TrimSpace(string(out))
// check if the user is in the administrators group
isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
if err != nil {
return false, err
}
if !isAdmin {
return false, errors.New("not a member of administrators group")
}
return true, nil
}
// hasSynoToken returns true if the request include a SynoToken used for synology auth.
func hasSynoToken(r *http.Request) bool {
if r.Header.Get("X-Syno-Token") != "" {
return true
}
if r.URL.Query().Get("SynoToken") != "" {
return true
}
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
return true
}
return false
}

View File

@@ -17,6 +17,7 @@ import (
"os"
"path"
"path/filepath"
"slices"
"strings"
"sync"
"time"
@@ -26,7 +27,6 @@ import (
"tailscale.com/client/tailscale/apitype"
"tailscale.com/clientupdate"
"tailscale.com/envknob"
"tailscale.com/envknob/featureknob"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
@@ -35,7 +35,6 @@ import (
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/util/httpm"
"tailscale.com/version"
"tailscale.com/version/distro"
@@ -114,6 +113,11 @@ const (
ManageServerMode ServerMode = "manage"
)
var (
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
)
// ServerOpts contains options for constructing a new Server.
type ServerOpts struct {
// Mode specifies the mode of web client being constructed.
@@ -279,12 +283,6 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
}
}
if r.URL.Path == "/metrics" {
r.URL.Path = "/api/local/v0/usermetrics"
s.proxyRequestToLocalAPI(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/api/") {
switch {
case r.URL.Path == "/api/auth" && r.Method == httpm.GET:
@@ -923,10 +921,10 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
return p == route
})
}
data.AdvertisingExitNodeApproved = routeApproved(tsaddr.AllIPv4()) || routeApproved(tsaddr.AllIPv6())
data.AdvertisingExitNodeApproved = routeApproved(exitNodeRouteV4) || routeApproved(exitNodeRouteV6)
for _, r := range prefs.AdvertiseRoutes {
if tsaddr.IsExitRoute(r) {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
data.AdvertisingExitNode = true
} else {
data.AdvertisedRoutes = append(data.AdvertisedRoutes, subnetRoute{
@@ -961,16 +959,37 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
}
func availableFeatures() map[string]bool {
env := hostinfo.GetEnvType()
features := map[string]bool{
"advertise-exit-node": true, // available on all platforms
"advertise-routes": true, // available on all platforms
"use-exit-node": featureknob.CanUseExitNode() == nil,
"ssh": featureknob.CanRunTailscaleSSH() == nil,
"use-exit-node": canUseExitNode(env) == nil,
"ssh": envknob.CanRunTailscaleSSH() == nil,
"auto-update": version.IsUnstableBuild() && clientupdate.CanAutoUpdate(),
}
if env == hostinfo.HomeAssistantAddOn {
// 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.
features["ssh"] = false
}
return features
}
func canUseExitNode(env hostinfo.EnvType) error {
switch dist := distro.Get(); dist {
case distro.Synology, // see https://github.com/tailscale/tailscale/issues/1995
distro.QNAP,
distro.Unraid:
return fmt.Errorf("Tailscale exit nodes cannot be used on %s.", dist)
}
if env == hostinfo.HomeAssistantAddOn {
return errors.New("Tailscale exit nodes cannot be used on Home Assistant.")
}
return nil
}
// aclsAllowAccess returns whether tailnet ACLs (as expressed in the provided filter rules)
// permit any devices to access the local web client.
// This does not currently check whether a specific device can connect, just any device.
@@ -1046,7 +1065,7 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
var currNonExitRoutes []string
var currAdvertisingExitNode bool
for _, r := range prefs.AdvertiseRoutes {
if tsaddr.IsExitRoute(r) {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
currAdvertisingExitNode = true
continue
}
@@ -1067,7 +1086,12 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
return err
}
if !data.UseExitNode.IsZero() && tsaddr.ContainsExitRoutes(views.SliceOf(routes)) {
hasExitNodeRoute := func(all []netip.Prefix) bool {
return slices.Contains(all, exitNodeRouteV4) ||
slices.Contains(all, exitNodeRouteV6)
}
if !data.UseExitNode.IsZero() && hasExitNodeRoute(routes) {
return errors.New("cannot use and advertise exit node at same time")
}

View File

@@ -5382,9 +5382,9 @@ wrappy@1:
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
ws@^8.14.2:
version "8.17.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
version "8.14.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f"
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==
xml-name-validator@^5.0.0:
version "5.0.0"

View File

@@ -27,8 +27,11 @@ import (
"strconv"
"strings"
"github.com/google/uuid"
"tailscale.com/clientupdate/distsign"
"tailscale.com/types/logger"
"tailscale.com/util/cmpver"
"tailscale.com/util/winutil"
"tailscale.com/version"
"tailscale.com/version/distro"
)
@@ -753,6 +756,164 @@ func (up *Updater) updateMacAppStore() error {
return nil
}
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"
)
var (
verifyAuthenticode func(string) error // set non-nil only on Windows
markTempFileFunc func(string) error // set non-nil only on Windows
)
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) 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
}
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
}
// cleanupOldDownloads removes all files matching glob (see filepath.Glob).
// Only regular files are removed, so the glob must match specific files and
// not directories.
@@ -777,6 +938,53 @@ func (up *Updater) cleanupOldDownloads(glob string) {
}
}
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 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 f := markTempFileFunc; f != nil {
if err := f(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 (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)
}
func (up *Updater) updateFreeBSD() (err error) {
if up.Version != "" {
return errors.New("installing a specific version on FreeBSD is not supported")

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,10 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !windows
package clientupdate
func (up *Updater) updateWindows() error {
panic("unreachable")
}

View File

@@ -7,57 +7,13 @@
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 init() {
markTempFileFunc = markTempFileWindows
verifyAuthenticode = verifyTailscale
}
func markTempFileWindows(name string) error {
@@ -67,159 +23,6 @@ func markTempFileWindows(name string) error {
const certSubjectTailscale = "Tailscale Inc."
func verifyAuthenticode(path string) error {
func verifyTailscale(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 +1,486 @@
// 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
}
// 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 +1,54 @@
// 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
}
// 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 +1,3 @@
-----BEGIN ROOT PUBLIC KEY-----
Psrabv2YNiEDhPlnLVSMtB5EKACm7zxvKxfvYD4i7X8=
-----END ROOT PUBLIC KEY-----
-----BEGIN ROOT PUBLIC KEY-----
Psrabv2YNiEDhPlnLVSMtB5EKACm7zxvKxfvYD4i7X8=
-----END ROOT PUBLIC KEY-----

View File

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

View File

@@ -1,16 +1,16 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package distsign
import "testing"
func TestParseRoots(t *testing.T) {
roots, err := parseRoots()
if err != nil {
t.Fatal(err)
}
if len(roots) == 0 {
t.Error("parseRoots returned no root keys")
}
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package distsign
import "testing"
func TestParseRoots(t *testing.T) {
roots, err := parseRoots()
if err != nil {
t.Fatal(err)
}
if len(roots) == 0 {
t.Error("parseRoots returned no root keys")
}
}

View File

@@ -1,73 +1,73 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Program addlicense adds a license header to a file.
// It is intended for use with 'go generate',
// so it has a slightly weird usage.
package main
import (
"flag"
"fmt"
"os"
"os/exec"
)
var (
file = flag.String("file", "", "file to modify")
)
func usage() {
fmt.Fprintf(os.Stderr, `
usage: addlicense -file FILE <subcommand args...>
`[1:])
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, `
addlicense adds a Tailscale license to the beginning of file.
It is intended for use with 'go generate', so it also runs a subcommand,
which presumably creates the file.
Sample usage:
addlicense -file pull_strings.go stringer -type=pull
`[1:])
os.Exit(2)
}
func main() {
flag.Usage = usage
flag.Parse()
if len(flag.Args()) == 0 {
flag.Usage()
}
cmd := exec.Command(flag.Arg(0), flag.Args()[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
check(err)
b, err := os.ReadFile(*file)
check(err)
f, err := os.OpenFile(*file, os.O_TRUNC|os.O_WRONLY, 0644)
check(err)
_, err = fmt.Fprint(f, license)
check(err)
_, err = f.Write(b)
check(err)
err = f.Close()
check(err)
}
func check(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var license = `
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
`[1:]
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Program addlicense adds a license header to a file.
// It is intended for use with 'go generate',
// so it has a slightly weird usage.
package main
import (
"flag"
"fmt"
"os"
"os/exec"
)
var (
file = flag.String("file", "", "file to modify")
)
func usage() {
fmt.Fprintf(os.Stderr, `
usage: addlicense -file FILE <subcommand args...>
`[1:])
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, `
addlicense adds a Tailscale license to the beginning of file.
It is intended for use with 'go generate', so it also runs a subcommand,
which presumably creates the file.
Sample usage:
addlicense -file pull_strings.go stringer -type=pull
`[1:])
os.Exit(2)
}
func main() {
flag.Usage = usage
flag.Parse()
if len(flag.Args()) == 0 {
flag.Usage()
}
cmd := exec.Command(flag.Arg(0), flag.Args()[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
check(err)
b, err := os.ReadFile(*file)
check(err)
f, err := os.OpenFile(*file, os.O_TRUNC|os.O_WRONLY, 0644)
check(err)
_, err = fmt.Fprint(f, license)
check(err)
_, err = f.Write(b)
check(err)
err = f.Close()
check(err)
}
func check(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var license = `
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
`[1:]

View File

@@ -1,131 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// checkmetrics validates that all metrics in the tailscale client-metrics
// are documented in a given path or URL.
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"time"
"tailscale.com/ipn/store/mem"
"tailscale.com/tsnet"
"tailscale.com/tstest/integration/testcontrol"
"tailscale.com/util/httpm"
)
var (
kbPath = flag.String("kb-path", "", "filepath to the client-metrics knowledge base")
kbUrl = flag.String("kb-url", "", "URL to the client-metrics knowledge base page")
)
func main() {
flag.Parse()
if *kbPath == "" && *kbUrl == "" {
log.Fatalf("either -kb-path or -kb-url must be set")
}
var control testcontrol.Server
ts := httptest.NewServer(&control)
defer ts.Close()
td, err := os.MkdirTemp("", "testcontrol")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(td)
// tsnet is used not used as a Tailscale client, but as a way to
// boot up Tailscale, have all the metrics registered, and then
// verifiy that all the metrics are documented.
tsn := &tsnet.Server{
Dir: td,
Store: new(mem.Store),
UserLogf: log.Printf,
Ephemeral: true,
ControlURL: ts.URL,
}
if err := tsn.Start(); err != nil {
log.Fatal(err)
}
defer tsn.Close()
log.Printf("checking that all metrics are documented, looking for: %s", tsn.Sys().UserMetricsRegistry().MetricNames())
if *kbPath != "" {
kb, err := readKB(*kbPath)
if err != nil {
log.Fatalf("reading kb: %v", err)
}
missing := undocumentedMetrics(kb, tsn.Sys().UserMetricsRegistry().MetricNames())
if len(missing) > 0 {
log.Fatalf("found undocumented metrics in %q: %v", *kbPath, missing)
}
}
if *kbUrl != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
kb, err := getKB(ctx, *kbUrl)
if err != nil {
log.Fatalf("getting kb: %v", err)
}
missing := undocumentedMetrics(kb, tsn.Sys().UserMetricsRegistry().MetricNames())
if len(missing) > 0 {
log.Fatalf("found undocumented metrics in %q: %v", *kbUrl, missing)
}
}
}
func readKB(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("reading file: %w", err)
}
return string(b), nil
}
func getKB(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, httpm.GET, url, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("getting kb page: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading body: %w", err)
}
return string(b), nil
}
func undocumentedMetrics(b string, metrics []string) []string {
var missing []string
for _, metric := range metrics {
if !strings.Contains(b, metric) {
missing = append(missing, metric)
}
}
return missing
}

View File

@@ -47,7 +47,7 @@ func main() {
it := codegen.NewImportTracker(pkg.Types)
buf := new(bytes.Buffer)
for _, typeName := range typeNames {
typ, ok := namedTypes[typeName].(*types.Named)
typ, ok := namedTypes[typeName]
if !ok {
log.Fatalf("could not find type %s", typeName)
}
@@ -115,7 +115,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) {
continue
}
if named, _ := codegen.NamedTypeOf(ft); named != nil {
if named, _ := ft.(*types.Named); named != nil {
if codegen.IsViewType(ft) {
writef("dst.%s = src.%s", fname, fname)
continue
@@ -161,7 +161,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
case *types.Pointer:
base := ft.Elem()
hasPtrs := codegen.ContainsPointers(base)
if named, _ := codegen.NamedTypeOf(base); named != nil && hasPtrs {
if named, _ := base.(*types.Named); named != nil && hasPtrs {
writef("dst.%s = src.%s.Clone()", fname, fname)
continue
}

View File

@@ -1,60 +1,60 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"reflect"
"testing"
"tailscale.com/cmd/cloner/clonerex"
)
func TestSliceContainer(t *testing.T) {
num := 5
examples := []struct {
name string
in *clonerex.SliceContainer
}{
{
name: "nil",
in: nil,
},
{
name: "zero",
in: &clonerex.SliceContainer{},
},
{
name: "empty",
in: &clonerex.SliceContainer{
Slice: []*int{},
},
},
{
name: "nils",
in: &clonerex.SliceContainer{
Slice: []*int{nil, nil, nil, nil, nil},
},
},
{
name: "one",
in: &clonerex.SliceContainer{
Slice: []*int{&num},
},
},
{
name: "several",
in: &clonerex.SliceContainer{
Slice: []*int{&num, &num, &num, &num, &num},
},
},
}
for _, ex := range examples {
t.Run(ex.name, func(t *testing.T) {
out := ex.in.Clone()
if !reflect.DeepEqual(ex.in, out) {
t.Errorf("Clone() = %v, want %v", out, ex.in)
}
})
}
}
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"reflect"
"testing"
"tailscale.com/cmd/cloner/clonerex"
)
func TestSliceContainer(t *testing.T) {
num := 5
examples := []struct {
name string
in *clonerex.SliceContainer
}{
{
name: "nil",
in: nil,
},
{
name: "zero",
in: &clonerex.SliceContainer{},
},
{
name: "empty",
in: &clonerex.SliceContainer{
Slice: []*int{},
},
},
{
name: "nils",
in: &clonerex.SliceContainer{
Slice: []*int{nil, nil, nil, nil, nil},
},
},
{
name: "one",
in: &clonerex.SliceContainer{
Slice: []*int{&num},
},
},
{
name: "several",
in: &clonerex.SliceContainer{
Slice: []*int{&num, &num, &num, &num, &num},
},
},
}
for _, ex := range examples {
t.Run(ex.name, func(t *testing.T) {
out := ex.in.Clone()
if !reflect.DeepEqual(ex.in, out) {
t.Errorf("Clone() = %v, want %v", out, ex.in)
}
})
}
}

View File

@@ -1,262 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"context"
"fmt"
"log"
"net"
"net/netip"
"os"
"path/filepath"
"strings"
"tailscale.com/util/linuxfw"
)
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
func ensureIPForwarding(root, clusterProxyTargetIP, tailnetTargetIP, tailnetTargetFQDN string, routes *string) error {
var (
v4Forwarding, v6Forwarding bool
)
if clusterProxyTargetIP != "" {
proxyIP, err := netip.ParseAddr(clusterProxyTargetIP)
if err != nil {
return fmt.Errorf("invalid cluster destination IP: %v", err)
}
if proxyIP.Is4() {
v4Forwarding = true
} else {
v6Forwarding = true
}
}
if tailnetTargetIP != "" {
proxyIP, err := netip.ParseAddr(tailnetTargetIP)
if err != nil {
return fmt.Errorf("invalid tailnet destination IP: %v", err)
}
if proxyIP.Is4() {
v4Forwarding = true
} else {
v6Forwarding = true
}
}
// Currently we only proxy traffic to the IPv4 address of the tailnet
// target.
if tailnetTargetFQDN != "" {
v4Forwarding = true
}
if routes != nil && *routes != "" {
for _, route := range strings.Split(*routes, ",") {
cidr, err := netip.ParsePrefix(route)
if err != nil {
return fmt.Errorf("invalid subnet route: %v", err)
}
if cidr.Addr().Is4() {
v4Forwarding = true
} else {
v6Forwarding = true
}
}
}
return enableIPForwarding(v4Forwarding, v6Forwarding, root)
}
func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error {
var paths []string
if v4Forwarding {
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv4/ip_forward"))
}
if v6Forwarding {
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv6/conf/all/forwarding"))
}
// In some common configurations (e.g. default docker,
// kubernetes), the container environment denies write access to
// most sysctls, including IP forwarding controls. Check the
// sysctl values before trying to change them, so that we
// gracefully do nothing if the container's already been set up
// properly by e.g. a k8s initContainer.
for _, path := range paths {
bs, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %q: %w", path, err)
}
if v := strings.TrimSpace(string(bs)); v != "1" {
if err := os.WriteFile(path, []byte("1"), 0644); err != nil {
return fmt.Errorf("enabling %q: %w", path, err)
}
}
}
return nil
}
func installEgressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
var local netip.Addr
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
if err := nfr.DNATNonTailscaleTraffic("tailscale0", dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
if err := nfr.EnsureSNATForDst(local, dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("installing egress proxy rules: %w", err)
}
return nil
}
// installTSForwardingRuleForDestination accepts a destination address and a
// list of node's tailnet addresses, sets up rules to forward traffic for
// destination to the tailnet IP matching the destination IP family.
// Destination can be Pod IP of this node.
func installTSForwardingRuleForDestination(_ context.Context, dstFilter string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstFilter)
if err != nil {
return err
}
var local netip.Addr
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstFilter, tsIPs)
}
if err := nfr.AddDNATRule(dst, local); err != nil {
return fmt.Errorf("installing rule for forwarding traffic to tailnet IP: %w", err)
}
return nil
}
func installIngressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
dst, err := netip.ParseAddr(dstStr)
if err != nil {
return err
}
var local netip.Addr
proxyHasIPv4Address := false
for _, pfx := range tsIPs {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() {
proxyHasIPv4Address = true
}
if pfx.Addr().Is4() != dst.Is4() {
continue
}
local = pfx.Addr()
break
}
if proxyHasIPv4Address && dst.Is6() {
log.Printf("Warning: proxy backend ClusterIP is an IPv6 address and the proxy has a IPv4 tailnet address. You might need to disable IPv4 address allocation for the proxy for forwarding to work. See https://github.com/tailscale/tailscale/issues/12156")
}
if !local.IsValid() {
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
}
if err := nfr.AddDNATRule(local, dst); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
}
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("installing ingress proxy rules: %w", err)
}
return nil
}
func installIngressForwardingRuleForDNSTarget(_ context.Context, backendAddrs []net.IP, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
var (
tsv4 netip.Addr
tsv6 netip.Addr
v4Backends []netip.Addr
v6Backends []netip.Addr
)
for _, pfx := range tsIPs {
if pfx.IsSingleIP() && pfx.Addr().Is4() {
tsv4 = pfx.Addr()
continue
}
if pfx.IsSingleIP() && pfx.Addr().Is6() {
tsv6 = pfx.Addr()
continue
}
}
// TODO: log if more than one backend address is found and firewall is
// in nftables mode that only the first IP will be used.
for _, ip := range backendAddrs {
if ip.To4() != nil {
v4Backends = append(v4Backends, netip.AddrFrom4([4]byte(ip.To4())))
}
if ip.To16() != nil {
v6Backends = append(v6Backends, netip.AddrFrom16([16]byte(ip.To16())))
}
}
// Enable IP forwarding here as opposed to at the start of containerboot
// as the IPv4/IPv6 requirements might have changed.
// For Kubernetes operator proxies, forwarding for both IPv4 and IPv6 is
// enabled by an init container, so in practice enabling forwarding here
// is only needed if this proxy has been configured by manually setting
// TS_EXPERIMENTAL_DEST_DNS_NAME env var for a containerboot instance.
if err := enableIPForwarding(len(v4Backends) != 0, len(v6Backends) != 0, ""); err != nil {
log.Printf("[unexpected] failed to ensure IP forwarding: %v", err)
}
updateFirewall := func(dst netip.Addr, backendTargets []netip.Addr) error {
if err := nfr.DNATWithLoadBalancer(dst, backendTargets); err != nil {
return fmt.Errorf("installing DNAT rules for ingress backends %+#v: %w", backendTargets, err)
}
// The backend might advertize MSS higher than that of the
// tailscale interfaces. Clamp MSS of packets going out via
// tailscale0 interface to its MTU to prevent broken connections
// in environments where path MTU discovery is not working.
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
return fmt.Errorf("adding rule to clamp traffic via tailscale0: %v", err)
}
return nil
}
if len(v4Backends) != 0 {
if !tsv4.IsValid() {
log.Printf("backend targets %v contain at least one IPv4 address, but this node's Tailscale IPs do not contain a valid IPv4 address: %v", backendAddrs, tsIPs)
} else if err := updateFirewall(tsv4, v4Backends); err != nil {
return fmt.Errorf("Installing IPv4 firewall rules: %w", err)
}
}
if len(v6Backends) != 0 && !tsv6.IsValid() {
if !tsv6.IsValid() {
log.Printf("backend targets %v contain at least one IPv6 address, but this node's Tailscale IPs do not contain a valid IPv6 address: %v", backendAddrs, tsIPs)
} else if !nfr.HasIPV6NAT() {
log.Printf("backend targets %v contain at least one IPv6 address, but the chosen firewall mode does not support IPv6 NAT", backendAddrs)
} else if err := updateFirewall(tsv6, v6Backends); err != nil {
return fmt.Errorf("Installing IPv6 firewall rules: %w", err)
}
}
return nil
}

View File

@@ -1,50 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"log"
"net/http"
"sync"
)
// healthz is a simple health check server, if enabled it returns 200 OK if
// this tailscale node currently has at least one tailnet IP address else
// returns 503.
type healthz struct {
sync.Mutex
hasAddrs bool
}
func (h *healthz) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.Lock()
defer h.Unlock()
if h.hasAddrs {
w.Write([]byte("ok"))
} else {
http.Error(w, "node currently has no tailscale IPs", http.StatusServiceUnavailable)
}
}
func (h *healthz) update(healthy bool) {
h.Lock()
defer h.Unlock()
if h.hasAddrs != healthy {
log.Println("Setting healthy", healthy)
}
h.hasAddrs = healthy
}
// healthHandlers registers a simple health handler at /healthz.
// A containerized tailscale instance is considered healthy if
// it has at least one tailnet IP address.
func healthHandlers(mux *http.ServeMux) *healthz {
h := &healthz{}
mux.Handle("GET /healthz", h)
return h
}

View File

@@ -8,56 +8,31 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/netip"
"os"
"tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/kube"
"tailscale.com/tailcfg"
)
// kubeClient is a wrapper around Tailscale's internal kube client that knows how to talk to the kube API server. We use
// this rather than any of the upstream Kubernetes client libaries to avoid extra imports.
type kubeClient struct {
kubeclient.Client
stateSecret string
}
func newKubeClient(root string, stateSecret string) (*kubeClient, error) {
if root != "/" {
// If we are running in a test, we need to set the root path to the fake
// service account directory.
kubeclient.SetRootPathForTesting(root)
}
var err error
kc, err := kubeclient.New("tailscale-container")
if err != nil {
return nil, fmt.Errorf("Error creating kube client: %w", err)
}
if (root != "/") || os.Getenv("TS_KUBERNETES_READ_API_SERVER_ADDRESS_FROM_ENV") == "true" {
// Derive the API server address from the environment variables
// Used to set http server in tests, or optionally enabled by flag
kc.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
}
return &kubeClient{Client: kc, stateSecret: stateSecret}, nil
}
// storeDeviceID writes deviceID to 'device_id' data field of the client's state Secret.
func (kc *kubeClient) storeDeviceID(ctx context.Context, deviceID tailcfg.StableNodeID) error {
s := &kubeapi.Secret{
// storeDeviceID writes deviceID to 'device_id' data field of the named
// Kubernetes Secret.
func storeDeviceID(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID) error {
s := &kube.Secret{
Data: map[string][]byte{
kubetypes.KeyDeviceID: []byte(deviceID),
"device_id": []byte(deviceID),
},
}
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container")
}
// storeDeviceEndpoints writes device's tailnet IPs and MagicDNS name to fields 'device_ips', 'device_fqdn' of client's
// state Secret.
func (kc *kubeClient) storeDeviceEndpoints(ctx context.Context, fqdn string, addresses []netip.Prefix) error {
// storeDeviceEndpoints writes device's tailnet IPs and MagicDNS name to fields
// 'device_ips', 'device_fqdn' of the named Kubernetes Secret.
func storeDeviceEndpoints(ctx context.Context, secretName string, fqdn string, addresses []netip.Prefix) error {
var ips []string
for _, addr := range addresses {
ips = append(ips, addr.Addr().String())
@@ -67,39 +42,27 @@ func (kc *kubeClient) storeDeviceEndpoints(ctx context.Context, fqdn string, add
return err
}
s := &kubeapi.Secret{
s := &kube.Secret{
Data: map[string][]byte{
kubetypes.KeyDeviceFQDN: []byte(fqdn),
kubetypes.KeyDeviceIPs: deviceIPs,
"device_fqdn": []byte(fqdn),
"device_ips": deviceIPs,
},
}
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
}
// storeHTTPSEndpoint writes an HTTPS endpoint exposed by this device via 'tailscale serve' to the client's state
// Secret. In practice this will be the same value that gets written to 'device_fqdn', but this should only be called
// when the serve config has been successfully set up.
func (kc *kubeClient) storeHTTPSEndpoint(ctx context.Context, ep string) error {
s := &kubeapi.Secret{
Data: map[string][]byte{
kubetypes.KeyHTTPSEndpoint: []byte(ep),
},
}
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container")
}
// deleteAuthKey deletes the 'authkey' field of the given kube
// secret. No-op if there is no authkey in the secret.
func (kc *kubeClient) deleteAuthKey(ctx context.Context) error {
func deleteAuthKey(ctx context.Context, secretName string) error {
// m is a JSON Patch data structure, see https://jsonpatch.com/ or RFC 6902.
m := []kubeclient.JSONPatch{
m := []kube.JSONPatch{
{
Op: "remove",
Path: "/data/authkey",
},
}
if err := kc.JSONPatchResource(ctx, kc.stateSecret, kubeclient.TypeSecrets, m); err != nil {
if s, ok := err.(*kubeapi.Status); ok && s.Code == http.StatusUnprocessableEntity {
if err := kc.JSONPatchSecret(ctx, secretName, m); err != nil {
if s, ok := err.(*kube.Status); ok && s.Code == http.StatusUnprocessableEntity {
// This is kubernetes-ese for "the field you asked to
// delete already doesn't exist", aka no-op.
return nil
@@ -109,19 +72,72 @@ func (kc *kubeClient) deleteAuthKey(ctx context.Context) error {
return nil
}
// storeCapVerUID stores the current capability version of tailscale and, if provided, UID of the Pod in the tailscale
// state Secret.
// These two fields are used by the Kubernetes Operator to observe the current capability version of tailscaled running in this container.
func (kc *kubeClient) storeCapVerUID(ctx context.Context, podUID string) error {
capVerS := fmt.Sprintf("%d", tailcfg.CurrentCapabilityVersion)
d := map[string][]byte{
kubetypes.KeyCapVer: []byte(capVerS),
var kc kube.Client
// setupKube is responsible for doing any necessary configuration and checks to
// ensure that tailscale state storage and authentication mechanism will work on
// Kubernetes.
func (cfg *settings) setupKube(ctx context.Context) error {
if cfg.KubeSecret == "" {
return nil
}
if podUID != "" {
d[kubetypes.KeyPodUID] = []byte(podUID)
canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret)
if err != nil {
return fmt.Errorf("Some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
}
s := &kubeapi.Secret{
Data: d,
cfg.KubernetesCanPatch = canPatch
s, err := kc.GetSecret(ctx, cfg.KubeSecret)
if err != nil && kube.IsNotFoundErr(err) && !canCreate {
return fmt.Errorf("Tailscale state Secret %s does not exist and we don't have permissions to create it. "+
"If you intend to store tailscale state elsewhere than a Kubernetes Secret, "+
"you can explicitly set TS_KUBE_SECRET env var to an empty string. "+
"Else ensure that RBAC is set up that allows the service account associated with this installation to create Secrets.", cfg.KubeSecret)
} else if err != nil && !kube.IsNotFoundErr(err) {
return fmt.Errorf("Getting Tailscale state Secret %s: %v", cfg.KubeSecret, err)
}
if cfg.AuthKey == "" && !isOneStepConfig(cfg) {
if s == nil {
log.Print("TS_AUTHKEY not provided and kube secret does not exist, login will be interactive if needed.")
return nil
}
keyBytes, _ := s.Data["authkey"]
key := string(keyBytes)
if key != "" {
// This behavior of pulling authkeys from kube secrets was added
// at the same time as the patch permission, so we can enforce
// that we must be able to patch out the authkey after
// authenticating if you want to use this feature. This avoids
// us having to deal with the case where we might leave behind
// an unnecessary reusable authkey in a secret, like a rake in
// the grass.
if !cfg.KubernetesCanPatch {
return errors.New("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the secret to manage the authkey.")
}
cfg.AuthKey = key
} else {
log.Print("No authkey found in kube secret and TS_AUTHKEY not provided, login will be interactive if needed.")
}
}
return nil
}
func initKubeClient(root string) {
if root != "/" {
// If we are running in a test, we need to set the root path to the fake
// service account directory.
kube.SetRootPathForTesting(root)
}
var err error
kc, err = kube.New()
if err != nil {
log.Fatalf("Error creating kube client: %v", err)
}
if (root != "/") || os.Getenv("TS_KUBERNETES_READ_API_SERVER_ADDRESS_FROM_ENV") == "true" {
// Derive the API server address from the environment variables
// Used to set http server in tests, or optionally enabled by flag
kc.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
}
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
}

View File

@@ -11,8 +11,7 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube"
)
func TestSetupKube(t *testing.T) {
@@ -21,7 +20,7 @@ func TestSetupKube(t *testing.T) {
cfg *settings
wantErr bool
wantCfg *settings
kc *kubeClient
kc kube.Client
}{
{
name: "TS_AUTHKEY set, state Secret exists",
@@ -29,14 +28,14 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, nil
},
}},
},
wantCfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
@@ -48,14 +47,14 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, true, nil
},
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return nil, &kubeapi.Status{Code: 404}
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 404}
},
}},
},
wantCfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
@@ -67,14 +66,14 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return nil, &kubeapi.Status{Code: 404}
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 404}
},
}},
},
wantCfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
@@ -87,14 +86,14 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return nil, &kubeapi.Status{Code: 403}
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 403}
},
}},
},
wantCfg: &settings{
AuthKey: "foo",
KubeSecret: "foo",
@@ -111,11 +110,11 @@ func TestSetupKube(t *testing.T) {
AuthKey: "foo",
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, errors.New("broken")
},
}},
},
wantErr: true,
},
{
@@ -127,14 +126,14 @@ func TestSetupKube(t *testing.T) {
wantCfg: &settings{
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, true, nil
},
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return nil, &kubeapi.Status{Code: 404}
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return nil, &kube.Status{Code: 404}
},
}},
},
},
{
// Interactive login using URL in Pod logs
@@ -145,28 +144,28 @@ func TestSetupKube(t *testing.T) {
wantCfg: &settings{
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return &kubeapi.Secret{}, nil
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return &kube.Secret{}, nil
},
}},
},
},
{
name: "TS_AUTHKEY not set, state Secret contains auth key, we do not have RBAC to patch it",
cfg: &settings{
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return false, false, nil
},
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return &kubeapi.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return &kube.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
},
}},
},
wantCfg: &settings{
KubeSecret: "foo",
},
@@ -177,14 +176,14 @@ func TestSetupKube(t *testing.T) {
cfg: &settings{
KubeSecret: "foo",
},
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
kc: &kube.FakeClient{
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
return true, false, nil
},
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
return &kubeapi.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
return &kube.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
},
}},
},
wantCfg: &settings{
KubeSecret: "foo",
AuthKey: "foo",
@@ -194,9 +193,9 @@ func TestSetupKube(t *testing.T) {
}
for _, tt := range tests {
kc := tt.kc
kc = tt.kc
t.Run(tt.name, func(t *testing.T) {
if err := tt.cfg.setupKube(context.Background(), kc); (err != nil) != tt.wantErr {
if err := tt.cfg.setupKube(context.Background()); (err != nil) != tt.wantErr {
t.Errorf("settings.setupKube() error = %v, wantErr %v", err, tt.wantErr)
}
if diff := cmp.Diff(*tt.cfg, *tt.wantCfg); diff != "" {

File diff suppressed because it is too large Load Diff

View File

@@ -101,26 +101,6 @@ func TestContainerBoot(t *testing.T) {
argFile := filepath.Join(d, "args")
runningSockPath := filepath.Join(d, "tmp/tailscaled.sock")
var localAddrPort, healthAddrPort int
for _, p := range []*int{&localAddrPort, &healthAddrPort} {
ln, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatalf("Failed to open listener: %v", err)
}
if err := ln.Close(); err != nil {
t.Fatalf("Failed to close listener: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
*p = port
}
metricsURL := func(port int) string {
return fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
}
healthURL := func(port int) string {
return fmt.Sprintf("http://127.0.0.1:%d/healthz", port)
}
capver := fmt.Sprintf("%d", tailcfg.CurrentCapabilityVersion)
type phase struct {
// If non-nil, send this IPN bus notification (and remember it as the
@@ -139,8 +119,6 @@ func TestContainerBoot(t *testing.T) {
// WantFatalLog is the fatal log message we expect from containerboot.
// If set for a phase, the test will finish on that phase.
WantFatalLog string
EndpointStatuses map[string]int
}
runningNotify := &ipn.Notify{
State: ptr.To(ipn.Running),
@@ -169,11 +147,6 @@ func TestContainerBoot(t *testing.T) {
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
},
// No metrics or health by default.
EndpointStatuses: map[string]int{
metricsURL(9002): -1,
healthURL(9002): -1,
},
},
{
Notify: runningNotify,
@@ -480,11 +453,10 @@ func TestContainerBoot(t *testing.T) {
{
Notify: runningNotify,
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
"tailscale_capver": capver,
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
},
},
},
@@ -574,10 +546,9 @@ func TestContainerBoot(t *testing.T) {
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{
"device_fqdn": "test-node.test.ts.net",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
"tailscale_capver": capver,
"device_fqdn": "test-node.test.ts.net",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
},
},
},
@@ -604,11 +575,10 @@ func TestContainerBoot(t *testing.T) {
{
Notify: runningNotify,
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
"tailscale_capver": capver,
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
},
},
{
@@ -623,11 +593,10 @@ func TestContainerBoot(t *testing.T) {
},
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "new-name.test.ts.net",
"device_id": "newID",
"device_ips": `["100.64.0.1"]`,
"tailscale_capver": capver,
"authkey": "tskey-key",
"device_fqdn": "new-name.test.ts.net",
"device_id": "newID",
"device_ips": `["100.64.0.1"]`,
},
},
},
@@ -731,104 +700,6 @@ func TestContainerBoot(t *testing.T) {
},
},
},
{
Name: "metrics_enabled",
Env: map[string]string{
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
"TS_ENABLE_METRICS": "true",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
},
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): 200,
healthURL(localAddrPort): -1,
},
}, {
Notify: runningNotify,
},
},
},
{
Name: "health_enabled",
Env: map[string]string{
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
"TS_ENABLE_HEALTH_CHECK": "true",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
},
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): -1,
healthURL(localAddrPort): 503, // Doesn't start passing until the next phase.
},
}, {
Notify: runningNotify,
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): -1,
healthURL(localAddrPort): 200,
},
},
},
},
{
Name: "metrics_and_health_on_same_port",
Env: map[string]string{
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
"TS_ENABLE_METRICS": "true",
"TS_ENABLE_HEALTH_CHECK": "true",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
},
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): 200,
healthURL(localAddrPort): 503, // Doesn't start passing until the next phase.
},
}, {
Notify: runningNotify,
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): 200,
healthURL(localAddrPort): 200,
},
},
},
},
{
Name: "local_metrics_and_deprecated_health",
Env: map[string]string{
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
"TS_ENABLE_METRICS": "true",
"TS_HEALTHCHECK_ADDR_PORT": fmt.Sprintf("[::]:%d", healthAddrPort),
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
},
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): 200,
healthURL(healthAddrPort): 503, // Doesn't start passing until the next phase.
},
}, {
Notify: runningNotify,
EndpointStatuses: map[string]int{
metricsURL(localAddrPort): 200,
healthURL(healthAddrPort): 200,
},
},
},
},
}
for _, test := range tests {
@@ -925,26 +796,7 @@ func TestContainerBoot(t *testing.T) {
return nil
})
if err != nil {
t.Fatalf("phase %d: %v", i, err)
}
for url, want := range p.EndpointStatuses {
err := tstest.WaitFor(2*time.Second, func() error {
resp, err := http.Get(url)
if err != nil && want != -1 {
return fmt.Errorf("GET %s: %v", url, err)
}
if want > 0 && resp.StatusCode != want {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GET %s, want %d, got %d\n%s", url, want, resp.StatusCode, string(body))
}
return nil
})
if err != nil {
t.Fatalf("phase %d: %v", i, err)
}
t.Fatal(err)
}
}
waitLogLine(t, 2*time.Second, cbOut, "Startup complete, waiting for shutdown signal")
@@ -1103,12 +955,6 @@ func (l *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
panic(fmt.Sprintf("unsupported method %q", r.Method))
}
case "/localapi/v0/usermetrics":
if r.Method != "GET" {
panic(fmt.Sprintf("unsupported method %q", r.Method))
}
w.Write([]byte("fake metrics"))
return
default:
panic(fmt.Sprintf("unsupported path %q", r.URL.Path))
}

View File

@@ -1,79 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"fmt"
"io"
"net/http"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
)
// metrics is a simple metrics HTTP server, if enabled it forwards requests to
// the tailscaled's LocalAPI usermetrics endpoint at /localapi/v0/usermetrics.
type metrics struct {
debugEndpoint string
lc *tailscale.LocalClient
}
func proxy(w http.ResponseWriter, r *http.Request, url string, do func(*http.Request) (*http.Response, error)) {
req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("failed to construct request: %s", err), http.StatusInternalServerError)
return
}
req.Header = r.Header.Clone()
resp, err := do(req)
if err != nil {
http.Error(w, fmt.Sprintf("failed to proxy request: %s", err), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
for key, val := range resp.Header {
for _, v := range val {
w.Header().Add(key, v)
}
}
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (m *metrics) handleMetrics(w http.ResponseWriter, r *http.Request) {
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi/v0/usermetrics"
proxy(w, r, localAPIURL, m.lc.DoLocalRequest)
}
func (m *metrics) handleDebug(w http.ResponseWriter, r *http.Request) {
if m.debugEndpoint == "" {
http.Error(w, "debug endpoint not configured", http.StatusNotFound)
return
}
debugURL := "http://" + m.debugEndpoint + r.URL.Path
proxy(w, r, debugURL, http.DefaultClient.Do)
}
// metricsHandlers registers a simple HTTP metrics handler at /metrics, forwarding
// requests to tailscaled's /localapi/v0/usermetrics API.
//
// In 1.78.x and 1.80.x, it also proxies debug paths to tailscaled's debug
// endpoint if configured to ease migration for a breaking change serving user
// metrics instead of debug metrics on the "metrics" port.
func metricsHandlers(mux *http.ServeMux, lc *tailscale.LocalClient, debugAddrPort string) {
m := &metrics{
lc: lc,
debugEndpoint: debugAddrPort,
}
mux.HandleFunc("GET /metrics", m.handleMetrics)
mux.HandleFunc("/debug/", m.handleDebug) // TODO(tomhjp): Remove for 1.82.0 release.
}

View File

@@ -1,134 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"bytes"
"context"
"encoding/json"
"log"
"os"
"path/filepath"
"reflect"
"sync/atomic"
"time"
"github.com/fsnotify/fsnotify"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/kube/kubetypes"
"tailscale.com/types/netmap"
)
// watchServeConfigChanges watches path for changes, and when it sees one, reads
// the serve config from it, replacing ${TS_CERT_DOMAIN} with certDomain, and
// applies it to lc. It exits when ctx is canceled. cdChanged is a channel that
// is written to when the certDomain changes, causing the serve config to be
// re-read and applied.
func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan bool, certDomainAtomic *atomic.Pointer[string], lc *tailscale.LocalClient, kc *kubeClient) {
if certDomainAtomic == nil {
panic("certDomainAtomic must not be nil")
}
var tickChan <-chan time.Time
var eventChan <-chan fsnotify.Event
if w, err := fsnotify.NewWatcher(); err != nil {
log.Printf("serve proxy: failed to create fsnotify watcher, timer-only mode: %v", err)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
tickChan = ticker.C
} else {
defer w.Close()
if err := w.Add(filepath.Dir(path)); err != nil {
log.Fatalf("serve proxy: failed to add fsnotify watch: %v", err)
}
eventChan = w.Events
}
var certDomain string
var prevServeConfig *ipn.ServeConfig
for {
select {
case <-ctx.Done():
return
case <-cdChanged:
certDomain = *certDomainAtomic.Load()
case <-tickChan:
case <-eventChan:
// We can't do any reasonable filtering on the event because of how
// k8s handles these mounts. So just re-read the file and apply it
// if it's changed.
}
sc, err := readServeConfig(path, certDomain)
if err != nil {
log.Fatalf("serve proxy: failed to read serve config: %v", err)
}
if prevServeConfig != nil && reflect.DeepEqual(sc, prevServeConfig) {
continue
}
validateHTTPSServe(certDomain, sc)
if err := updateServeConfig(ctx, sc, certDomain, lc); err != nil {
log.Fatalf("serve proxy: error updating serve config: %v", err)
}
if err := kc.storeHTTPSEndpoint(ctx, certDomain); err != nil {
log.Fatalf("serve proxy: error storing HTTPS endpoint: %v", err)
}
prevServeConfig = sc
}
}
func certDomainFromNetmap(nm *netmap.NetworkMap) string {
if len(nm.DNS.CertDomains) == 0 {
return ""
}
return nm.DNS.CertDomains[0]
}
func updateServeConfig(ctx context.Context, sc *ipn.ServeConfig, certDomain string, lc *tailscale.LocalClient) error {
// TODO(irbekrm): This means that serve config that does not expose HTTPS endpoint will not be set for a tailnet
// that does not have HTTPS enabled. We probably want to fix this.
if certDomain == kubetypes.ValueNoHTTPS {
return nil
}
log.Printf("serve proxy: applying serve config")
return lc.SetServeConfig(ctx, sc)
}
func validateHTTPSServe(certDomain string, sc *ipn.ServeConfig) {
if certDomain != kubetypes.ValueNoHTTPS || !hasHTTPSEndpoint(sc) {
return
}
log.Printf(
`serve proxy: this node is configured as a proxy that exposes an HTTPS endpoint to tailnet,
(perhaps a Kubernetes operator Ingress proxy) but it is not able to issue TLS certs, so this will likely not work.
To make it work, ensure that HTTPS is enabled for your tailnet, see https://tailscale.com/kb/1153/enabling-https for more details.`)
}
func hasHTTPSEndpoint(cfg *ipn.ServeConfig) bool {
for _, tcpCfg := range cfg.TCP {
if tcpCfg.HTTPS {
return true
}
}
return false
}
// readServeConfig reads the ipn.ServeConfig from path, replacing
// ${TS_CERT_DOMAIN} with certDomain.
func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
if path == "" {
return nil, nil
}
j, err := os.ReadFile(path)
if err != nil {
return nil, err
}
j = bytes.ReplaceAll(j, []byte("${TS_CERT_DOMAIN}"), []byte(certDomain))
var sc ipn.ServeConfig
if err := json.Unmarshal(j, &sc); err != nil {
return nil, err
}
return &sc, nil
}

View File

@@ -1,571 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/netip"
"os"
"path/filepath"
"reflect"
"strings"
"time"
"github.com/fsnotify/fsnotify"
"tailscale.com/ipn"
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubeclient"
"tailscale.com/tailcfg"
"tailscale.com/util/linuxfw"
"tailscale.com/util/mak"
)
const tailscaleTunInterface = "tailscale0"
// This file contains functionality to run containerboot as a proxy that can
// route cluster traffic to one or more tailnet targets, based on portmapping
// rules read from a configfile. Currently (9/2024) this is only used for the
// Kubernetes operator egress proxies.
// egressProxy knows how to configure firewall rules to route cluster traffic to
// one or more tailnet services.
type egressProxy struct {
cfgPath string // path to egress service config file
nfr linuxfw.NetfilterRunner // never nil
kc kubeclient.Client // never nil
stateSecret string // name of the kube state Secret
netmapChan chan ipn.Notify // chan to receive netmap updates on
podIPv4 string // never empty string, currently only IPv4 is supported
// tailnetFQDNs is the egress service FQDN to tailnet IP mappings that
// were last used to configure firewall rules for this proxy.
// TODO(irbekrm): target addresses are also stored in the state Secret.
// Evaluate whether we should retrieve them from there and not store in
// memory at all.
targetFQDNs map[string][]netip.Prefix
// used to configure firewall rules.
tailnetAddrs []netip.Prefix
}
// run configures egress proxy firewall rules and ensures that the firewall rules are reconfigured when:
// - the mounted egress config has changed
// - the proxy's tailnet IP addresses have changed
// - tailnet IPs have changed for any backend targets specified by tailnet FQDN
func (ep *egressProxy) run(ctx context.Context, n ipn.Notify) error {
var tickChan <-chan time.Time
var eventChan <-chan fsnotify.Event
// TODO (irbekrm): take a look if this can be pulled into a single func
// shared with serve config loader.
if w, err := fsnotify.NewWatcher(); err != nil {
log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
tickChan = ticker.C
} else {
defer w.Close()
if err := w.Add(filepath.Dir(ep.cfgPath)); err != nil {
return fmt.Errorf("failed to add fsnotify watch: %w", err)
}
eventChan = w.Events
}
if err := ep.sync(ctx, n); err != nil {
return err
}
for {
var err error
select {
case <-ctx.Done():
return nil
case <-tickChan:
err = ep.sync(ctx, n)
case <-eventChan:
log.Printf("config file change detected, ensuring firewall config is up to date...")
err = ep.sync(ctx, n)
case n = <-ep.netmapChan:
shouldResync := ep.shouldResync(n)
if shouldResync {
log.Printf("netmap change detected, ensuring firewall config is up to date...")
err = ep.sync(ctx, n)
}
}
if err != nil {
return fmt.Errorf("error syncing egress service config: %w", err)
}
}
}
// sync triggers an egress proxy config resync. The resync calculates the diff between config and status to determine if
// any firewall rules need to be updated. Currently using status in state Secret as a reference for what is the current
// firewall configuration is good enough because - the status is keyed by the Pod IP - we crash the Pod on errors such
// as failed firewall update
func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
cfgs, err := ep.getConfigs()
if err != nil {
return fmt.Errorf("error retrieving egress service configs: %w", err)
}
status, err := ep.getStatus(ctx)
if err != nil {
return fmt.Errorf("error retrieving current egress proxy status: %w", err)
}
newStatus, err := ep.syncEgressConfigs(cfgs, status, n)
if err != nil {
return fmt.Errorf("error syncing egress service configs: %w", err)
}
if !servicesStatusIsEqual(newStatus, status) {
if err := ep.setStatus(ctx, newStatus, n); err != nil {
return fmt.Errorf("error setting egress proxy status: %w", err)
}
}
return nil
}
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
// Netmap must not be nil.
func (ep *egressProxy) addrsHaveChanged(n ipn.Notify) bool {
return !reflect.DeepEqual(ep.tailnetAddrs, n.NetMap.SelfNode.Addresses())
}
// syncEgressConfigs adds and deletes firewall rules to match the desired
// configuration. It uses the provided status to determine what is currently
// applied and updates the status after a successful sync.
func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *egressservices.Status, n ipn.Notify) (*egressservices.Status, error) {
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
return nil, nil
}
// Delete unnecessary services.
if err := ep.deleteUnnecessaryServices(cfgs, status); err != nil {
return nil, fmt.Errorf("error deleting services: %w", err)
}
newStatus := &egressservices.Status{}
if !wantsServicesConfigured(cfgs) {
return newStatus, nil
}
// Add new services, update rules for any that have changed.
rulesPerSvcToAdd := make(map[string][]rule, 0)
rulesPerSvcToDelete := make(map[string][]rule, 0)
for svcName, cfg := range *cfgs {
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, n)
if err != nil {
return nil, fmt.Errorf("error determining tailnet target IPs: %w", err)
}
rulesToAdd, rulesToDelete, err := updatesForCfg(svcName, cfg, status, tailnetTargetIPs)
if err != nil {
return nil, fmt.Errorf("error validating service changes: %v", err)
}
log.Printf("syncegressservices: looking at svc %s rulesToAdd %d rulesToDelete %d", svcName, len(rulesToAdd), len(rulesToDelete))
if len(rulesToAdd) != 0 {
mak.Set(&rulesPerSvcToAdd, svcName, rulesToAdd)
}
if len(rulesToDelete) != 0 {
mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete)
}
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(n) {
// For each tailnet target, set up SNAT from the local tailnet device address of the matching
// family.
for _, t := range tailnetTargetIPs {
var local netip.Addr
for _, pfx := range n.NetMap.SelfNode.Addresses().All() {
if !pfx.IsSingleIP() {
continue
}
if pfx.Addr().Is4() != t.Is4() {
continue
}
local = pfx.Addr()
break
}
if !local.IsValid() {
return nil, fmt.Errorf("no valid local IP: %v", local)
}
if err := ep.nfr.EnsureSNATForDst(local, t); err != nil {
return nil, fmt.Errorf("error setting up SNAT rule: %w", err)
}
}
}
// Update the status. Status will be written back to the state Secret by the caller.
mak.Set(&newStatus.Services, svcName, &egressservices.ServiceStatus{TailnetTargetIPs: tailnetTargetIPs, TailnetTarget: cfg.TailnetTarget, Ports: cfg.Ports})
}
// Actually apply the firewall rules.
if err := ensureRulesAdded(rulesPerSvcToAdd, ep.nfr); err != nil {
return nil, fmt.Errorf("error adding rules: %w", err)
}
if err := ensureRulesDeleted(rulesPerSvcToDelete, ep.nfr); err != nil {
return nil, fmt.Errorf("error deleting rules: %w", err)
}
return newStatus, nil
}
// updatesForCfg calculates any rules that need to be added or deleted for an individucal egress service config.
func updatesForCfg(svcName string, cfg egressservices.Config, status *egressservices.Status, tailnetTargetIPs []netip.Addr) ([]rule, []rule, error) {
rulesToAdd := make([]rule, 0)
rulesToDelete := make([]rule, 0)
currentConfig, ok := lookupCurrentConfig(svcName, status)
// If no rules for service are present yet, add them all.
if !ok {
for _, t := range tailnetTargetIPs {
for ports := range cfg.Ports {
log.Printf("syncegressservices: svc %s adding port %v", svcName, ports)
rulesToAdd = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: t})
}
}
return rulesToAdd, rulesToDelete, nil
}
// If there are no backend targets available, delete any currently configured rules.
if len(tailnetTargetIPs) == 0 {
log.Printf("tailnet target for egress service %s does not have any backend addresses, deleting all rules", svcName)
for _, ip := range currentConfig.TailnetTargetIPs {
for ports := range currentConfig.Ports {
rulesToDelete = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip})
}
}
return rulesToAdd, rulesToDelete, nil
}
// If there are rules present for backend targets that no longer match, delete them.
for _, ip := range currentConfig.TailnetTargetIPs {
var found bool
for _, wantsIP := range tailnetTargetIPs {
if reflect.DeepEqual(ip, wantsIP) {
found = true
break
}
}
if !found {
for ports := range currentConfig.Ports {
rulesToDelete = append(rulesToDelete, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip})
}
}
}
// Sync rules for the currently wanted backend targets.
for _, ip := range tailnetTargetIPs {
// If the backend target is not yet present in status, add all rules.
var found bool
for _, gotIP := range currentConfig.TailnetTargetIPs {
if reflect.DeepEqual(ip, gotIP) {
found = true
break
}
}
if !found {
for ports := range cfg.Ports {
rulesToAdd = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip})
}
continue
}
// If the backend target is present in status, check that the
// currently applied rules are up to date.
// Delete any current portmappings that are no longer present in config.
for port := range currentConfig.Ports {
if _, ok := cfg.Ports[port]; ok {
continue
}
rulesToDelete = append(rulesToDelete, rule{tailnetPort: port.TargetPort, containerPort: port.MatchPort, protocol: port.Protocol, tailnetIP: ip})
}
// Add any new portmappings.
for port := range cfg.Ports {
if _, ok := currentConfig.Ports[port]; ok {
continue
}
rulesToAdd = append(rulesToAdd, rule{tailnetPort: port.TargetPort, containerPort: port.MatchPort, protocol: port.Protocol, tailnetIP: ip})
}
}
return rulesToAdd, rulesToDelete, nil
}
// deleteUnneccessaryServices ensure that any services found on status, but not
// present in config are deleted.
func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, status *egressservices.Status) error {
if !hasServicesConfigured(status) {
return nil
}
if !wantsServicesConfigured(cfgs) {
for svcName, svc := range status.Services {
log.Printf("service %s is no longer required, deleting", svcName)
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
return fmt.Errorf("error deleting service %s: %w", svcName, err)
}
}
return nil
}
for svcName, svc := range status.Services {
if _, ok := (*cfgs)[svcName]; !ok {
log.Printf("service %s is no longer required, deleting", svcName)
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
return fmt.Errorf("error deleting service %s: %w", svcName, err)
}
// TODO (irbekrm): also delete the SNAT rule here
}
}
return nil
}
// getConfigs gets the mounted egress service configuration.
func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) {
j, err := os.ReadFile(ep.cfgPath)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
if len(j) == 0 || string(j) == "" {
return nil, nil
}
cfg := &egressservices.Configs{}
if err := json.Unmarshal(j, &cfg); err != nil {
return nil, err
}
return cfg, nil
}
// getStatus gets the current status of the configured firewall. The current
// status is stored in state Secret. Returns nil status if no status that
// applies to the current proxy Pod was found. Uses the Pod IP to determine if a
// status found in the state Secret applies to this proxy Pod.
func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, error) {
secret, err := ep.kc.GetSecret(ctx, ep.stateSecret)
if err != nil {
return nil, fmt.Errorf("error retrieving state secret: %w", err)
}
status := &egressservices.Status{}
raw, ok := secret.Data[egressservices.KeyEgressServices]
if !ok {
return nil, nil
}
if err := json.Unmarshal([]byte(raw), status); err != nil {
return nil, fmt.Errorf("error unmarshalling previous config: %w", err)
}
if reflect.DeepEqual(status.PodIPv4, ep.podIPv4) {
return status, nil
}
return nil, nil
}
// setStatus writes egress proxy's currently configured firewall to the state
// Secret and updates proxy's tailnet addresses.
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, n ipn.Notify) error {
// Pod IP is used to determine if a stored status applies to THIS proxy Pod.
if status == nil {
status = &egressservices.Status{}
}
status.PodIPv4 = ep.podIPv4
secret, err := ep.kc.GetSecret(ctx, ep.stateSecret)
if err != nil {
return fmt.Errorf("error retrieving state Secret: %w", err)
}
bs, err := json.Marshal(status)
if err != nil {
return fmt.Errorf("error marshalling service config: %w", err)
}
secret.Data[egressservices.KeyEgressServices] = bs
patch := kubeclient.JSONPatch{
Op: "replace",
Path: fmt.Sprintf("/data/%s", egressservices.KeyEgressServices),
Value: bs,
}
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
return fmt.Errorf("error patching state Secret: %w", err)
}
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
return nil
}
// tailnetTargetIPsForSvc returns the tailnet IPs to which traffic for this
// egress service should be proxied. The egress service can be configured by IP
// or by FQDN. If it's configured by IP, just return that. If it's configured by
// FQDN, resolve the FQDN and return the resolved IPs. It checks if the
// netfilter runner supports IPv6 NAT and skips any IPv6 addresses if it
// doesn't.
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.Notify) (addrs []netip.Addr, err error) {
if svc.TailnetTarget.IP != "" {
addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
if err != nil {
return nil, fmt.Errorf("error parsing tailnet target IP: %w", err)
}
if addr.Is6() && !ep.nfr.HasIPV6NAT() {
log.Printf("tailnet target is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode. This will probably not work.")
return addrs, nil
}
return []netip.Addr{addr}, nil
}
if svc.TailnetTarget.FQDN == "" {
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
}
if n.NetMap == nil {
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
return addrs, nil
}
var (
node tailcfg.NodeView
nodeFound bool
)
for _, nn := range n.NetMap.Peers {
if equalFQDNs(nn.Name(), svc.TailnetTarget.FQDN) {
node = nn
nodeFound = true
break
}
}
if nodeFound {
for _, addr := range node.Addresses().AsSlice() {
if addr.Addr().Is6() && !ep.nfr.HasIPV6NAT() {
log.Printf("tailnet target %v is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode, skipping.", addr.Addr().String())
continue
}
addrs = append(addrs, addr.Addr())
}
// Egress target endpoints configured via FQDN are stored, so
// that we can determine if a netmap update should trigger a
// resync.
mak.Set(&ep.targetFQDNs, svc.TailnetTarget.FQDN, node.Addresses().AsSlice())
}
return addrs, nil
}
// shouldResync parses netmap update and returns true if the update contains
// changes for which the egress proxy's firewall should be reconfigured.
func (ep *egressProxy) shouldResync(n ipn.Notify) bool {
if n.NetMap == nil {
return false
}
// If proxy's tailnet addresses have changed, resync.
if !reflect.DeepEqual(n.NetMap.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
log.Printf("node addresses have changed, trigger egress config resync")
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
return true
}
// If the IPs for any of the egress services configured via FQDN have
// changed, resync.
for fqdn, ips := range ep.targetFQDNs {
for _, nn := range n.NetMap.Peers {
if equalFQDNs(nn.Name(), fqdn) {
if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) {
log.Printf("backend addresses for egress target %q have changed old IPs %v, new IPs %v trigger egress config resync", nn.Name(), ips, nn.Addresses().AsSlice())
}
return true
}
}
}
return false
}
// ensureServiceDeleted ensures that any rules for an egress service are removed
// from the firewall configuration.
func ensureServiceDeleted(svcName string, svc *egressservices.ServiceStatus, nfr linuxfw.NetfilterRunner) error {
// Note that the portmap is needed for iptables based firewall only.
// Nftables group rules for a service in a chain, so there is no need to
// specify individual portmapping based rules.
pms := make([]linuxfw.PortMap, 0)
for pm := range svc.Ports {
pms = append(pms, linuxfw.PortMap{MatchPort: pm.MatchPort, TargetPort: pm.TargetPort, Protocol: pm.Protocol})
}
if err := nfr.DeleteSvc(svcName, tailscaleTunInterface, svc.TailnetTargetIPs, pms); err != nil {
return fmt.Errorf("error deleting service %s: %w", svcName, err)
}
return nil
}
// ensureRulesAdded ensures that all portmapping rules are added to the firewall
// configuration. For any rules that already exist, calling this function is a
// no-op. In case of nftables, a service consists of one or two (one per IP
// family) chains that conain the portmapping rules for the service and the
// chains as needed when this function is called.
func ensureRulesAdded(rulesPerSvc map[string][]rule, nfr linuxfw.NetfilterRunner) error {
for svc, rules := range rulesPerSvc {
for _, rule := range rules {
log.Printf("ensureRulesAdded svc %s tailnetTarget %s container port %d tailnet port %d protocol %s", svc, rule.tailnetIP, rule.containerPort, rule.tailnetPort, rule.protocol)
if err := nfr.EnsurePortMapRuleForSvc(svc, tailscaleTunInterface, rule.tailnetIP, linuxfw.PortMap{MatchPort: rule.containerPort, TargetPort: rule.tailnetPort, Protocol: rule.protocol}); err != nil {
return fmt.Errorf("error ensuring rule: %w", err)
}
}
}
return nil
}
// ensureRulesDeleted ensures that the given rules are deleted from the firewall
// configuration. For any rules that do not exist, calling this funcion is a
// no-op.
func ensureRulesDeleted(rulesPerSvc map[string][]rule, nfr linuxfw.NetfilterRunner) error {
for svc, rules := range rulesPerSvc {
for _, rule := range rules {
log.Printf("ensureRulesDeleted svc %s tailnetTarget %s container port %d tailnet port %d protocol %s", svc, rule.tailnetIP, rule.containerPort, rule.tailnetPort, rule.protocol)
if err := nfr.DeletePortMapRuleForSvc(svc, tailscaleTunInterface, rule.tailnetIP, linuxfw.PortMap{MatchPort: rule.containerPort, TargetPort: rule.tailnetPort, Protocol: rule.protocol}); err != nil {
return fmt.Errorf("error deleting rule: %w", err)
}
}
}
return nil
}
func lookupCurrentConfig(svcName string, status *egressservices.Status) (*egressservices.ServiceStatus, bool) {
if status == nil || len(status.Services) == 0 {
return nil, false
}
c, ok := status.Services[svcName]
return c, ok
}
func equalFQDNs(s, s1 string) bool {
s, _ = strings.CutSuffix(s, ".")
s1, _ = strings.CutSuffix(s1, ".")
return strings.EqualFold(s, s1)
}
// rule contains configuration for an egress proxy firewall rule.
type rule struct {
containerPort uint16 // port to match incoming traffic
tailnetPort uint16 // tailnet service port
tailnetIP netip.Addr // tailnet service IP
protocol string
}
func wantsServicesConfigured(cfgs *egressservices.Configs) bool {
return cfgs != nil && len(*cfgs) != 0
}
func hasServicesConfigured(status *egressservices.Status) bool {
return status != nil && len(status.Services) != 0
}
func servicesStatusIsEqual(st, st1 *egressservices.Status) bool {
if st == nil && st1 == nil {
return true
}
if st == nil || st1 == nil {
return false
}
st.PodIPv4 = ""
st1.PodIPv4 = ""
return reflect.DeepEqual(*st, *st1)
}

View File

@@ -1,175 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"net/netip"
"reflect"
"testing"
"tailscale.com/kube/egressservices"
)
func Test_updatesForSvc(t *testing.T) {
tailnetIPv4, tailnetIPv6 := netip.MustParseAddr("100.99.99.99"), netip.MustParseAddr("fd7a:115c:a1e0::701:b62a")
tailnetIPv4_1, tailnetIPv6_1 := netip.MustParseAddr("100.88.88.88"), netip.MustParseAddr("fd7a:115c:a1e0::4101:512f")
ports := map[egressservices.PortMap]struct{}{{Protocol: "tcp", MatchPort: 4003, TargetPort: 80}: {}}
ports1 := map[egressservices.PortMap]struct{}{{Protocol: "udp", MatchPort: 4004, TargetPort: 53}: {}}
ports2 := map[egressservices.PortMap]struct{}{{Protocol: "tcp", MatchPort: 4003, TargetPort: 80}: {},
{Protocol: "tcp", MatchPort: 4005, TargetPort: 443}: {}}
fqdnSpec := egressservices.Config{
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
Ports: ports,
}
fqdnSpec1 := egressservices.Config{
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
Ports: ports1,
}
fqdnSpec2 := egressservices.Config{
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
Ports: ports,
}
fqdnSpec3 := egressservices.Config{
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
Ports: ports2,
}
r := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv4}
r1 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv6}
r2 := rule{tailnetPort: 53, containerPort: 4004, protocol: "udp", tailnetIP: tailnetIPv4}
r3 := rule{tailnetPort: 53, containerPort: 4004, protocol: "udp", tailnetIP: tailnetIPv6}
r4 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv4_1}
r5 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv6_1}
r6 := rule{containerPort: 4005, tailnetPort: 443, protocol: "tcp", tailnetIP: tailnetIPv4}
tests := []struct {
name string
svcName string
tailnetTargetIPs []netip.Addr
podIP string
spec egressservices.Config
status *egressservices.Status
wantRulesToAdd []rule
wantRulesToDelete []rule
}{
{
name: "add_fqdn_svc_that_does_not_yet_exist",
svcName: "test",
tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
spec: fqdnSpec,
status: &egressservices.Status{},
wantRulesToAdd: []rule{r, r1},
wantRulesToDelete: []rule{},
},
{
name: "fqdn_svc_already_exists",
svcName: "test",
tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
spec: fqdnSpec,
status: &egressservices.Status{
Services: map[string]*egressservices.ServiceStatus{"test": {
TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
Ports: ports,
}}},
wantRulesToAdd: []rule{},
wantRulesToDelete: []rule{},
},
{
name: "fqdn_svc_already_exists_add_port_remove_port",
svcName: "test",
tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
spec: fqdnSpec1,
status: &egressservices.Status{
Services: map[string]*egressservices.ServiceStatus{"test": {
TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
Ports: ports,
}}},
wantRulesToAdd: []rule{r2, r3},
wantRulesToDelete: []rule{r, r1},
},
{
name: "fqdn_svc_already_exists_change_fqdn_backend_ips",
svcName: "test",
tailnetTargetIPs: []netip.Addr{tailnetIPv4_1, tailnetIPv6_1},
spec: fqdnSpec,
status: &egressservices.Status{
Services: map[string]*egressservices.ServiceStatus{"test": {
TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
Ports: ports,
}}},
wantRulesToAdd: []rule{r4, r5},
wantRulesToDelete: []rule{r, r1},
},
{
name: "add_ip_service",
svcName: "test",
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
spec: fqdnSpec2,
status: &egressservices.Status{},
wantRulesToAdd: []rule{r},
wantRulesToDelete: []rule{},
},
{
name: "add_ip_service_already_exists",
svcName: "test",
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
spec: fqdnSpec2,
status: &egressservices.Status{
Services: map[string]*egressservices.ServiceStatus{"test": {
TailnetTargetIPs: []netip.Addr{tailnetIPv4},
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
Ports: ports,
}}},
wantRulesToAdd: []rule{},
wantRulesToDelete: []rule{},
},
{
name: "ip_service_add_port",
svcName: "test",
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
spec: fqdnSpec3,
status: &egressservices.Status{
Services: map[string]*egressservices.ServiceStatus{"test": {
TailnetTargetIPs: []netip.Addr{tailnetIPv4},
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
Ports: ports,
}}},
wantRulesToAdd: []rule{r6},
wantRulesToDelete: []rule{},
},
{
name: "ip_service_delete_port",
svcName: "test",
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
spec: fqdnSpec,
status: &egressservices.Status{
Services: map[string]*egressservices.ServiceStatus{"test": {
TailnetTargetIPs: []netip.Addr{tailnetIPv4},
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
Ports: ports2,
}}},
wantRulesToAdd: []rule{},
wantRulesToDelete: []rule{r6},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotRulesToAdd, gotRulesToDelete, err := updatesForCfg(tt.svcName, tt.spec, tt.status, tt.tailnetTargetIPs)
if err != nil {
t.Errorf("updatesForSvc() unexpected error %v", err)
return
}
if !reflect.DeepEqual(gotRulesToAdd, tt.wantRulesToAdd) {
t.Errorf("updatesForSvc() got rulesToAdd = \n%v\n want rulesToAdd \n%v", gotRulesToAdd, tt.wantRulesToAdd)
}
if !reflect.DeepEqual(gotRulesToDelete, tt.wantRulesToDelete) {
t.Errorf("updatesForSvc() got rulesToDelete = \n%v\n want rulesToDelete \n%v", gotRulesToDelete, tt.wantRulesToDelete)
}
})
}
}

View File

@@ -1,356 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"context"
"errors"
"fmt"
"log"
"net/netip"
"os"
"path"
"strconv"
"strings"
"tailscale.com/ipn/conffile"
"tailscale.com/kube/kubeclient"
)
// settings is all the configuration for containerboot.
type settings struct {
AuthKey string
Hostname string
Routes *string
// ProxyTargetIP is the destination IP to which all incoming
// Tailscale traffic should be proxied. If empty, no proxying
// is done. This is typically a locally reachable IP.
ProxyTargetIP string
// ProxyTargetDNSName is a DNS name to whose backing IP addresses all
// incoming Tailscale traffic should be proxied.
ProxyTargetDNSName string
// TailnetTargetIP is the destination IP to which all incoming
// non-Tailscale traffic should be proxied. This is typically a
// Tailscale IP.
TailnetTargetIP string
// TailnetTargetFQDN is an MagicDNS name to which all incoming
// non-Tailscale traffic should be proxied. This must be a full Tailnet
// node FQDN.
TailnetTargetFQDN string
ServeConfigPath string
DaemonExtraArgs string
ExtraArgs string
InKubernetes bool
UserspaceMode bool
StateDir string
AcceptDNS *bool
KubeSecret string
SOCKSProxyAddr string
HTTPProxyAddr string
Socket string
AuthOnce bool
Root string
KubernetesCanPatch bool
TailscaledConfigFilePath string
EnableForwardingOptimizations bool
// If set to true and, if this containerboot instance is a Kubernetes
// ingress proxy, set up rules to forward incoming cluster traffic to be
// forwarded to the ingress target in cluster.
AllowProxyingClusterTrafficViaIngress bool
// PodIP is the IP of the Pod if running in Kubernetes. This is used
// when setting up rules to proxy cluster traffic to cluster ingress
// target.
// Deprecated: use PodIPv4, PodIPv6 instead to support dual stack clusters
PodIP string
PodIPv4 string
PodIPv6 string
PodUID string
HealthCheckAddrPort string
LocalAddrPort string
MetricsEnabled bool
HealthCheckEnabled bool
DebugAddrPort string
EgressSvcsCfgPath string
}
func configFromEnv() (*settings, error) {
cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnvStringPointer("TS_ROUTES"),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTargetIP: defaultEnv("TS_DEST_IP", ""),
ProxyTargetDNSName: defaultEnv("TS_EXPERIMENTAL_DEST_DNS_NAME", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
UserspaceMode: defaultBool("TS_USERSPACE", true),
StateDir: defaultEnv("TS_STATE_DIR", ""),
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
TailscaledConfigFilePath: tailscaledConfigFilePath(),
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
PodIP: defaultEnv("POD_IP", ""),
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""),
LocalAddrPort: defaultEnv("TS_LOCAL_ADDR_PORT", "[::]:9002"),
MetricsEnabled: defaultBool("TS_ENABLE_METRICS", false),
HealthCheckEnabled: defaultBool("TS_ENABLE_HEALTH_CHECK", false),
DebugAddrPort: defaultEnv("TS_DEBUG_ADDR_PORT", ""),
EgressSvcsCfgPath: defaultEnv("TS_EGRESS_SERVICES_CONFIG_PATH", ""),
PodUID: defaultEnv("POD_UID", ""),
}
podIPs, ok := os.LookupEnv("POD_IPS")
if ok {
ips := strings.Split(podIPs, ",")
if len(ips) > 2 {
return nil, fmt.Errorf("POD_IPs can contain at most 2 IPs, got %d (%v)", len(ips), ips)
}
for _, ip := range ips {
parsed, err := netip.ParseAddr(ip)
if err != nil {
return nil, fmt.Errorf("error parsing IP address %s: %w", ip, err)
}
if parsed.Is4() {
cfg.PodIPv4 = parsed.String()
continue
}
cfg.PodIPv6 = parsed.String()
}
}
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %v", err)
}
return cfg, nil
}
func (s *settings) validate() error {
if s.TailscaledConfigFilePath != "" {
dir, file := path.Split(s.TailscaledConfigFilePath)
if _, err := os.Stat(dir); err != nil {
return fmt.Errorf("error validating whether directory with tailscaled config file %s exists: %w", dir, err)
}
if _, err := os.Stat(s.TailscaledConfigFilePath); err != nil {
return fmt.Errorf("error validating whether tailscaled config directory %q contains tailscaled config for current capability version %q: %w. If this is a Tailscale Kubernetes operator proxy, please ensure that the version of the operator is not older than the version of the proxy", dir, file, err)
}
if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil {
return fmt.Errorf("error validating tailscaled configfile contents: %w", err)
}
}
if s.ProxyTargetIP != "" && s.UserspaceMode {
return errors.New("TS_DEST_IP is not supported with TS_USERSPACE")
}
if s.ProxyTargetDNSName != "" && s.UserspaceMode {
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME is not supported with TS_USERSPACE")
}
if s.ProxyTargetDNSName != "" && s.ProxyTargetIP != "" {
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME and TS_DEST_IP cannot both be set")
}
if s.TailnetTargetIP != "" && s.UserspaceMode {
return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
}
if s.TailnetTargetFQDN != "" && s.UserspaceMode {
return errors.New("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
}
if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" {
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
}
if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") {
return errors.New("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
}
if s.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode")
}
if s.AllowProxyingClusterTrafficViaIngress && s.ServeConfigPath == "" {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but this is not a cluster ingress proxy")
}
if s.AllowProxyingClusterTrafficViaIngress && s.PodIP == "" {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but POD_IP is not set")
}
if s.EnableForwardingOptimizations && s.UserspaceMode {
return errors.New("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS is not supported in userspace mode")
}
if s.HealthCheckAddrPort != "" {
log.Printf("[warning] TS_HEALTHCHECK_ADDR_PORT is deprecated and will be removed in 1.82.0. Please use TS_ENABLE_HEALTH_CHECK and optionally TS_LOCAL_ADDR_PORT instead.")
if _, err := netip.ParseAddrPort(s.HealthCheckAddrPort); err != nil {
return fmt.Errorf("error parsing TS_HEALTHCHECK_ADDR_PORT value %q: %w", s.HealthCheckAddrPort, err)
}
}
if s.localMetricsEnabled() || s.localHealthEnabled() {
if _, err := netip.ParseAddrPort(s.LocalAddrPort); err != nil {
return fmt.Errorf("error parsing TS_LOCAL_ADDR_PORT value %q: %w", s.LocalAddrPort, err)
}
}
if s.DebugAddrPort != "" {
if _, err := netip.ParseAddrPort(s.DebugAddrPort); err != nil {
return fmt.Errorf("error parsing TS_DEBUG_ADDR_PORT value %q: %w", s.DebugAddrPort, err)
}
}
if s.HealthCheckEnabled && s.HealthCheckAddrPort != "" {
return errors.New("TS_HEALTHCHECK_ADDR_PORT is deprecated and will be removed in 1.82.0, use TS_ENABLE_HEALTH_CHECK and optionally TS_LOCAL_ADDR_PORT")
}
return nil
}
// setupKube is responsible for doing any necessary configuration and checks to
// ensure that tailscale state storage and authentication mechanism will work on
// Kubernetes.
func (cfg *settings) setupKube(ctx context.Context, kc *kubeClient) error {
if cfg.KubeSecret == "" {
return nil
}
canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret)
if err != nil {
return fmt.Errorf("some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
}
cfg.KubernetesCanPatch = canPatch
s, err := kc.GetSecret(ctx, cfg.KubeSecret)
if err != nil {
if !kubeclient.IsNotFoundErr(err) {
return fmt.Errorf("getting Tailscale state Secret %s: %v", cfg.KubeSecret, err)
}
if !canCreate {
return fmt.Errorf("tailscale state Secret %s does not exist and we don't have permissions to create it. "+
"If you intend to store tailscale state elsewhere than a Kubernetes Secret, "+
"you can explicitly set TS_KUBE_SECRET env var to an empty string. "+
"Else ensure that RBAC is set up that allows the service account associated with this installation to create Secrets.", cfg.KubeSecret)
}
}
// Return early if we already have an auth key.
if cfg.AuthKey != "" || isOneStepConfig(cfg) {
return nil
}
if s == nil {
log.Print("TS_AUTHKEY not provided and state Secret does not exist, login will be interactive if needed.")
return nil
}
keyBytes, _ := s.Data["authkey"]
key := string(keyBytes)
if key != "" {
// Enforce that we must be able to patch out the authkey after
// authenticating if you want to use this feature. This avoids
// us having to deal with the case where we might leave behind
// an unnecessary reusable authkey in a secret, like a rake in
// the grass.
if !cfg.KubernetesCanPatch {
return errors.New("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the Secret to manage the authkey.")
}
cfg.AuthKey = key
}
log.Print("No authkey found in state Secret and TS_AUTHKEY not provided, login will be interactive if needed.")
return nil
}
// isTwoStepConfigAuthOnce returns true if the Tailscale node should be configured
// in two steps and login should only happen once.
// Step 1: run 'tailscaled'
// Step 2):
// A) if this is the first time starting this node run 'tailscale up --authkey <authkey> <config opts>'
// B) if this is not the first time starting this node run 'tailscale set <config opts>'.
func isTwoStepConfigAuthOnce(cfg *settings) bool {
return cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
}
// isTwoStepConfigAlwaysAuth returns true if the Tailscale node should be configured
// in two steps and we should log in every time it starts.
// Step 1: run 'tailscaled'
// Step 2): run 'tailscale up --authkey <authkey> <config opts>'
func isTwoStepConfigAlwaysAuth(cfg *settings) bool {
return !cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
}
// isOneStepConfig returns true if the Tailscale node should always be ran and
// configured in a single step by running 'tailscaled <config opts>'
func isOneStepConfig(cfg *settings) bool {
return cfg.TailscaledConfigFilePath != ""
}
// isL3Proxy returns true if the Tailscale node needs to be configured to act
// as an L3 proxy, proxying to an endpoint provided via one of the config env
// vars.
func isL3Proxy(cfg *settings) bool {
return cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress || cfg.EgressSvcsCfgPath != ""
}
// hasKubeStateStore returns true if the state must be stored in a Kubernetes
// Secret.
func hasKubeStateStore(cfg *settings) bool {
return cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != ""
}
func (cfg *settings) localMetricsEnabled() bool {
return cfg.LocalAddrPort != "" && cfg.MetricsEnabled
}
func (cfg *settings) localHealthEnabled() bool {
return cfg.LocalAddrPort != "" && cfg.HealthCheckEnabled
}
// defaultEnv returns the value of the given envvar name, or defVal if
// unset.
func defaultEnv(name, defVal string) string {
if v, ok := os.LookupEnv(name); ok {
return v
}
return defVal
}
// defaultEnvStringPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a
// variable being set to empty string vs unset.
func defaultEnvStringPointer(name string) *string {
if v, ok := os.LookupEnv(name); ok {
return &v
}
return nil
}
// defaultEnvBoolPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a
// variable being explicitly set to false vs unset.
func defaultEnvBoolPointer(name string) *bool {
v := os.Getenv(name)
ret, err := strconv.ParseBool(v)
if err != nil {
return nil
}
return &ret
}
func defaultEnvs(names []string, defVal string) string {
for _, name := range names {
if v, ok := os.LookupEnv(name); ok {
return v
}
}
return defVal
}
// defaultBool returns the boolean value of the given envvar name, or
// defVal if unset or not a bool.
func defaultBool(name string, defVal bool) bool {
v := os.Getenv(name)
ret, err := strconv.ParseBool(v)
if err != nil {
return defVal
}
return ret
}

View File

@@ -1,168 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build linux
package main
import (
"context"
"errors"
"fmt"
"io/fs"
"log"
"os"
"os/exec"
"strings"
"syscall"
"time"
"tailscale.com/client/tailscale"
)
func startTailscaled(ctx context.Context, cfg *settings) (*tailscale.LocalClient, *os.Process, error) {
args := tailscaledArgs(cfg)
// tailscaled runs without context, since it needs to persist
// beyond the startup timeout in ctx.
cmd := exec.Command("tailscaled", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
log.Printf("Starting tailscaled")
if err := cmd.Start(); err != nil {
return nil, nil, fmt.Errorf("starting tailscaled failed: %v", err)
}
// Wait for the socket file to appear, otherwise API ops will racily fail.
log.Printf("Waiting for tailscaled socket")
for {
if ctx.Err() != nil {
log.Fatalf("Timed out waiting for tailscaled socket")
}
_, err := os.Stat(cfg.Socket)
if errors.Is(err, fs.ErrNotExist) {
time.Sleep(100 * time.Millisecond)
continue
} else if err != nil {
log.Fatalf("Waiting for tailscaled socket: %v", err)
}
break
}
tsClient := &tailscale.LocalClient{
Socket: cfg.Socket,
UseSocketOnly: true,
}
return tsClient, cmd.Process, nil
}
// tailscaledArgs uses cfg to construct the argv for tailscaled.
func tailscaledArgs(cfg *settings) []string {
args := []string{"--socket=" + cfg.Socket}
switch {
case cfg.InKubernetes && cfg.KubeSecret != "":
args = append(args, "--state=kube:"+cfg.KubeSecret)
if cfg.StateDir == "" {
cfg.StateDir = "/tmp"
}
fallthrough
case cfg.StateDir != "":
args = append(args, "--statedir="+cfg.StateDir)
default:
args = append(args, "--state=mem:", "--statedir=/tmp")
}
if cfg.UserspaceMode {
args = append(args, "--tun=userspace-networking")
} else if err := ensureTunFile(cfg.Root); err != nil {
log.Fatalf("ensuring that /dev/net/tun exists: %v", err)
}
if cfg.SOCKSProxyAddr != "" {
args = append(args, "--socks5-server="+cfg.SOCKSProxyAddr)
}
if cfg.HTTPProxyAddr != "" {
args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr)
}
if cfg.TailscaledConfigFilePath != "" {
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
}
// Once enough proxy versions have been released for all the supported
// versions to understand this cfg setting, the operator can stop
// setting TS_TAILSCALED_EXTRA_ARGS for the debug flag.
if cfg.DebugAddrPort != "" && !strings.Contains(cfg.DaemonExtraArgs, cfg.DebugAddrPort) {
args = append(args, "--debug="+cfg.DebugAddrPort)
}
if cfg.DaemonExtraArgs != "" {
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
}
return args
}
// tailscaleUp uses cfg to run 'tailscale up' everytime containerboot starts, or
// if TS_AUTH_ONCE is set, only the first time containerboot starts.
func tailscaleUp(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "up"}
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
args = append(args, "--accept-dns=true")
} else {
args = append(args, "--accept-dns=false")
}
if cfg.AuthKey != "" {
args = append(args, "--authkey="+cfg.AuthKey)
}
// --advertise-routes can be passed an empty string to configure a
// device (that might have previously advertised subnet routes) to not
// advertise any routes. Respect an empty string passed by a user and
// use it to explicitly unset the routes.
if cfg.Routes != nil {
args = append(args, "--advertise-routes="+*cfg.Routes)
}
if cfg.Hostname != "" {
args = append(args, "--hostname="+cfg.Hostname)
}
if cfg.ExtraArgs != "" {
args = append(args, strings.Fields(cfg.ExtraArgs)...)
}
log.Printf("Running 'tailscale up'")
cmd := exec.CommandContext(ctx, "tailscale", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tailscale up failed: %v", err)
}
return nil
}
// tailscaleSet uses cfg to run 'tailscale set' to set any known configuration
// options that are passed in via environment variables. This is run after the
// node is in Running state and only if TS_AUTH_ONCE is set.
func tailscaleSet(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "set"}
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
args = append(args, "--accept-dns=true")
} else {
args = append(args, "--accept-dns=false")
}
// --advertise-routes can be passed an empty string to configure a
// device (that might have previously advertised subnet routes) to not
// advertise any routes. Respect an empty string passed by a user and
// use it to explicitly unset the routes.
if cfg.Routes != nil {
args = append(args, "--advertise-routes="+*cfg.Routes)
}
if cfg.Hostname != "" {
args = append(args, "--hostname="+cfg.Hostname)
}
log.Printf("Running 'tailscale set'")
cmd := exec.CommandContext(ctx, "tailscale", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("tailscale set failed: %v", err)
}
return nil
}

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env bash
#
# This is a fake tailscale CLI (and also iptables and ip6tables) that
# records its arguments and exits successfully.
#
# It is used by main_test.go to test the behavior of containerboot.
echo $0 $@ >>$TS_TEST_RECORD_ARGS
#!/usr/bin/env bash
#
# This is a fake tailscale CLI (and also iptables and ip6tables) that
# records its arguments and exits successfully.
#
# It is used by main_test.go to test the behavior of containerboot.
echo $0 $@ >>$TS_TEST_RECORD_ARGS

View File

@@ -1,38 +1,38 @@
#!/usr/bin/env bash
#
# This is a fake tailscale daemon that records its arguments, symlinks a
# fake LocalAPI socket into place, and does nothing until terminated.
#
# It is used by main_test.go to test the behavior of containerboot.
set -eu
echo $0 $@ >>$TS_TEST_RECORD_ARGS
socket=""
while [[ $# -gt 0 ]]; do
case $1 in
--socket=*)
socket="${1#--socket=}"
shift
;;
--socket)
shift
socket="$1"
shift
;;
*)
shift
;;
esac
done
if [[ -z "$socket" ]]; then
echo "didn't find socket path in args"
exit 1
fi
ln -s "$TS_TEST_SOCKET" "$socket"
trap 'rm -f "$socket"' EXIT
while sleep 10; do :; done
#!/usr/bin/env bash
#
# This is a fake tailscale daemon that records its arguments, symlinks a
# fake LocalAPI socket into place, and does nothing until terminated.
#
# It is used by main_test.go to test the behavior of containerboot.
set -eu
echo $0 $@ >>$TS_TEST_RECORD_ARGS
socket=""
while [[ $# -gt 0 ]]; do
case $1 in
--socket=*)
socket="${1#--socket=}"
shift
;;
--socket)
shift
socket="$1"
shift
;;
*)
shift
;;
esac
done
if [[ -z "$socket" ]]; then
echo "didn't find socket path in args"
exit 1
fi
ln -s "$TS_TEST_SOCKET" "$socket"
trap 'rm -f "$socket"' EXIT
while sleep 10; do :; done

View File

@@ -8,7 +8,6 @@ import (
"crypto/x509"
"errors"
"fmt"
"net"
"net/http"
"path/filepath"
"regexp"
@@ -54,9 +53,8 @@ func certProviderByCertMode(mode, dir, hostname string) (certProvider, error) {
}
type manualCertManager struct {
cert *tls.Certificate
hostname string // hostname or IP address of server
noHostname bool // whether hostname is an IP address
cert *tls.Certificate
hostname string
}
// NewManualCertManager returns a cert provider which read certificate by given hostname on create.
@@ -76,11 +74,7 @@ func NewManualCertManager(certdir, hostname string) (certProvider, error) {
if err := x509Cert.VerifyHostname(hostname); err != nil {
return nil, fmt.Errorf("cert invalid for hostname %q: %w", hostname, err)
}
return &manualCertManager{
cert: &cert,
hostname: hostname,
noHostname: net.ParseIP(hostname) != nil,
}, nil
return &manualCertManager{cert: &cert, hostname: hostname}, nil
}
func (m *manualCertManager) TLSConfig() *tls.Config {
@@ -94,7 +88,7 @@ func (m *manualCertManager) TLSConfig() *tls.Config {
}
func (m *manualCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
if hi.ServerName != m.hostname && !m.noHostname {
if hi.ServerName != m.hostname {
return nil, fmt.Errorf("cert mismatch with hostname: %q", hi.ServerName)
}

View File

@@ -1,97 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
)
// Verify that in --certmode=manual mode, we can use a bare IP address
// as the --hostname and that GetCertificate will return it.
func TestCertIP(t *testing.T) {
dir := t.TempDir()
const hostname = "1.2.3.4"
priv, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader)
if err != nil {
t.Fatal(err)
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
t.Fatal(err)
}
ip := net.ParseIP(hostname)
if ip == nil {
t.Fatalf("invalid IP address %q", hostname)
}
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"Tailscale Test Corp"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(30 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{ip},
}
derBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
t.Fatal(err)
}
certOut, err := os.Create(filepath.Join(dir, hostname+".crt"))
if err != nil {
t.Fatal(err)
}
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
t.Fatalf("Failed to write data to cert.pem: %v", err)
}
if err := certOut.Close(); err != nil {
t.Fatalf("Error closing cert.pem: %v", err)
}
keyOut, err := os.OpenFile(filepath.Join(dir, hostname+".key"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
t.Fatal(err)
}
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("Unable to marshal private key: %v", err)
}
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
t.Fatalf("Failed to write data to key.pem: %v", err)
}
if err := keyOut.Close(); err != nil {
t.Fatalf("Error closing key.pem: %v", err)
}
cp, err := certProviderByCertMode("manual", dir, hostname)
if err != nil {
t.Fatal(err)
}
back, err := cp.TLSConfig().GetCertificate(&tls.ClientHelloInfo{
ServerName: "", // no SNI
})
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
if back == nil {
t.Fatalf("GetCertificate returned nil")
}
}

View File

@@ -7,14 +7,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
github.com/coder/websocket from tailscale.com/cmd/derper+
github.com/coder/websocket/internal/errd from github.com/coder/websocket
github.com/coder/websocket/internal/util from github.com/coder/websocket
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/go-json-experiment/json from tailscale.com/types/opt+
github.com/go-json-experiment/json from tailscale.com/types/opt
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
@@ -27,6 +23,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L github.com/google/nftables/expr from github.com/google/nftables+
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/util/fastuuid
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
@@ -51,7 +48,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
W github.com/tailscale/go-winio/internal/stringbuffer from github.com/tailscale/go-winio/internal/fs
W github.com/tailscale/go-winio/pkg/guid from github.com/tailscale/go-winio+
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
L 💣 github.com/tailscale/netlink/nl from github.com/tailscale/netlink
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/client/tailscale+
@@ -85,6 +82,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
google.golang.org/protobuf/runtime/protoiface from google.golang.org/protobuf/internal/impl+
google.golang.org/protobuf/runtime/protoimpl from github.com/prometheus/client_model/go+
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
nhooyr.io/websocket from tailscale.com/cmd/derper+
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
nhooyr.io/websocket/internal/util from nhooyr.io/websocket
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
tailscale.com from tailscale.com/version
tailscale.com/atomicfile from tailscale.com/cmd/derper+
tailscale.com/client/tailscale from tailscale.com/derp
@@ -98,7 +99,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/hostinfo from tailscale.com/net/netmon+
tailscale.com/ipn from tailscale.com/client/tailscale
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/metrics from tailscale.com/cmd/derper+
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
tailscale.com/net/ktimeout from tailscale.com/cmd/derper
@@ -112,10 +112,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/net/stunserver from tailscale.com/cmd/derper
L tailscale.com/net/tcpinfo from tailscale.com/derp
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
tailscale.com/net/tlsdial/blockblame from tailscale.com/net/tlsdial
tailscale.com/net/tsaddr from tailscale.com/ipn+
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
tailscale.com/net/wsconn from tailscale.com/cmd/derper
tailscale.com/net/wsconn from tailscale.com/cmd/derper+
tailscale.com/paths from tailscale.com/client/tailscale
💣 tailscale.com/safesocket from tailscale.com/client/tailscale
tailscale.com/syncs from tailscale.com/cmd/derper+
@@ -128,7 +127,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/tsweb from tailscale.com/cmd/derper
tailscale.com/tsweb/promvarz from tailscale.com/tsweb
tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/dnstype from tailscale.com/tailcfg+
tailscale.com/types/dnstype from tailscale.com/tailcfg
tailscale.com/types/empty from tailscale.com/ipn
tailscale.com/types/ipproto from tailscale.com/tailcfg+
tailscale.com/types/key from tailscale.com/client/tailscale+
@@ -139,7 +138,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/types/persist from tailscale.com/ipn
tailscale.com/types/preftype from tailscale.com/ipn
tailscale.com/types/ptr from tailscale.com/hostinfo+
tailscale.com/types/result from tailscale.com/util/lineiter
tailscale.com/types/structs from tailscale.com/ipn+
tailscale.com/types/tkatype from tailscale.com/client/tailscale+
tailscale.com/types/views from tailscale.com/ipn+
@@ -148,32 +146,21 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
tailscale.com/util/ctxkey from tailscale.com/tsweb+
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/hostinfo+
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/fastuuid from tailscale.com/tsweb
tailscale.com/util/httpm from tailscale.com/client/tailscale
tailscale.com/util/lineiter from tailscale.com/hostinfo+
tailscale.com/util/lineread from tailscale.com/hostinfo+
L tailscale.com/util/linuxfw from tailscale.com/net/netns
tailscale.com/util/mak from tailscale.com/health+
tailscale.com/util/multierr from tailscale.com/health+
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
tailscale.com/util/rands from tailscale.com/tsweb
tailscale.com/util/set from tailscale.com/derp+
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
tailscale.com/util/syspolicy from tailscale.com/ipn
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting+
tailscale.com/util/syspolicy/internal/loggerx from tailscale.com/util/syspolicy/internal/metrics+
tailscale.com/util/syspolicy/internal/metrics from tailscale.com/util/syspolicy/source
tailscale.com/util/syspolicy/rsop from tailscale.com/util/syspolicy
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy+
tailscale.com/util/syspolicy/source from tailscale.com/util/syspolicy+
tailscale.com/util/testenv from tailscale.com/util/syspolicy+
tailscale.com/util/usermetric from tailscale.com/health
tailscale.com/util/vizerror from tailscale.com/tailcfg+
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
W 💣 tailscale.com/util/winutil/gp from tailscale.com/util/syspolicy/source
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/envknob+
@@ -184,17 +171,15 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
golang.org/x/crypto/blake2s from tailscale.com/tka
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
golang.org/x/crypto/chacha20poly1305 from crypto/tls+
golang.org/x/crypto/chacha20poly1305 from crypto/tls
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/hkdf from crypto/tls+
golang.org/x/crypto/hkdf from crypto/tls
golang.org/x/crypto/nacl/box from tailscale.com/types/key
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/sha3 from crypto/internal/mlkem768+
W golang.org/x/exp/constraints from tailscale.com/util/winutil
golang.org/x/exp/maps from tailscale.com/util/syspolicy/setting+
L golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http
@@ -243,6 +228,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
crypto/tls from golang.org/x/crypto/acme+
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
database/sql/driver from github.com/google/uuid
embed from crypto/internal/nistec+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
@@ -254,7 +240,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
encoding/pem from crypto/tls+
errors from bufio+
expvar from github.com/prometheus/client_golang/prometheus+
flag from tailscale.com/cmd/derper+
flag from tailscale.com/cmd/derper
fmt from compress/flate+
go/token from google.golang.org/protobuf/internal/strs
hash from crypto+
@@ -262,11 +248,9 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
hash/fnv from google.golang.org/protobuf/internal/detrand
hash/maphash from go4.org/mem
html from net/http/pprof+
html/template from tailscale.com/cmd/derper
io from bufio+
io/fs from crypto/x509+
io/ioutil from github.com/mitchellh/go-ps+
iter from maps+
log from expvar+
log/internal from log
maps from tailscale.com/ipn+
@@ -274,7 +258,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
math/big from crypto/dsa+
math/bits from compress/flate+
math/rand from github.com/mdlayher/netlink+
math/rand/v2 from internal/concurrent+
math/rand/v2 from tailscale.com/util/fastuuid+
mime from github.com/prometheus/common/expfmt+
mime/multipart from net/http
mime/quotedprintable from mime/multipart
@@ -282,14 +266,14 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
net/http from expvar+
net/http/httptrace from net/http+
net/http/internal from net/http
net/http/pprof from tailscale.com/tsweb
net/http/pprof from tailscale.com/tsweb+
net/netip from go4.org/netipx+
net/textproto from golang.org/x/net/http/httpguts+
net/url from crypto/x509+
os from crypto/rand+
os/exec from github.com/coreos/go-iptables/iptables+
os/signal from tailscale.com/cmd/derper
W os/user from tailscale.com/util/winutil+
W os/user from tailscale.com/util/winutil
path from github.com/prometheus/client_golang/prometheus/internal+
path/filepath from crypto/x509+
reflect from crypto/x509+
@@ -307,10 +291,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
sync/atomic from context+
syscall from crypto/rand+
text/tabwriter from runtime/pprof
text/template from html/template
text/template/parse from html/template+
time from compress/gzip+
unicode from bytes+
unicode/utf16 from crypto/x509+
unicode/utf8 from bufio+
unique from net/netip

View File

@@ -19,7 +19,6 @@ import (
"expvar"
"flag"
"fmt"
"html/template"
"io"
"log"
"math"
@@ -58,7 +57,7 @@ var (
configPath = flag.String("c", "", "config file path")
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt")
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443")
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443. When --certmode=manual, this can be an IP address to avoid SNI checks")
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443")
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.")
@@ -213,23 +212,32 @@ func main() {
tsweb.AddBrowserHeaders(w)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
err := homePageTemplate.Execute(w, templateData{
ShowAbuseInfo: validProdHostname.MatchString(*hostname),
Disabled: !*runDERP,
AllowDebug: tsweb.AllowDebugAccess(r),
})
if err != nil {
if r.Context().Err() == nil {
log.Printf("homePageTemplate.Execute: %v", err)
}
return
io.WriteString(w, `<html><body>
<h1>DERP</h1>
<p>
This is a <a href="https://tailscale.com/">Tailscale</a> DERP server.
</p>
<p>
Documentation:
</p>
<ul>
<li><a href="https://tailscale.com/kb/1232/derp-servers">About DERP</a></li>
<li><a href="https://pkg.go.dev/tailscale.com/derp">Protocol & Go docs</a></li>
<li><a href="https://github.com/tailscale/tailscale/tree/main/cmd/derper#derp">How to run a DERP server</a></li>
</ul>
`)
if !*runDERP {
io.WriteString(w, `<p>Status: <b>disabled</b></p>`)
}
if tsweb.AllowDebugAccess(r) {
io.WriteString(w, "<p>Debug info at <a href='/debug/'>/debug/</a>.</p>\n")
}
}))
mux.Handle("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tsweb.AddBrowserHeaders(w)
io.WriteString(w, "User-agent: *\nDisallow: /\n")
}))
mux.Handle("/generate_204", http.HandlerFunc(derphttp.ServeNoContent))
mux.Handle("/generate_204", http.HandlerFunc(serveNoContent))
debug := tsweb.Debugger(mux)
debug.KV("TLS hostname", *hostname)
debug.KV("Mesh key", s.HasMeshKey())
@@ -329,7 +337,7 @@ func main() {
if *httpPort > -1 {
go func() {
port80mux := http.NewServeMux()
port80mux.HandleFunc("/generate_204", derphttp.ServeNoContent)
port80mux.HandleFunc("/generate_204", serveNoContent)
port80mux.Handle("/", certManager.HTTPHandler(tsweb.Port80Handler{Main: mux}))
port80srv := &http.Server{
Addr: net.JoinHostPort(listenHost, fmt.Sprintf("%d", *httpPort)),
@@ -370,6 +378,31 @@ func main() {
}
}
const (
noContentChallengeHeader = "X-Tailscale-Challenge"
noContentResponseHeader = "X-Tailscale-Response"
)
// For 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.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 == '_'
}
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
func prodAutocertHostPolicy(_ context.Context, host string) error {
@@ -460,52 +493,3 @@ func init() {
return 0
}))
}
type templateData struct {
ShowAbuseInfo bool
Disabled bool
AllowDebug bool
}
// homePageTemplate renders the home page using [templateData].
var homePageTemplate = template.Must(template.New("home").Parse(`<html><body>
<h1>DERP</h1>
<p>
This is a <a href="https://tailscale.com/">Tailscale</a> DERP server.
</p>
<p>
It provides STUN, interactive connectivity establishment, and relaying of end-to-end encrypted traffic
for Tailscale clients.
</p>
{{if .ShowAbuseInfo }}
<p>
If you suspect abuse, please contact <a href="mailto:security@tailscale.com">security@tailscale.com</a>.
</p>
{{end}}
<p>
Documentation:
</p>
<ul>
{{if .ShowAbuseInfo }}
<li><a href="https://tailscale.com/security-policies">Tailscale Security Policies</a></li>
<li><a href="https://tailscale.com/tailscale-aup">Tailscale Acceptable Use Policies</a></li>
{{end}}
<li><a href="https://tailscale.com/kb/1232/derp-servers">About DERP</a></li>
<li><a href="https://pkg.go.dev/tailscale.com/derp">Protocol & Go docs</a></li>
<li><a href="https://github.com/tailscale/tailscale/tree/main/cmd/derper#derp">How to run a DERP server</a></li>
</ul>
{{if .Disabled}}
<p>Status: <b>disabled</b></p>
{{end}}
{{if .AllowDebug}}
<p>Debug info at <a href='/debug/'>/debug/</a>.</p>
{{end}}
</body>
</html>
`))

View File

@@ -4,14 +4,12 @@
package main
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"tailscale.com/derp/derphttp"
"tailscale.com/tstest/deptest"
)
@@ -78,20 +76,20 @@ func TestNoContent(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", "https://localhost/generate_204", nil)
if tt.input != "" {
req.Header.Set(derphttp.NoContentChallengeHeader, tt.input)
req.Header.Set(noContentChallengeHeader, tt.input)
}
w := httptest.NewRecorder()
derphttp.ServeNoContent(w, req)
serveNoContent(w, req)
resp := w.Result()
if tt.want == "" {
if h, found := resp.Header[derphttp.NoContentResponseHeader]; found {
if h, found := resp.Header[noContentResponseHeader]; found {
t.Errorf("got %+v; expected no response header", h)
}
return
}
if got := resp.Header.Get(derphttp.NoContentResponseHeader); got != tt.want {
if got := resp.Header.Get(noContentResponseHeader); got != tt.want {
t.Errorf("got %q; want %q", got, tt.want)
}
})
@@ -108,33 +106,6 @@ func TestDeps(t *testing.T) {
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
"tailscale.com/net/packet": "not needed in derper",
"github.com/gaissmai/bart": "not needed in derper",
"database/sql/driver": "not needed in derper", // previously came in via github.com/google/uuid
},
}.Check(t)
}
func TestTemplate(t *testing.T) {
buf := &bytes.Buffer{}
err := homePageTemplate.Execute(buf, templateData{
ShowAbuseInfo: true,
Disabled: true,
AllowDebug: true,
})
if err != nil {
t.Fatal(err)
}
str := buf.String()
if !strings.Contains(str, "If you suspect abuse") {
t.Error("Output is missing abuse mailto")
}
if !strings.Contains(str, "Tailscale Security Policies") {
t.Error("Output is missing Tailscale Security Policies link")
}
if !strings.Contains(str, "Status:") {
t.Error("Output is missing disabled status")
}
if !strings.Contains(str, "Debug info") {
t.Error("Output is missing debug info")
}
}

View File

@@ -10,7 +10,7 @@ import (
"net/http"
"strings"
"github.com/coder/websocket"
"nhooyr.io/websocket"
"tailscale.com/derp"
"tailscale.com/net/wsconn"
)

View File

@@ -29,7 +29,6 @@ var (
tlsInterval = flag.Duration("tls-interval", 15*time.Second, "TLS probe interval")
bwInterval = flag.Duration("bw-interval", 0, "bandwidth probe interval (0 = no bandwidth probing)")
bwSize = flag.Int64("bw-probe-size-bytes", 1_000_000, "bandwidth probe size")
regionCode = flag.String("region-code", "", "probe only this region (e.g. 'lax'); if left blank, all regions will be probed")
)
func main() {
@@ -48,9 +47,6 @@ func main() {
if *bwInterval > 0 {
opts = append(opts, prober.WithBandwidthProbing(*bwInterval, *bwSize))
}
if *regionCode != "" {
opts = append(opts, prober.WithRegion(*regionCode))
}
dp, err := prober.DERP(p, *derpMapURL, opts...)
if err != nil {
log.Fatal(err)
@@ -79,11 +75,6 @@ func main() {
prober.WithPageLink("Prober metrics", "/debug/varz"),
prober.WithProbeLink("Run Probe", "/debug/probe-run?name={{.Name}}"),
), tsweb.HandlerOptions{Logf: log.Printf}))
mux.Handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok\n"))
}))
log.Printf("Listening on %s", *listen)
log.Fatal(http.ListenAndServe(*listen, mux))
}

View File

@@ -1 +1 @@
get-authkey
get-authkey

View File

@@ -51,7 +51,6 @@ func main() {
ctx := context.Background()
tsClient := tailscale.NewClient("-", nil)
tsClient.UserAgent = "tailscale-get-authkey"
tsClient.HTTPClient = credentials.Client(ctx)
tsClient.BaseURL = baseURL

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