Compare commits

..

1 Commits

Author SHA1 Message Date
Denton Gentry
239b7cce74 util/codegen: Remove year from copyright header.
Copyright notices in software are not supposed to update
the year in the header.

Because we have a CI check for `go generate`, we're failing
CI until we go update all of the copyright headers in
generated files to say 2023.

Instead, relax the requirement to always have a year in the
copyright header.

Fixes https://github.com/tailscale/tailscale/issues/6865

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2023-01-01 16:36:10 -08:00
1099 changed files with 17581 additions and 63482 deletions

View File

@@ -12,6 +12,7 @@ body:
attributes:
label: What is the issue?
description: What happened? What did you expect to happen?
placeholder: oh no
validations:
required: true
- type: textarea
@@ -60,13 +61,6 @@ body:
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:

31
.github/workflows/cifuzz.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: CIFuzz
on: [pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
Fuzzing:
runs-on: ubuntu-latest
steps:
- name: Build Fuzzers
id: build
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master
with:
oss-fuzz-project-name: 'tailscale'
dry-run: false
language: go
- name: Run Fuzzers
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master
with:
oss-fuzz-project-name: 'tailscale'
fuzz-seconds: 300
dry-run: false
language: go
- name: Upload Crash
uses: actions/upload-artifact@v3
if: failure() && steps.build.outcome == 'success'
with:
name: artifacts
path: ./out/artifacts

View File

@@ -17,8 +17,6 @@ on:
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
merge_group:
branches: [ main ]
schedule:
- cron: '31 14 * * 5'
@@ -49,7 +47,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -60,7 +58,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@v2
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -74,4 +72,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v1

55
.github/workflows/cross-android.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Android-Cross
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
id: go
- name: Android smoke build
# 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.
# TODO(bradfitz): better; see https://github.com/tailscale/tailscale/issues/4482
env:
GOOS: android
GOARCH: arm64
run: go install ./net/netns ./ipn/ipnlocal ./wgengine/magicsock/ ./wgengine/ ./wgengine/router/ ./wgengine/netstack ./util/dnsname/ ./ipn/ ./net/interfaces ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

63
.github/workflows/cross-darwin.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: Darwin-Cross
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
id: go
- name: macOS build cmd
env:
GOOS: darwin
GOARCH: amd64
run: go build ./cmd/...
- name: macOS build tests
env:
GOOS: darwin
GOARCH: amd64
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
- name: iOS build most
env:
GOOS: ios
GOARCH: arm64
run: go install ./ipn/... ./wgengine/ ./types/... ./control/controlclient
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

57
.github/workflows/cross-freebsd.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: FreeBSD-Cross
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
id: go
- name: FreeBSD build cmd
env:
GOOS: freebsd
GOARCH: amd64
run: go build ./cmd/...
- name: FreeBSD build tests
env:
GOOS: freebsd
GOARCH: amd64
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

57
.github/workflows/cross-loong64.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Loongnix-Cross
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
id: go
- name: Loongnix build cmd
env:
GOOS: linux
GOARCH: loong64
run: go build ./cmd/...
- name: Loongnix build tests
env:
GOOS: linux
GOARCH: loong64
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

57
.github/workflows/cross-openbsd.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: OpenBSD-Cross
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
id: go
- name: OpenBSD build cmd
env:
GOOS: openbsd
GOARCH: amd64
run: go build ./cmd/...
- name: OpenBSD build tests
env:
GOOS: openbsd
GOARCH: amd64
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

58
.github/workflows/cross-wasm.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Wasm-Cross
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
id: go
- name: Wasm client build
env:
GOOS: js
GOARCH: wasm
run: go build ./cmd/tsconnect/wasm ./cmd/tailscale/cli
- name: tsconnect static build
# Use our custom Go toolchain, we set build tags (to control binary size)
# that depend on it.
run: |
./tool/go run ./cmd/tsconnect --fast-compression build
./tool/go run ./cmd/tsconnect --fast-compression build-pkg
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

57
.github/workflows/cross-windows.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Windows-Cross
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
id: go
- name: Windows build cmd
env:
GOOS: windows
GOARCH: amd64
run: go build ./cmd/...
- name: Windows build tests
env:
GOOS: windows
GOARCH: amd64
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -c ); done
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

33
.github/workflows/depaware.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: depaware
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
- name: depaware
run: go run github.com/tailscale/depaware --check
tailscale.com/cmd/tailscaled
tailscale.com/cmd/tailscale
tailscale.com/cmd/derper

View File

@@ -1,15 +0,0 @@
name: "Dockerfile build"
on:
push:
branches:
- main
pull_request:
branches:
- "*"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: "Build Docker image"
run: docker build .

View File

@@ -17,7 +17,7 @@ concurrency:
cancel-in-progress: true
jobs:
update-licenses:
tailscale:
runs-on: ubuntu-latest
steps:
@@ -25,7 +25,7 @@ jobs:
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
uses: actions/setup-go@v3
with:
go-version-file: go.mod
@@ -42,7 +42,7 @@ jobs:
go-licenses report tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled > licenses/tailscale.md --template .github/licenses.tmpl
- name: Get access token
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
uses: tibdex/github-app-token@f717b5ecd4534d3c4df4ce9b5c1c2214f0f7cd06 # v1.6.0
id: generate-token
with:
app_id: ${{ secrets.LICENSING_APP_ID }}
@@ -50,11 +50,11 @@ jobs:
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
- name: Send pull request
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
uses: peter-evans/create-pull-request@ad43dccb4d726ca8514126628bec209b8354b6dd #v4.1.4
with:
token: ${{ steps.generate-token.outputs.token }}
author: License Updater <noreply+license-updater@tailscale.com>
committer: License Updater <noreply+license-updater@tailscale.com>
author: License Updater <noreply@tailscale.com>
committer: License Updater <noreply@tailscale.com>
branch: licenses/cli
commit-message: "licenses: update tailscale{,d} licenses"
title: "licenses: update tailscale{,d} licenses"

42
.github/workflows/go_generate.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: go generate
on:
push:
branches:
- main
- "release-branch/*"
pull_request:
branches:
- "*"
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
- name: check 'go generate' is clean
run: |
if [[ "${{github.ref}}" == release-branch/* ]]
then
pkgs=$(go list ./... | grep -v dnsfallback)
else
pkgs=$(go list ./... | grep -v dnsfallback)
fi
go generate $pkgs
echo
echo
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)

35
.github/workflows/go_mod_tidy.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: go mod tidy
on:
push:
branches:
- main
pull_request:
branches:
- "*"
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
- name: check 'go mod tidy' is clean
run: |
go mod tidy
echo
echo
git diff --name-only --exit-code || (echo "Please run 'go mod tidy'."; exit 1)

View File

@@ -1,40 +0,0 @@
name: golangci-lint
on:
# For now, only lint pull requests, not the main branches.
pull_request:
# TODO(andrew): enable for main branch after an initial waiting period.
#push:
# branches:
# - main
workflow_dispatch:
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version-file: go.mod
cache: false
- name: golangci-lint
# Note: this is the 'v3' tag as of 2023-04-17
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
with:
version: v1.52.2
# Show only new issues if it's a pull request.
only-new-issues: true

View File

@@ -1,37 +0,0 @@
name: govulncheck
on:
schedule:
- cron: "0 12 * * *" # 8am EST / 10am PST / 12pm UTC
workflow_dispatch: # allow manual trigger for testing
pull_request:
paths:
- ".github/workflows/govulncheck.yml"
jobs:
source-scan:
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Install govulncheck
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Scan source code for known vulnerabilities
run: PATH=$PWD/tool/:$PATH "$(./tool/go env GOPATH)/bin/govulncheck" -test ./...
- uses: ruby/action-slack@v3.2.1
with:
payload: >
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks>
(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|commit>) of ${{ github.repository }}@${{ github.ref_name }} by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'schedule'

View File

@@ -1,102 +0,0 @@
name: test installer.sh
on:
push:
branches:
- "main"
paths:
- scripts/installer.sh
pull_request:
branches:
- "*"
paths:
- scripts/installer.sh
jobs:
test:
strategy:
# Don't abort the entire matrix if one element fails.
fail-fast: false
# Don't start all of these at once, which could saturate Github workers.
max-parallel: 4
matrix:
image:
# This is a list of Docker images against which we test our installer.
# If you find that some of these no longer exist, please feel free
# to remove them from the list.
# When adding new images, please only use official ones.
- "debian:oldstable-slim"
- "debian:stable-slim"
- "debian:testing-slim"
- "debian:sid-slim"
- "ubuntu:18.04"
- "ubuntu:20.04"
- "ubuntu:22.04"
- "ubuntu:22.10"
- "ubuntu:23.04"
- "elementary/docker:stable"
- "elementary/docker:unstable"
- "parrotsec/core:lts-amd64"
- "parrotsec/core:latest"
- "kalilinux/kali-rolling"
- "kalilinux/kali-dev"
- "oraclelinux:9"
- "oraclelinux:8"
- "fedora:latest"
- "rockylinux:8.7"
- "rockylinux:9"
- "amazonlinux:latest"
- "opensuse/leap:latest"
- "opensuse/tumbleweed:latest"
- "archlinux:latest"
- "alpine:3.14"
- "alpine:latest"
- "alpine:edge"
deps:
# Run all images installing curl as a dependency.
- curl
include:
# Check a few images with wget rather than curl.
- { image: "debian:oldstable-slim", deps: "wget" }
- { image: "debian:sid-slim", deps: "wget" }
- { image: "ubuntu:23.04", deps: "wget" }
# Ubuntu 16.04 also needs apt-transport-https installed.
- { image: "ubuntu:16.04", deps: "curl apt-transport-https" }
- { image: "ubuntu:16.04", deps: "wget apt-transport-https" }
runs-on: ubuntu-latest
container:
image: ${{ matrix.image }}
options: --user root
steps:
- name: install dependencies (yum)
# tar and gzip are needed by the actions/checkout below.
run: yum install -y --allowerasing tar gzip ${{ matrix.deps }}
if: |
contains(matrix.image, 'centos')
|| contains(matrix.image, 'oraclelinux')
|| contains(matrix.image, 'fedora')
|| contains(matrix.image, 'amazonlinux')
- name: install dependencies (zypper)
# tar and gzip are needed by the actions/checkout below.
run: zypper --non-interactive install tar gzip ${{ matrix.deps }}
if: contains(matrix.image, 'opensuse')
- name: install dependencies (apt-get)
run: |
apt-get update
apt-get install -y ${{ matrix.deps }}
if: |
contains(matrix.image, 'debian')
|| contains(matrix.image, 'ubuntu')
|| contains(matrix.image, 'elementary')
|| contains(matrix.image, 'parrotsec')
|| contains(matrix.image, 'kalilinux')
- name: checkout
uses: actions/checkout@v3
- name: run installer
run: scripts/installer.sh
# Package installation can fail in docker because systemd is not running
# as PID 1, so ignore errors at this step. The real check is the
# `tailscale --version` command below.
continue-on-error: true
- name: check tailscale version
run: tailscale --version

45
.github/workflows/license.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: license
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
- name: Run license checker
run: ./scripts/check_license_headers.sh .
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

67
.github/workflows/linux-race.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Linux race
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
id: go
- name: Basic build
run: go build ./cmd/...
- name: Run tests and benchmarks with -race flag on linux
run: go test -race -bench=. -benchtime=1x ./...
- name: Check that no tracked files in the repo have been modified
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
- name: Check that no files have been added to the repo
run: |
# Note: The "error: pathspec..." you see below is normal!
# In the success case in which there are no new untracked files,
# git ls-files complains about the pathspec not matching anything.
# That's OK. It's not worth the effort to suppress. Please ignore it.
if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*'
then
echo "Build/test created untracked files in the repo (file names above)."
exit 1
fi
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

77
.github/workflows/linux.yml vendored Normal file
View File

@@ -0,0 +1,77 @@
name: Linux
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-22.04
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
id: go
- name: Basic build
run: go build ./cmd/...
- name: Build variants
run: |
go install --tags=ts_include_cli ./cmd/tailscaled
go install --tags=ts_omit_aws ./cmd/tailscaled
- name: Get QEMU
run: |
sudo apt-get -y update
sudo apt-get -y install qemu-user
- name: Run tests on linux
run: go test -bench=. -benchtime=1x ./...
- name: Check that no tracked files in the repo have been modified
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
- name: Check that no files have been added to the repo
run: |
# Note: The "error: pathspec..." you see below is normal!
# In the success case in which there are no new untracked files,
# git ls-files complains about the pathspec not matching anything.
# That's OK. It's not worth the effort to suppress. Please ignore it.
if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*'
then
echo "Build/test created untracked files in the repo (file names above)."
exit 1
fi
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

67
.github/workflows/linux32.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Linux 32-bit
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
id: go
- name: Basic build
run: GOARCH=386 go build ./cmd/...
- name: Run tests on linux
run: GOARCH=386 go test -bench=. -benchtime=1x ./...
- name: Check that no tracked files in the repo have been modified
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
- name: Check that no files have been added to the repo
run: |
# Note: The "error: pathspec..." you see below is normal!
# In the success case in which there are no new untracked files,
# git ls-files complains about the pathspec not matching anything.
# That's OK. It's not worth the effort to suppress. Please ignore it.
if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*'
then
echo "Build/test created untracked files in the repo (file names above)."
exit 1
fi
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

113
.github/workflows/static-analysis.yml vendored Normal file
View File

@@ -0,0 +1,113 @@
name: static-analysis
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
gofmt:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
- name: Run gofmt (goimports)
run: go run golang.org/x/tools/cmd/goimports -d --format-only .
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'
vet:
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.19
- name: Check out code
uses: actions/checkout@v3
- name: Run go vet
run: go vet ./...
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'
staticcheck:
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, windows, darwin]
goarch: [amd64]
include:
- goos: windows
goarch: 386
steps:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.19
- name: Check out code
uses: actions/checkout@v3
- name: Install staticcheck
run: "GOBIN=~/.local/bin go install honnef.co/go/tools/cmd/staticcheck"
- name: Print staticcheck version
run: "staticcheck -version"
- name: "Run staticcheck (${{ matrix.goos }}/${{ matrix.goarch }})"
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: "staticcheck -- $(go list ./... | grep -v tempfork)"
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

View File

@@ -1,500 +0,0 @@
# This is our main "CI tests" workflow. It runs everything that should run on
# both PRs and merged commits, and for the latter reports failures to slack.
name: CI
env:
# Our fuzz job, powered by OSS-Fuzz, fails periodically because we upgrade to
# new Go versions very eagerly. OSS-Fuzz is a little more conservative, and
# ends up being unable to compile our code.
#
# When this happens, we want to disable the fuzz target until OSS-Fuzz catches
# up. However, we also don't want to forget to turn it back on when OSS-Fuzz
# can once again build our code.
#
# This variable toggles the fuzz job between two modes:
# - false: we expect fuzzing to be happy, and should report failure if it's not.
# - true: we expect fuzzing is broken, and should report failure if it start working.
TS_FUZZ_CURRENTLY_BROKEN: false
on:
push:
branches:
- "main"
- "release-branch/*"
pull_request:
branches:
- "*"
merge_group:
branches:
- "main"
concurrency:
# For PRs, later CI runs preempt previous ones. e.g. a force push on a PR
# cancels running CI jobs and starts all new ones.
#
# For non-PR pushes, concurrency.group needs to be unique for every distinct
# CI run we want to have happen. Use run_id, which in practice means all
# non-PR CI runs will be allowed to run without preempting each other.
group: ${{ github.workflow }}-$${{ github.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
test:
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
include:
- goarch: amd64
- goarch: amd64
buildflags: "-race"
- goarch: "386" # thanks yaml
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
- name: Restore Cache
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
# contains zips that can be unpacked in parallel faster than they can be
# fetched and extracted by tar
path: |
~/.cache/go-build
~/go/pkg/mod/cache
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
restore-keys: |
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-
- name: build all
run: ./tool/go build ${{matrix.buildflags}} ./...
env:
GOARCH: ${{ matrix.goarch }}
- name: build variant CLIs
run: |
export TS_USE_TOOLCHAIN=1
./build_dist.sh --extra-small ./cmd/tailscaled
./build_dist.sh --box ./cmd/tailscaled
./build_dist.sh --extra-small --box ./cmd/tailscaled
rm -f tailscaled
env:
GOARCH: ${{ matrix.goarch }}
- name: get qemu # for tstest/archtest
if: matrix.goarch == 'amd64' && matrix.variant == ''
run: |
sudo apt-get -y update
sudo apt-get -y install qemu-user
- name: build test wrapper
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
- name: test all
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
env:
GOARCH: ${{ matrix.goarch }}
- name: bench all
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$
env:
GOARCH: ${{ matrix.goarch }}
- name: check that no tracked files changed
run: git diff --no-ext-diff --name-only --exit-code || (echo "Build/test modified the files above."; exit 1)
- name: check that no new files were added
run: |
# Note: The "error: pathspec..." you see below is normal!
# In the success case in which there are no new untracked files,
# git ls-files complains about the pathspec not matching anything.
# That's OK. It's not worth the effort to suppress. Please ignore it.
if git ls-files --others --exclude-standard --directory --no-empty-directory --error-unmatch -- ':/*'
then
echo "Build/test created untracked files in the repo (file names above)."
exit 1
fi
windows:
runs-on: windows-2022
steps:
- name: checkout
uses: actions/checkout@v3
- name: Install Go
uses: actions/setup-go@v4
with:
go-version-file: go.mod
cache: false
- name: Restore Cache
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
# contains zips that can be unpacked in parallel faster than they can be
# fetched and extracted by tar
path: |
~/.cache/go-build
~/go/pkg/mod/cache
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
restore-keys: |
${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-go-2-
- name: test
# Don't use -bench=. -benchtime=1x.
# Somewhere in the layers (powershell?)
# the equals signs cause great confusion.
run: go run ./cmd/testwrapper ./... -bench . -benchtime 1x
vm:
runs-on: ["self-hosted", "linux", "vm"]
# VM tests run with some privileges, don't let them run on 3p PRs.
if: github.repository == 'tailscale/tailscale'
steps:
- name: checkout
uses: actions/checkout@v3
- name: Run VM tests
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
env:
HOME: "/tmp"
TMPDIR: "/tmp"
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
cross: # cross-compile checks, build only.
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
include:
# Note: linux/amd64 is not in this matrix, because that goos/goarch is
# tested more exhaustively in the 'test' job above.
- goos: linux
goarch: arm64
- goos: linux
goarch: "386" # thanks yaml
- goos: linux
goarch: loong64
- goos: linux
goarch: arm
goarm: "5"
- goos: linux
goarch: arm
goarm: "7"
# macOS
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
# Windows
- goos: windows
goarch: amd64
- goos: windows
goarch: arm64
# BSDs
- goos: freebsd
goarch: amd64
- goos: openbsd
goarch: amd64
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
- name: Restore Cache
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
# contains zips that can be unpacked in parallel faster than they can be
# fetched and extracted by tar
path: |
~/.cache/go-build
~/go/pkg/mod/cache
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
restore-keys: |
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-
- name: build all
run: ./tool/go build ./cmd/...
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: "0"
- name: build tests
run: ./tool/go test -exec=true ./...
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
ios: # similar to cross above, but iOS can't build most of the repo. So, just
#make it build a few smoke packages.
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
- name: build some
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
env:
GOOS: ios
GOARCH: arm64
android:
# similar to cross above, but android fails to build a few pieces of the
# repo. We should fix those pieces, they're small, but as a stepping stone,
# only test the subset of android that our past smoke test checked.
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
# 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.
# TODO(bradfitz): better; see https://github.com/tailscale/tailscale/issues/4482
- name: build some
run: ./tool/go install ./net/netns ./ipn/ipnlocal ./wgengine/magicsock/ ./wgengine/ ./wgengine/router/ ./wgengine/netstack ./util/dnsname/ ./ipn/ ./net/interfaces ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version
env:
GOOS: android
GOARCH: arm64
wasm: # builds tsconnect, which is the only wasm build we support
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
- name: Restore Cache
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
# contains zips that can be unpacked in parallel faster than they can be
# fetched and extracted by tar
path: |
~/.cache/go-build
~/go/pkg/mod/cache
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
key: ${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
restore-keys: |
${{ github.job }}-${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-go-2-
- name: build tsconnect client
run: ./tool/go build ./cmd/tsconnect/wasm ./cmd/tailscale/cli
env:
GOOS: js
GOARCH: wasm
- name: build tsconnect server
# Note, no GOOS/GOARCH in env on this build step, we're running a build
# tool that handles the build itself.
run: |
./tool/go run ./cmd/tsconnect --fast-compression build
./tool/go run ./cmd/tsconnect --fast-compression build-pkg
tailscale_go: # Subset of tests that depend on our custom Go toolchain.
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
- name: test tailscale_go
run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/...
fuzz:
# This target periodically breaks (see TS_FUZZ_CURRENTLY_BROKEN at the top
# of the file), so it's more complex than usual: the 'build fuzzers' step
# might fail, and depending on the value of 'TS_FUZZ_CURRENTLY_BROKEN', that
# might or might not be fine. The steps after the build figure out whether
# the success/failure is expected, and appropriately pass/fail the job
# overall accordingly.
#
# Practically, this means that all steps after 'build fuzzers' must have an
# explicit 'if' condition, because the default condition for steps is
# 'success()', meaning "only run this if no previous steps failed".
if: github.event_name == 'pull_request'
runs-on: ubuntu-22.04
steps:
- name: build fuzzers
id: build
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master
# continue-on-error makes steps.build.conclusion be 'success' even if
# steps.build.outcome is 'failure'. This means this step does not
# contribute to the job's overall pass/fail evaluation.
continue-on-error: true
with:
oss-fuzz-project-name: 'tailscale'
dry-run: false
language: go
- name: report unexpectedly broken fuzz build
if: steps.build.outcome == 'failure' && env.TS_FUZZ_CURRENTLY_BROKEN != 'true'
run: |
echo "fuzzer build failed, see above for why"
echo "if the failure is due to OSS-Fuzz not being on the latest Go yet,"
echo "set TS_FUZZ_CURRENTLY_BROKEN=true in .github/workflows/test.yml"
echo "to temporarily disable fuzzing until OSS-Fuzz works again."
exit 1
- name: report unexpectedly working fuzz build
if: steps.build.outcome == 'success' && env.TS_FUZZ_CURRENTLY_BROKEN == 'true'
run: |
echo "fuzzer build succeeded, but we expect it to be broken"
echo "please set TS_FUZZ_CURRENTLY_BROKEN=false in .github/workflows/test.yml"
echo "to reenable fuzz testing"
exit 1
- name: run fuzzers
id: run
# Run the fuzzers whenever they're able to build, even if we're going to
# report a failure because TS_FUZZ_CURRENTLY_BROKEN is set to the wrong
# value.
if: steps.build.outcome == 'success'
uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master
with:
oss-fuzz-project-name: 'tailscale'
fuzz-seconds: 300
dry-run: false
language: go
- name: upload crash
uses: actions/upload-artifact@v3
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
with:
name: artifacts
path: ./out/artifacts
depaware:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
- name: check depaware
run: |
export PATH=$(./tool/go env GOROOT)/bin:$PATH
find . -name 'depaware.txt' | xargs -n1 dirname | xargs ./tool/go run github.com/tailscale/depaware --check
go_generate:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
- name: check that 'go generate' is clean
run: |
pkgs=$(./tool/go list ./... | grep -v dnsfallback)
./tool/go generate $pkgs
echo
echo
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go generate'."; exit 1)
go_mod_tidy:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
- name: check that 'go mod tidy' is clean
run: |
./tool/go mod tidy
echo
echo
git diff --name-only --exit-code || (echo "Please run 'go mod tidy'."; exit 1)
licenses:
runs-on: ubuntu-22.04
steps:
- name: checkout
uses: actions/checkout@v3
- name: check licenses
run: ./scripts/check_license_headers.sh .
staticcheck:
runs-on: ubuntu-22.04
strategy:
fail-fast: false # don't abort the entire matrix if one element fails
matrix:
goos: ["linux", "windows", "darwin"]
goarch: ["amd64"]
include:
- goos: "windows"
goarch: "386"
steps:
- name: checkout
uses: actions/checkout@v3
- name: install staticcheck
run: GOBIN=~/.local/bin ./tool/go install honnef.co/go/tools/cmd/staticcheck
- name: run staticcheck
run: |
export GOROOT=$(./tool/go env GOROOT)
export PATH=$GOROOT/bin:$PATH
staticcheck -- $(./tool/go list ./... | grep -v tempfork)
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
notify_slack:
if: always()
# Any of these jobs failing causes a slack notification.
needs:
- android
- test
- windows
- vm
- cross
- ios
- wasm
- tailscale_go
- fuzz
- depaware
- go_generate
- go_mod_tidy
- licenses
- staticcheck
runs-on: ubuntu-22.04
steps:
- name: notify
# Only notify slack for merged commits, not PR failures.
#
# It may be tempting to move this condition into the job's 'if' block, but
# don't: Github only collapses the test list into "everything is OK" if
# all jobs succeeded. A skipped job results in the list staying expanded.
# 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: ruby/action-slack@v3.2.1
with:
payload: |
{
"attachments": [{
"title": "Failure: ${{ github.workflow }}",
"title_link": "https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks",
"text": "${{ github.repository }}@${{ github.ref_name }}: <https://github.com/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>",
"fields": [{ "value": ${{ toJson(github.event.head_commit.message) }}, "short": false }],
"footer": "${{ github.event.head_commit.committer.name }} at ${{ github.event.head_commit.timestamp }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
check_mergeability:
if: always()
runs-on: ubuntu-22.04
needs:
- android
- test
- windows
- vm
- cross
- ios
- wasm
- tailscale_go
- fuzz
- depaware
- go_generate
- go_mod_tidy
- licenses
- staticcheck
steps:
- name: Decide if change is okay to merge
if: github.event_name != 'push'
uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}

View File

@@ -0,0 +1,30 @@
name: "@tailscale/connect npm publish"
on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up node
uses: actions/setup-node@v3
with:
node-version: "16.x"
registry-url: "https://registry.npmjs.org"
- name: Build package
# Build with build_dist.sh to ensure that version information is embedded.
# GOROOT is specified so that the Go/Wasm that is trigged by build-pk
# also picks up our custom Go toolchain.
run: |
./build_dist.sh tailscale.com/cmd/tsconnect
GOROOT="${HOME}/.cache/tailscale-go" ./tsconnect build-pkg
- name: Publish
env:
NODE_AUTH_TOKEN: ${{ secrets.TSCONNECT_NPM_PUBLISH_AUTH_TOKEN }}
run: ./tool/yarn --cwd ./cmd/tsconnect/pkg publish --access public

View File

@@ -1,49 +0,0 @@
name: update-flake
on:
# run action when a change lands in the main branch which updates go.mod. Also
# allow manual triggering.
push:
branches:
- main
paths:
- go.mod
- .github/workflows/update-flakes.yml
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
update-flake:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Run update-flakes
run: ./update-flake.sh
- name: Get access token
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
id: generate-token
with:
app_id: ${{ secrets.LICENSING_APP_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@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
committer: Flakes Updater <noreply+flakes-updater@tailscale.com>
branch: flakes
commit-message: "go.mod.sri: update SRI hash for go.mod changes"
title: "go.mod.sri: update SRI hash for go.mod changes"
body: Triggered by ${{ github.repository }}@${{ github.sha }}
signoff: true
delete-branch: true
reviewers: danderson

51
.github/workflows/vm.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: VM
on:
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
ubuntu2004-LTS-cloud-base:
runs-on: [ self-hosted, linux, vm ]
if: "(github.repository == 'tailscale/tailscale') && !contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Set GOPATH
run: echo "GOPATH=$HOME/go" >> $GITHUB_ENV
- name: Checkout Code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
- name: Run VM tests
run: go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
env:
HOME: "/tmp"
TMPDIR: "/tmp"
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

67
.github/workflows/windows.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Windows
on:
push:
branches:
- main
pull_request:
branches:
- '*'
- 'release-branch/*'
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
test:
runs-on: windows-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install Go
uses: actions/setup-go@v3
with:
go-version-file: go.mod
- name: Restore Cache
uses: actions/cache@v3
with:
# Note: unlike some other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
# contains zips that can be unpacked in parallel faster than they can be
# fetched and extracted by tar
path: |
~/go/pkg/mod/cache
~\AppData\Local\go-build
# The -2- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
# TODO(raggi): add a go version here.
key: ${{ runner.os }}-go-2-${{ hashFiles('**/go.sum') }}
- name: Test
# Don't use -bench=. -benchtime=1x.
# Somewhere in the layers (powershell?)
# the equals signs cause great confusion.
run: go test -bench . -benchtime 1x ./...
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

14
.gitignore vendored
View File

@@ -26,19 +26,5 @@ cmd/tailscaled/tailscaled
# Ignore personal VS Code settings
.vscode/
# Support personal project-specific GOPATH
.gopath/
# Ignore nix build result path
/result
# Ignore direnv nix-shell environment cache
.direnv/
# Ignore web client node modules
.vite/
client/web/node_modules
client/web/build
/gocross
/dist

View File

@@ -1,61 +0,0 @@
linters:
# Don't enable any linters by default; just the ones that we explicitly
# enable in the list below.
disable-all: true
enable:
- bidichk
- gofmt
- goimports
- misspell
- revive
# Configuration for how we run golangci-lint
run:
timeout: 5m
issues:
# Excluding configuration per-path, per-linter, per-text and per-source
exclude-rules:
# These are forks of an upstream package and thus are exempt from stylistic
# changes that would make pulling in upstream changes harder.
- path: tempfork/.*\.go
text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`"
- path: util/singleflight/.*\.go
text: "File is not `gofmt`-ed with `-s` `-r 'interface{} -> any'`"
# Per-linter settings are contained in this top-level key
linters-settings:
# Enable all rules by default; we don't use invisible unicode runes.
bidichk:
gofmt:
rewrite-rules:
- pattern: 'interface{}'
replacement: 'any'
goimports:
misspell:
revive:
enable-all-rules: false
ignore-generated-header: true
rules:
- name: atomic
- name: context-keys-type
- name: defer
arguments: [[
# Calling 'recover' at the time a defer is registered (i.e. "defer recover()") has no effect.
"immediate-recover",
# Calling 'recover' outside of a deferred function has no effect
"recover",
# Returning values from a deferred function has no effect
"return",
]]
- name: duplicated-imports
- name: errorf
- name: string-of-int
- name: time-equal
- name: unconditional-recursion
- name: useless-break
- name: waitgroup-by-value

View File

@@ -1,5 +1,6 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
############################################################################
#
@@ -31,7 +32,7 @@
# $ docker exec tailscaled tailscale status
FROM golang:1.21-alpine AS build-env
FROM golang:1.19-alpine AS build-env
WORKDIR /go/src/tailscale
@@ -47,7 +48,8 @@ RUN go install \
golang.org/x/crypto/ssh \
golang.org/x/crypto/acme \
nhooyr.io/websocket \
github.com/mdlayher/netlink
github.com/mdlayher/netlink \
golang.zx2c4.com/wireguard/device
COPY . .
@@ -61,9 +63,9 @@ ENV VERSION_GIT_HASH=$VERSION_GIT_HASH
ARG TARGETARCH
RUN GOARCH=$TARGETARCH go install -ldflags="\
-X tailscale.com/version.longStamp=$VERSION_LONG \
-X tailscale.com/version.shortStamp=$VERSION_SHORT \
-X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \
-X tailscale.com/version.Long=$VERSION_LONG \
-X tailscale.com/version.Short=$VERSION_SHORT \
-X tailscale.com/version.GitCommit=$VERSION_GIT_HASH" \
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
FROM alpine:3.16
@@ -72,4 +74,4 @@ RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
COPY --from=build-env /go/bin/* /usr/local/bin/
# For compat with the previous run.sh, although ideally you should be
# using build_docker.sh which sets an entrypoint for the image.
RUN mkdir /tailscale && ln -s /usr/local/bin/containerboot /tailscale/run.sh
RUN ln -s /usr/local/bin/containerboot /tailscale/run.sh

View File

@@ -1,5 +1,6 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
FROM alpine:3.16
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables iputils
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables

View File

@@ -1,6 +1,7 @@
BSD 3-Clause License
Copyright (c) 2020 Tailscale Inc & AUTHORS.
Copyright (c) 2020 Tailscale & AUTHORS.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

View File

@@ -2,63 +2,63 @@ IMAGE_REPO ?= tailscale/tailscale
SYNO_ARCH ?= "amd64"
SYNO_DSM ?= "7"
vet: ## Run go vet
usage:
echo "See Makefile"
vet:
./tool/go vet ./...
tidy: ## Run go mod tidy
tidy:
./tool/go mod tidy
updatedeps: ## Update depaware deps
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
# it finds in its $$PATH is the right one.
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update \
updatedeps:
./tool/go run github.com/tailscale/depaware --update \
tailscale.com/cmd/tailscaled \
tailscale.com/cmd/tailscale \
tailscale.com/cmd/derper
depaware: ## Run depaware checks
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
# it finds in its $$PATH is the right one.
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check \
depaware:
./tool/go run github.com/tailscale/depaware --check \
tailscale.com/cmd/tailscaled \
tailscale.com/cmd/tailscale \
tailscale.com/cmd/derper
buildwindows: ## Build tailscale CLI for windows/amd64
buildwindows:
GOOS=windows GOARCH=amd64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
build386: ## Build tailscale CLI for linux/386
build386:
GOOS=linux GOARCH=386 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
buildlinuxarm: ## Build tailscale CLI for linux/arm
buildlinuxarm:
GOOS=linux GOARCH=arm ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
buildwasm: ## Build tailscale CLI for js/wasm
buildwasm:
GOOS=js GOARCH=wasm ./tool/go install ./cmd/tsconnect/wasm ./cmd/tailscale/cli
buildlinuxloong64: ## Build tailscale CLI for linux/loong64
buildlinuxloong64:
GOOS=linux GOARCH=loong64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
buildmultiarchimage: ## Build (and optionally push) multiarch docker image
buildmultiarchimage:
./build_docker.sh
check: staticcheck vet depaware buildwindows build386 buildlinuxarm buildwasm ## Perform basic checks and compilation tests
check: staticcheck vet depaware buildwindows build386 buildlinuxarm buildwasm
staticcheck: ## Run staticcheck.io checks
staticcheck:
./tool/go run honnef.co/go/tools/cmd/staticcheck -- $$(./tool/go list ./... | grep -v tempfork)
spk: ## Build synology package for ${SYNO_ARCH} architecture and ${SYNO_DSM} DSM version
./tool/go run ./cmd/dist build synology/dsm${SYNO_DSM}/${SYNO_ARCH}
spk:
PATH="${PWD}/tool:${PATH}" ./tool/go run github.com/tailscale/tailscale-synology@main -o tailscale.spk --source=. --goarch=${SYNO_ARCH} --dsm-version=${SYNO_DSM}
spkall: ## Build synology packages for all architectures and DSM versions
./tool/go run ./cmd/dist build synology
spkall:
mkdir -p spks
PATH="${PWD}/tool:${PATH}" ./tool/go run github.com/tailscale/tailscale-synology@main -o spks --source=. --goarch=all --dsm-version=all
pushspk: spk ## Push and install synology package on ${SYNO_HOST} host
pushspk: spk
echo "Pushing SPK to root@${SYNO_HOST} (env var SYNO_HOST) ..."
scp tailscale.spk root@${SYNO_HOST}:
ssh root@${SYNO_HOST} /usr/syno/bin/synopkg install tailscale.spk
publishdevimage: ## Build and publish tailscale image to location specified by ${REPO}
publishdevimage:
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@@ -66,18 +66,10 @@ publishdevimage: ## Build and publish tailscale image to location specified by $
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS=latest REPOS=${REPO} PUSH=true TARGET=client ./build_docker.sh
publishdevoperator: ## Build and publish k8s-operator image to location specified by ${REPO}
publishdevoperator:
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS=latest REPOS=${REPO} PUSH=true TARGET=operator ./build_docker.sh
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'
@echo ""
.PHONY: help
.DEFAULT_GOAL := help

View File

@@ -6,41 +6,27 @@ Private WireGuard® networks made easy
## Overview
This repository contains the majority of Tailscale's open source code.
Notably, it includes the `tailscaled` daemon and
the `tailscale` CLI tool. The `tailscaled` daemon runs on Linux, Windows,
[macOS](https://tailscale.com/kb/1065/macos-variants/), and to varying degrees
on FreeBSD and OpenBSD. The Tailscale iOS and Android apps use this repo's
code, but this repo doesn't contain the mobile GUI code.
This repository contains all the open source Tailscale client code and
the `tailscaled` daemon and `tailscale` CLI tool. The `tailscaled`
daemon runs on Linux, Windows and [macOS](https://tailscale.com/kb/1065/macos-variants/), and to varying degrees on FreeBSD, OpenBSD, and Darwin. (The Tailscale iOS and Android apps use this repo's code, but this repo doesn't contain the mobile GUI code.)
Other [Tailscale repos](https://github.com/orgs/tailscale/repositories) of note:
The Android app is at https://github.com/tailscale/tailscale-android
* the Android app is at https://github.com/tailscale/tailscale-android
* the Synology package is at https://github.com/tailscale/tailscale-synology
* the QNAP package is at https://github.com/tailscale/tailscale-qpkg
* the Chocolatey packaging is at https://github.com/tailscale/tailscale-chocolatey
For background on which parts of Tailscale are open source and why,
see [https://tailscale.com/opensource/](https://tailscale.com/opensource/).
The Synology package is at https://github.com/tailscale/tailscale-synology
## Using
We serve packages for a variety of distros and platforms at
[https://pkgs.tailscale.com](https://pkgs.tailscale.com/).
We serve packages for a variety of distros at
https://pkgs.tailscale.com .
## Other clients
The [macOS, iOS, and Windows clients](https://tailscale.com/download)
use the code in this repository but additionally include small GUI
wrappers. The GUI wrappers on non-open source platforms are themselves
not open source.
wrappers that are not open source.
## Building
We always require the latest Go release, currently Go 1.21. (While we build
releases with our [Go fork](https://github.com/tailscale/go/), its use is not
required.)
```
go install tailscale.com/cmd/tailscale{,d}
```
@@ -57,6 +43,8 @@ If your distro has conventions that preclude the use of
`build_dist.sh`, please do the equivalent of what it does in your
distro's way, so that bug reports contain useful version information.
We require the latest Go release, currently Go 1.19.
## Bugs
Please file any issues about this code or the hosted service on

View File

@@ -1 +1 @@
1.49.0
1.35.0

1889
api.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2019 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package atomicfile contains code related to writing to filesystems
// atomically.
@@ -8,20 +9,14 @@
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.
// WriteFile writes data to filename+some suffix, then renames it
// into filename. The perm argument is ignored on Windows.
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

View File

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

@@ -11,25 +11,42 @@
set -eu
go="go"
if [ -n "${TS_USE_TOOLCHAIN:-}" ]; then
go="./tool/go"
IFS=".$IFS" read -r major minor patch <VERSION.txt
git_hash=$(git rev-parse HEAD)
if ! git diff-index --quiet HEAD; then
git_hash="${git_hash}-dirty"
fi
base_hash=$(git rev-list --max-count=1 HEAD -- VERSION.txt)
change_count=$(git rev-list --count HEAD "^$base_hash")
short_hash=$(echo "$git_hash" | cut -c1-9)
if expr "$minor" : "[0-9]*[13579]$" >/dev/null; then
patch="$change_count"
change_suffix=""
elif [ "$change_count" != "0" ]; then
change_suffix="-$change_count"
else
change_suffix=""
fi
eval `CGO_ENABLED=0 GOOS=$($go env GOHOSTOS) GOARCH=$($go env GOHOSTARCH) $go run ./cmd/mkversion`
long_suffix="$change_suffix-t$short_hash"
MINOR="$major.$minor"
SHORT="$MINOR.$patch"
LONG="${SHORT}$long_suffix"
GIT_HASH="$git_hash"
if [ "$1" = "shellvars" ]; then
cat <<EOF
VERSION_MINOR="$VERSION_MINOR"
VERSION_SHORT="$VERSION_SHORT"
VERSION_LONG="$VERSION_LONG"
VERSION_GIT_HASH="$VERSION_GIT_HASH"
VERSION_MINOR="$MINOR"
VERSION_SHORT="$SHORT"
VERSION_LONG="$LONG"
VERSION_GIT_HASH="$GIT_HASH"
EOF
exit 0
fi
tags=""
ldflags="-X tailscale.com/version.longStamp=${VERSION_LONG} -X tailscale.com/version.shortStamp=${VERSION_SHORT}"
ldflags="-X tailscale.com/version.Long=${LONG} -X tailscale.com/version.Short=${SHORT} -X tailscale.com/version.GitCommit=${GIT_HASH}"
# build_dist.sh arguments must precede go build arguments.
while [ "$#" -gt 1 ]; do
@@ -37,7 +54,7 @@ while [ "$#" -gt 1 ]; do
--extra-small)
shift
ldflags="$ldflags -w -s"
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube"
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap"
;;
--box)
shift
@@ -49,4 +66,4 @@ while [ "$#" -gt 1 ]; do
esac
done
exec $go build ${tags:+-tags=$tags} -ldflags "$ldflags" "$@"
exec ./tool/go build ${tags:+-tags=$tags} -ldflags "$ldflags" "$@"

View File

@@ -23,29 +23,28 @@ set -eu
export PATH=$PWD/tool:$PATH
eval $(./build_dist.sh shellvars)
DEFAULT_TARGET="client"
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
DEFAULT_BASE="tailscale/alpine-base:3.16"
DEFAULT_REPOS="tailscale/tailscale,ghcr.io/tailscale/tailscale"
DEFAULT_BASE="ghcr.io/tailscale/alpine-base:3.16"
DEFAULT_TARGET="client"
PUSH="${PUSH:-false}"
TARGET="${TARGET:-${DEFAULT_TARGET}}"
REPOS="${REPOS:-${DEFAULT_REPOS}}"
TAGS="${TAGS:-${DEFAULT_TAGS}}"
BASE="${BASE:-${DEFAULT_BASE}}"
TARGET="${TARGET:-${DEFAULT_TARGET}}"
case "$TARGET" in
client)
DEFAULT_REPOS="tailscale/tailscale"
REPOS="${REPOS:-${DEFAULT_REPOS}}"
go run github.com/tailscale/mkctr \
--gopaths="\
tailscale.com/cmd/tailscale:/usr/local/bin/tailscale, \
tailscale.com/cmd/tailscaled:/usr/local/bin/tailscaled, \
tailscale.com/cmd/containerboot:/usr/local/bin/containerboot" \
--ldflags="\
-X tailscale.com/version.longStamp=${VERSION_LONG} \
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
-X tailscale.com/version.Long=${VERSION_LONG} \
-X tailscale.com/version.Short=${VERSION_SHORT} \
-X tailscale.com/version.GitCommit=${VERSION_GIT_HASH}" \
--base="${BASE}" \
--tags="${TAGS}" \
--repos="${REPOS}" \
@@ -53,14 +52,12 @@ case "$TARGET" in
/usr/local/bin/containerboot
;;
operator)
DEFAULT_REPOS="tailscale/k8s-operator"
REPOS="${REPOS:-${DEFAULT_REPOS}}"
go run github.com/tailscale/mkctr \
--gopaths="tailscale.com/cmd/k8s-operator:/usr/local/bin/operator" \
--ldflags="\
-X tailscale.com/version.longStamp=${VERSION_LONG} \
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
-X tailscale.com/version.Long=${VERSION_LONG} \
-X tailscale.com/version.Short=${VERSION_SHORT} \
-X tailscale.com/version.GitCommit=${VERSION_GIT_HASH}" \
--base="${BASE}" \
--tags="${TAGS}" \
--repos="${REPOS}" \

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package chirp implements a client to communicate with the BIRD Internet
// Routing Daemon.

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package chirp
import (

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.19
@@ -103,7 +104,7 @@ func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
// it as a string.
// HuJSON is JSON with a few modifications to make it more human-friendly. The primary
// changes are allowing comments and trailing comments. See the following links for more info:
// https://tailscale.com/s/acl-format
// https://tailscale.com/kb/1018/acls?q=acl#tailscale-acl-policy-format
// https://github.com/tailscale/hujson
func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
// Format return errors to be descriptive.
@@ -150,9 +151,8 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
// ACLTestFailureSummary specifies the JSON format sent to the
// JavaScript client to be rendered in the HTML.
type ACLTestFailureSummary struct {
User string `json:"user,omitempty"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
User string `json:"user"`
Errors []string `json:"errors"`
}
// ACLTestError is ErrResponse but with an extra field to account for ACLTestFailureSummary.
@@ -437,7 +437,7 @@ func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (test
}
}()
tests := []ACLTest{{User: source, Allow: []string{dest}}}
tests := []ACLTest{ACLTest{User: source, Allow: []string{dest}}}
postData, err := json.Marshal(tests)
if err != nil {
return nil, err

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package apitype contains types for the Tailscale LocalAPI and control plane API.
package apitype
@@ -10,14 +11,12 @@ import "tailscale.com/tailcfg"
const LocalAPIHost = "local-tailscaled.sock"
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
// In successful whois responses, Node and UserProfile are never nil.
type WhoIsResponse struct {
Node *tailcfg.Node
UserProfile *tailcfg.UserProfile
// CapMap is a map of capabilities to their values.
// See tailcfg.PeerCapMap and tailcfg.PeerCapability for details.
CapMap tailcfg.PeerCapMap
// Caps are extra capabilities that the remote Node has to this node.
Caps []string `json:",omitempty"`
}
// FileTarget is a node to which files can be sent, and the PeerAPI
@@ -34,9 +33,3 @@ type WaitingFile struct {
Name string
Size int64
}
// SetPushDeviceTokenRequest is the body POSTed to the LocalAPI endpoint /set-device-token.
type SetPushDeviceTokenRequest struct {
// PushDeviceToken is the iOS/macOS APNs device token (and any future Android equivalent).
PushDeviceToken string
}

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package apitype
@@ -10,7 +11,6 @@ type DNSConfig struct {
Domains []string `json:"domains"`
Nameservers []string `json:"nameservers"`
Proxied bool `json:"proxied"`
DNSFilterURL string `json:"DNSFilterURL"`
}
type DNSResolver struct {

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.19
@@ -12,6 +13,7 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"tailscale.com/types/opt"
)
@@ -43,18 +45,17 @@ type Device struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
ClientVersion string `json:"clientVersion"` // Empty for external devices.
UpdateAvailable bool `json:"updateAvailable"` // Empty for external devices.
OS string `json:"os"`
Tags []string `json:"tags"`
Created string `json:"created"` // Empty for external devices.
LastSeen string `json:"lastSeen"`
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
Expires string `json:"expires"`
Authorized bool `json:"authorized"`
IsExternal bool `json:"isExternal"`
MachineKey string `json:"machineKey"` // Empty for external devices.
NodeKey string `json:"nodeKey"`
ClientVersion string `json:"clientVersion"` // Empty for external devices.
UpdateAvailable bool `json:"updateAvailable"` // Empty for external devices.
OS string `json:"os"`
Created string `json:"created"` // Empty for external devices.
LastSeen string `json:"lastSeen"`
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
Expires string `json:"expires"`
Authorized bool `json:"authorized"`
IsExternal bool `json:"isExternal"`
MachineKey string `json:"machineKey"` // Empty for external devices.
NodeKey string `json:"nodeKey"`
// BlocksIncomingConnections is configured via the device's
// Tailscale client preferences. This field is only reported
@@ -212,20 +213,8 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
// AuthorizeDevice marks a device as authorized.
func (c *Client) AuthorizeDevice(ctx context.Context, deviceID string) error {
return c.SetAuthorized(ctx, deviceID, true)
}
// SetAuthorized marks a device as authorized or not.
func (c *Client) SetAuthorized(ctx context.Context, deviceID string, authorized bool) error {
params := &struct {
Authorized bool `json:"authorized"`
}{Authorized: authorized}
data, err := json.Marshal(params)
if err != nil {
return err
}
path := fmt.Sprintf("%s/api/v2/device/%s/authorized", c.baseURL(), url.PathEscape(deviceID))
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
req, err := http.NewRequestWithContext(ctx, "POST", path, strings.NewReader(`{"authorized":true}`))
if err != nil {
return err
}

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.19
@@ -63,7 +64,7 @@ func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, er
return b, nil
}
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData any) ([]byte, error) {
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData interface{}) ([]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 {

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// The servetls program shows how to run an HTTPS server
// using a Tailscale cert via LetsEncrypt.

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package tailscale
@@ -54,46 +55,26 @@ func (c *Client) Keys(ctx context.Context) ([]string, error) {
return nil, handleErrorResponse(b, resp)
}
var keys struct {
Keys []*Key `json:"keys"`
var keys []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(b, &keys); err != nil {
return nil, err
}
ret := make([]string, 0, len(keys.Keys))
for _, k := range keys.Keys {
ret := make([]string, 0, len(keys))
for _, k := range 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
// can be created. Returns the 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")
}
func (c *Client) CreateKey(ctx context.Context, caps KeyCapabilities) (string, *Key, error) {
keyRequest := struct {
Capabilities KeyCapabilities `json:"capabilities"`
ExpirySeconds int64 `json:"expirySeconds,omitempty"`
}{caps, int64(expirySeconds)}
Capabilities KeyCapabilities `json:"capabilities"`
}{caps}
bs, err := json.Marshal(keyRequest)
if err != nil {
return "", nil, err

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.19
@@ -36,7 +37,6 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
)
// defaultLocalClient is the default LocalClient when using the legacy
@@ -96,9 +96,8 @@ func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string)
// a TCP server on a random port, find the random port. For HTTP connections,
// we don't send the token. It gets added in an HTTP Basic-Auth header.
if port, _, err := safesocket.LocalTCPPortAndToken(); err == nil {
// We use 127.0.0.1 and not "localhost" (issue 7851).
var d net.Dialer
return d.DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
return d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port))
}
}
s := safesocket.DefaultConnectionStrategy(lc.socket())
@@ -115,7 +114,6 @@ func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string)
//
// DoLocalRequest may mutate the request to add Authorization headers.
func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error) {
req.Header.Set("Tailscale-Cap", strconv.Itoa(int(tailcfg.CurrentCapabilityVersion)))
lc.tsClientOnce.Do(func() {
lc.tsClient = &http.Client{
Transport: &http.Transport{
@@ -259,45 +257,6 @@ func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
return lc.get200(ctx, "/localapi/v0/metrics")
}
// 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.
//
// IncrementCounter does not support gauge metrics or negative delta values.
func (lc *LocalClient) IncrementCounter(ctx context.Context, name string, delta int) error {
type metricUpdate struct {
Name string `json:"name"`
Type string `json:"type"`
Value int `json:"value"` // amount to increment by
}
if delta < 0 {
return errors.New("negative delta not allowed")
}
_, err := lc.send(ctx, "POST", "/localapi/v0/upload-client-metrics", 200, jsonBody([]metricUpdate{{
Name: name,
Type: "counter",
Value: delta,
}}))
return err
}
// TailDaemonLogs returns a stream the Tailscale daemon's logs as they arrive.
// Close the context to stop the stream.
func (lc *LocalClient) TailDaemonLogs(ctx context.Context) (io.Reader, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/logtap", nil)
if err != nil {
return nil, err
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
return nil, errors.New(res.Status)
}
return res.Body, nil
}
// Pprof returns a pprof profile of the Tailscale daemon.
func (lc *LocalClient) Pprof(ctx context.Context, pprofType string, sec int) ([]byte, error) {
var secArg string
@@ -391,34 +350,6 @@ func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
return nil
}
// DebugPortmap invokes the debug-portmap endpoint, and returns an
// io.ReadCloser that can be used to read the logs that are printed during this
// process.
func (lc *LocalClient) DebugPortmap(ctx context.Context, duration time.Duration, ty, gwSelf string) (io.ReadCloser, error) {
vals := make(url.Values)
vals.Set("duration", duration.String())
vals.Set("type", ty)
if gwSelf != "" {
vals.Set("gateway_and_self", gwSelf)
}
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-portmap?"+vals.Encode(), nil)
if err != nil {
return nil, err
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
body, _ := io.ReadAll(res.Body)
res.Body.Close()
return nil, fmt.Errorf("HTTP %s: %s", res.Status, body)
}
return res.Body, nil
}
// SetDevStoreKeyValue set a statestore key/value. It's only meant for development.
// The schema (including when keys are re-read) is not a stable interface.
func (lc *LocalClient) SetDevStoreKeyValue(ctx context.Context, key, value string) error {
@@ -829,25 +760,11 @@ func (lc *LocalClient) ExpandSNIName(ctx context.Context, name string) (fqdn str
return "", false
}
// PingOpts contains options for the ping request.
//
// The zero value is valid, which means to use defaults.
type PingOpts struct {
// Size is the length of the ping message in bytes. It's ignored if it's
// smaller than the minimum message size.
//
// For disco pings, it specifies the length of the packet's payload. That
// is, it includes the disco headers and message, but not the IP and UDP
// headers.
Size int
}
// Ping sends a ping of the provided type to the provided IP and waits
// for its response. The opts type specifies additional options.
func (lc *LocalClient) PingWithOpts(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType, opts PingOpts) (*ipnstate.PingResult, error) {
// for its response.
func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
v := url.Values{}
v.Set("ip", ip.String())
v.Set("size", strconv.Itoa(opts.Size))
v.Set("type", string(pingtype))
body, err := lc.send(ctx, "POST", "/localapi/v0/ping?"+v.Encode(), 200, nil)
if err != nil {
@@ -856,12 +773,6 @@ func (lc *LocalClient) PingWithOpts(ctx context.Context, ip netip.Addr, pingtype
return decodeJSON[*ipnstate.PingResult](body)
}
// Ping sends a ping of the provided type to the provided IP and waits
// for its response.
func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
return lc.PingWithOpts(ctx, ip, pingtype, PingOpts{})
}
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
func (lc *LocalClient) NetworkLockStatus(ctx context.Context) (*ipnstate.NetworkLockStatus, error) {
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/status", 200, nil)
@@ -893,30 +804,6 @@ func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key, disa
return decodeJSON[*ipnstate.NetworkLockStatus](body)
}
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
// enable unattended bringup in the locked tailnet.
func (lc *LocalClient) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
encodedPrivate, err := tkaKey.MarshalText()
if err != nil {
return "", err
}
var b bytes.Buffer
type wrapRequest struct {
TSKey string
TKAKey string // key.NLPrivate.MarshalText
}
if err := json.NewEncoder(&b).Encode(wrapRequest{TSKey: preauthKey, TKAKey: string(encodedPrivate)}); err != nil {
return "", err
}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/wrap-preauth-key", 200, &b)
if err != nil {
return "", fmt.Errorf("error: %w", err)
}
return string(body), nil
}
// NetworkLockModify adds and/or removes key(s) to the tailnet key authority.
func (lc *LocalClient) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
var b bytes.Buffer
@@ -954,15 +841,6 @@ func (lc *LocalClient) NetworkLockSign(ctx context.Context, nodeKey key.NodePubl
return nil
}
// NetworkLockAffectedSigs returns all signatures signed by the specified keyID.
func (lc *LocalClient) NetworkLockAffectedSigs(ctx context.Context, keyID tkatype.KeyID) ([]tkatype.MarshaledSignature, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/affected-sigs", 200, bytes.NewReader(keyID))
if err != nil {
return nil, fmt.Errorf("error: %w", err)
}
return decodeJSON[[]tkatype.MarshaledSignature](body)
}
// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
func (lc *LocalClient) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
v := url.Values{}
@@ -988,57 +866,6 @@ func (lc *LocalClient) NetworkLockForceLocalDisable(ctx context.Context) error {
return nil
}
// NetworkLockVerifySigningDeeplink verifies the network lock deeplink contained
// in url and returns information extracted from it.
func (lc *LocalClient) NetworkLockVerifySigningDeeplink(ctx context.Context, url string) (*tka.DeeplinkValidationResult, error) {
vr := struct {
URL string
}{url}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/verify-deeplink", 200, jsonBody(vr))
if err != nil {
return nil, fmt.Errorf("sending verify-deeplink: %w", err)
}
return decodeJSON[*tka.DeeplinkValidationResult](body)
}
// NetworkLockGenRecoveryAUM generates an AUM for recovering from a tailnet-lock key compromise.
func (lc *LocalClient) NetworkLockGenRecoveryAUM(ctx context.Context, removeKeys []tkatype.KeyID, forkFrom tka.AUMHash) ([]byte, error) {
vr := struct {
Keys []tkatype.KeyID
ForkFrom string
}{removeKeys, forkFrom.String()}
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/generate-recovery-aum", 200, jsonBody(vr))
if err != nil {
return nil, fmt.Errorf("sending generate-recovery-aum: %w", err)
}
return body, nil
}
// NetworkLockCosignRecoveryAUM co-signs a recovery AUM using the node's tailnet lock key.
func (lc *LocalClient) NetworkLockCosignRecoveryAUM(ctx context.Context, aum tka.AUM) ([]byte, error) {
r := bytes.NewReader(aum.Serialize())
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/cosign-recovery-aum", 200, r)
if err != nil {
return nil, fmt.Errorf("sending cosign-recovery-aum: %w", err)
}
return body, nil
}
// NetworkLockSubmitRecoveryAUM submits a recovery AUM to the control plane.
func (lc *LocalClient) NetworkLockSubmitRecoveryAUM(ctx context.Context, aum tka.AUM) error {
r := bytes.NewReader(aum.Serialize())
_, err := lc.send(ctx, "POST", "/localapi/v0/tka/submit-recovery-aum", 200, r)
if err != nil {
return fmt.Errorf("sending cosign-recovery-aum: %w", err)
}
return nil
}
// SetServeConfig sets or replaces the serving settings.
// If config is nil, settings are cleared and serving is disabled.
func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
@@ -1166,27 +993,6 @@ func (lc *LocalClient) DeleteProfile(ctx context.Context, profile ipn.ProfileID)
return err
}
// QueryFeature makes a request for instructions on how to enable
// a feature, such as Funnel, for the node's tailnet. If relevant,
// this includes a control server URL the user can visit to enable
// the feature.
//
// If you are looking to use QueryFeature, you'll likely want to
// use cli.enableFeatureInteractive instead, which handles the logic
// of wraping QueryFeature and translating its response into an
// interactive flow for the user, including using the IPN notify bus
// to block until the feature has been enabled.
//
// 2023-08-09: Valid feature values are "serve" and "funnel".
func (lc *LocalClient) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) {
v := url.Values{"feature": {feature}}
body, err := lc.send(ctx, "POST", "/localapi/v0/query-feature?"+v.Encode(), 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[*tailcfg.QueryFeatureResponse](body)
}
func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*ipnstate.DebugDERPRegionReport, error) {
v := url.Values{"region": {regionIDOrCode}}
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-derp-region?"+v.Encode(), 200, nil)
@@ -1196,35 +1002,6 @@ func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode strin
return decodeJSON[*ipnstate.DebugDERPRegionReport](body)
}
// DebugSetExpireIn marks the current node key to expire in d.
//
// This is meant primarily for debug and testing.
func (lc *LocalClient) DebugSetExpireIn(ctx context.Context, d time.Duration) error {
v := url.Values{"expiry": {fmt.Sprint(time.Now().Add(d).Unix())}}
_, err := lc.send(ctx, "POST", "/localapi/v0/set-expiry-sooner?"+v.Encode(), 200, nil)
return err
}
// StreamDebugCapture streams a pcap-formatted packet capture.
//
// The provided context does not determine the lifetime of the
// returned io.ReadCloser.
func (lc *LocalClient) StreamDebugCapture(ctx context.Context) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/debug-capture", nil)
if err != nil {
return nil, err
}
res, err := lc.doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
res.Body.Close()
return nil, errors.New(res.Status)
}
return res.Body, nil
}
// WatchIPNBus subscribes to the IPN notification bus. It returns a watcher
// once the bus is connected successfully.
//

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.19

View File

@@ -1,10 +1,11 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !go1.21
//go:build !go1.19
package tailscale
func init() {
you_need_Go_1_21_to_compile_Tailscale()
you_need_Go_1_19_to_compile_Tailscale()
}

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.19

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.19
@@ -10,8 +11,6 @@ import (
"fmt"
"net/http"
"net/url"
"tailscale.com/util/httpm"
)
// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
@@ -23,7 +22,7 @@ func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (er
}()
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
req, err := http.NewRequestWithContext(ctx, httpm.DELETE, path, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
if err != nil {
return err
}

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.19

View File

@@ -1,75 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
// startDevServer starts the JS dev server that does on-demand rebuilding
// and serving of web client JS and CSS resources.
func (s *Server) startDevServer() (cleanup func()) {
root := gitRootDir()
webClientPath := filepath.Join(root, "client", "web")
yarn := filepath.Join(root, "tool", "yarn")
node := filepath.Join(root, "tool", "node")
vite := filepath.Join(webClientPath, "node_modules", ".bin", "vite")
log.Printf("installing JavaScript deps using %s... (might take ~30s)", yarn)
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webClientPath, "install").CombinedOutput()
if err != nil {
log.Fatalf("error running tailscale web's yarn install: %v, %s", err, out)
}
log.Printf("starting JavaScript dev server...")
cmd := exec.Command(node, vite)
cmd.Dir = webClientPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Fatalf("Starting JS dev server: %v", err)
}
log.Printf("JavaScript dev server running as pid %d", cmd.Process.Pid)
return func() {
cmd.Process.Signal(os.Interrupt)
err := cmd.Wait()
log.Printf("JavaScript dev server exited: %v", err)
}
}
func (s *Server) addProxyToDevServer() {
if !s.devMode {
return // only using Vite proxy in dev mode
}
// We use Vite to develop on the web client.
// Vite starts up its own local server for development,
// which we proxy requests to from Server.ServeHTTP.
// Here we set up the proxy to Vite's server.
handleErr := func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("The web client development server isn't running. " +
"Run `./tool/yarn --cwd client/web start` from " +
"the repo root to start the development server."))
w.Write([]byte("\n\nError: " + err.Error()))
}
viteTarget, _ := url.Parse("http://127.0.0.1:4000")
s.devProxy = httputil.NewSingleHostReverseProxy(viteTarget)
s.devProxy.ErrorHandler = handleErr
}
func gitRootDir() string {
top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
log.Fatalf("failed to find git top level (not in corp git?): %v", err)
}
return strings.TrimSpace(string(top))
}

View File

@@ -1,29 +0,0 @@
<!doctype html>
<html class="bg-gray-50">
<head>
<title>Tailscale</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
<link rel="stylesheet" type="text/css" href="/src/index.css" />
</head>
<body>
<div class="min-h-screen py-10 flex justify-center items-center" style="display: none">
<div class="max-w-md">
<h3 class="font-semibold text-lg mb-4">Your web browser is unsupported.</h3>
<p class="mb-2">
Update to a modern browser to access the Tailscale web client. You can use
<a class="link" href="https://www.mozilla.org/en-US/firefox/new/" target="_blank">Firefox</a>,
<a class="link" href="https://www.microsoft.com/en-us/edge" target="_blank">Edge</a>,
<a class="link" href="https://www.apple.com/safari/" target="_blank">Safari</a>,
or <a class="link" href="https://www.google.com/chrome/" target="_blank">Chrome</a>.</p>
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a></p>
</div>
</div>
<noscript>
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
</noscript>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@@ -1,42 +0,0 @@
{
"name": "webclient",
"version": "0.0.1",
"license": "BSD-3-Clause",
"engines": {
"node": "18.16.1",
"yarn": "1.22.19"
},
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"postcss": "^8.4.27",
"prettier": "^2.5.1",
"prettier-plugin-organize-imports": "^3.2.2",
"tailwindcss": "^3.3.3",
"typescript": "^4.7.4",
"vite": "^4.3.9",
"vite-plugin-rewrite-all": "^1.0.1",
"vite-plugin-svgr": "^3.2.0",
"vite-tsconfig-paths": "^3.5.0",
"vitest": "^0.32.0"
},
"scripts": {
"build": "vite build",
"start": "vite",
"lint": "tsc --noEmit",
"test": "vitest",
"format": "prettier --write 'src/**/*.{ts,tsx}'",
"format-check": "prettier --check 'src/**/*.{ts,tsx}'"
},
"prettier": {
"semi": false,
"printWidth": 80
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,25 +0,0 @@
import React from "react"
import { Footer, Header, IP, State } from "src/components/legacy"
import useNodeData from "src/hooks/node-data"
export default function App() {
const data = useNodeData()
return (
<div className="py-14">
{!data ? (
// TODO(sonia): add a loading view
<div className="text-center">Loading...</div>
) : (
<>
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<Header data={data} />
<IP data={data} />
<State data={data} />
</main>
<Footer data={data} />
</>
)}
</div>
)
}

View File

@@ -1,272 +0,0 @@
import React from "react"
import { NodeData } from "src/hooks/node-data"
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
// that (crudely) implement the pre-2023 web client. These are implemented
// purely to ease migration to the new React-based web client, and will
// eventually be completely removed.
export function Header(props: { data: NodeData }) {
const { data } = props
return (
<header className="flex justify-between items-center min-width-0 py-2 mb-8">
<svg
width="26"
height="26"
viewBox="0 0 23 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="flex-shrink-0 mr-4"
>
<circle
opacity="0.2"
cx="3.4"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="3.4"
cy="19.5"
r="2.7"
fill="currentColor"
></circle>
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="11.5"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle
opacity="0.2"
cx="19.5"
cy="3.25"
r="2.7"
fill="currentColor"
></circle>
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle
opacity="0.2"
cx="19.5"
cy="19.5"
r="2.7"
fill="currentColor"
></circle>
</svg>
<div className="flex items-center justify-end space-x-2 w-2/3">
{data.Profile && (
<>
<div className="text-right w-full leading-4">
<h4 className="truncate leading-normal">
{data.Profile.LoginName}
</h4>
<div className="text-xs text-gray-500 text-right">
<a href="#" className="hover:text-gray-700 js-loginButton">
Switch account
</a>{" "}
|{" "}
<a href="#" className="hover:text-gray-700 js-loginButton">
Reauthenticate
</a>{" "}
|{" "}
<a href="#" className="hover:text-gray-700 js-logoutButton">
Logout
</a>
</div>
</div>
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
{data.Profile.ProfilePicURL ? (
<div
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
style={{
backgroundImage: `url(${data.Profile.ProfilePicURL})`,
backgroundSize: "cover",
}}
/>
) : (
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
)}
</div>
</>
)}
</div>
</header>
)
}
export function IP(props: { data: NodeData }) {
const { data } = props
if (!data.IP) {
return null
}
return (
<>
<div className="border border-gray-200 bg-gray-50 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between">
<div className="flex items-center min-width-0">
<svg
className="flex-shrink-0 text-gray-600 mr-3 ml-1"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
<div>
<h4 className="font-semibold truncate mr-2">{data.DeviceName}</h4>
</div>
</div>
<h5>{data.IP}</h5>
</div>
<p className="mt-1 ml-1 mb-6 text-xs text-gray-600">
Debug info: Tailscale {data.IPNVersion}, tun={data.TUNMode.toString()}
{data.IsSynology && (
<>
, DSM{data.DSMVersion}
{data.TUNMode || (
<>
{" "}
(
<a
href="https://tailscale.com/kb/1152/synology-outbound/"
className="link-underline text-gray-600"
target="_blank"
aria-label="Configure outbound synology traffic"
rel="noopener noreferrer"
>
outgoing access not configured
</a>
)
</>
)}
</>
)}
</p>
</>
)
}
export function State(props: { data: NodeData }) {
const { data } = props
switch (data.Status) {
case "NeedsLogin":
case "NoState":
if (data.IP) {
return (
<>
<div className="mb-6">
<p className="text-gray-700">
Your device's key has expired. Reauthenticate this device by
logging in again, or{" "}
<a
href="https://tailscale.com/kb/1028/key-expiry"
className="link"
target="_blank"
>
learn more
</a>
.
</p>
</div>
<a href="#" className="mb-4 js-loginButton" target="_blank">
<button className="button button-blue w-full">
Reauthenticate
</button>
</a>
</>
)
} else {
return (
<>
<div className="mb-6">
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
<p className="text-gray-700">
Get started by logging in to your Tailscale network.
Or,&nbsp;learn&nbsp;more at{" "}
<a
href="https://tailscale.com/"
className="link"
target="_blank"
>
tailscale.com
</a>
.
</p>
</div>
<a href="#" className="mb-4 js-loginButton" target="_blank">
<button className="button button-blue w-full">Log In</button>
</a>
</>
)
}
case "NeedsMachineAuth":
return (
<div className="mb-4">
This device is authorized, but needs approval from a network admin
before it can connect to the network.
</div>
)
default:
return (
<>
<div className="mb-4">
<p>
You are connected! Access this device over Tailscale using the
device name or IP address above.
</p>
</div>
<div className="mb-4">
<a href="#" className="mb-4 js-advertiseExitNode">
{data.AdvertiseExitNode ? (
<button
className="button button-red button-medium"
id="enabled"
>
Stop advertising Exit Node
</button>
) : (
<button
className="button button-blue button-medium"
id="enabled"
>
Advertise as Exit Node
</button>
)}
</a>
</div>
</>
)
}
}
export function Footer(props: { data: NodeData }) {
const { data } = props
return (
<footer className="container max-w-lg mx-auto text-center">
<a
className="text-xs text-gray-500 hover:text-gray-600"
href={data.LicensesURL}
>
Open Source Licenses
</a>
</footer>
)
}

View File

@@ -1,37 +0,0 @@
import { useEffect, useState } from "react"
export type NodeData = {
Profile: UserProfile
Status: string
DeviceName: string
IP: string
AdvertiseExitNode: boolean
AdvertiseRoutes: string
LicensesURL: string
TUNMode: boolean
IsSynology: boolean
DSMVersion: number
IsUnraid: boolean
UnraidToken: string
IPNVersion: string
}
export type UserProfile = {
LoginName: string
DisplayName: string
ProfilePicURL: string
}
// useNodeData returns basic data about the current node.
export default function useNodeData() {
const [data, setData] = useState<NodeData>()
useEffect(() => {
fetch("/api/data")
.then((response) => response.json())
.then((json) => setData(json))
.catch((error) => console.error(error))
}, [])
return data
}

View File

@@ -1,130 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/**
* Non-Tailwind styles begin here.
*/
.bg-gray-0 {
--tw-bg-opacity: 1;
background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
}
.bg-gray-50 {
--tw-bg-opacity: 1;
background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
}
html {
letter-spacing: -0.015em;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.link {
--text-opacity: 1;
color: #4b70cc;
color: rgba(75, 112, 204, var(--text-opacity));
}
.link:hover,
.link:active {
--text-opacity: 1;
color: #19224a;
color: rgba(25, 34, 74, var(--text-opacity));
}
.link-underline {
text-decoration: underline;
}
.link-underline:hover,
.link-underline:active {
text-decoration: none;
}
.link-muted {
/* same as text-gray-500 */
--tw-text-opacity: 1;
color: rgba(112, 110, 109, var(--tw-text-opacity));
}
.link-muted:hover,
.link-muted:active {
/* same as text-gray-500 */
--tw-text-opacity: 1;
color: rgba(68, 67, 66, var(--tw-text-opacity));
}
.button {
font-weight: 500;
padding-top: 0.45rem;
padding-bottom: 0.45rem;
padding-left: 1rem;
padding-right: 1rem;
border-radius: 0.375rem;
border-width: 1px;
border-color: transparent;
transition-property: background-color, border-color, color, box-shadow;
transition-duration: 120ms;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
min-width: 80px;
}
.button:focus {
outline: 0;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
}
.button:disabled {
cursor: not-allowed;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.button-blue {
--bg-opacity: 1;
background-color: #4b70cc;
background-color: rgba(75, 112, 204, var(--bg-opacity));
--border-opacity: 1;
border-color: #4b70cc;
border-color: rgba(75, 112, 204, var(--border-opacity));
--text-opacity: 1;
color: #fff;
color: rgba(255, 255, 255, var(--text-opacity));
}
.button-blue:enabled:hover {
--bg-opacity: 1;
background-color: #3f5db3;
background-color: rgba(63, 93, 179, var(--bg-opacity));
--border-opacity: 1;
border-color: #3f5db3;
border-color: rgba(63, 93, 179, var(--border-opacity));
}
.button-blue:disabled {
--text-opacity: 1;
color: #cedefd;
color: rgba(206, 222, 253, var(--text-opacity));
--bg-opacity: 1;
background-color: #6c94ec;
background-color: rgba(108, 148, 236, var(--bg-opacity));
--border-opacity: 1;
border-color: #6c94ec;
border-color: rgba(108, 148, 236, var(--border-opacity));
}
.button-red {
background-color: #d04841;
border-color: #d04841;
color: #fff;
}
.button-red:enabled:hover {
background-color: #b22d30;
border-color: #b22d30;
}

View File

@@ -1,16 +0,0 @@
import React from "react"
import { createRoot } from "react-dom/client"
import App from "src/components/app"
const rootEl = document.createElement("div")
rootEl.id = "app-root"
rootEl.classList.add("relative", "z-0")
document.body.append(rootEl)
const root = createRoot(rootEl)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -1,12 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -1,16 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ES2017",
"module": "ES2020",
"strict": true,
"sourceMap": true,
"isolatedModules": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"jsx": "react",
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View File

@@ -1,69 +0,0 @@
/// <reference types="vitest" />
import { createLogger, defineConfig } from "vite"
import rewrite from "vite-plugin-rewrite-all"
import svgr from "vite-plugin-svgr"
import paths from "vite-tsconfig-paths"
// Use a custom logger that filters out Vite's logging of server URLs, since
// they are an attractive nuisance (we run a proxy in front of Vite, and the
// tailscale web client should be accessed through that).
// Unfortunately there's no option to disable this logging, so the best we can
// do it to ignore calls from a specific function.
const filteringLogger = createLogger(undefined, { allowClearScreen: false })
const originalInfoLog = filteringLogger.info
filteringLogger.info = (...args) => {
if (new Error("ignored").stack?.includes("printServerUrls")) {
return
}
originalInfoLog.apply(filteringLogger, args)
}
// https://vitejs.dev/config/
export default defineConfig({
base: "/",
plugins: [
paths(),
svgr(),
// By default, the Vite dev server doesn't handle dots
// in path names and treats them as static files.
// This plugin changes Vite's routing logic to fix this.
// See: https://github.com/vitejs/vite/issues/2415
rewrite(),
],
build: {
outDir: "build",
sourcemap: true,
},
esbuild: {
logOverride: {
// Silence a warning about `this` being undefined in ESM when at the
// top-level. The way JSX is transpiled causes this to happen, but it
// isn't a problem.
// See: https://github.com/vitejs/vite/issues/8644
"this-is-undefined-in-esm": "silent",
},
},
server: {
// This needs to be 127.0.0.1 instead of localhost, because of how our
// Go proxy connects to it.
host: "127.0.0.1",
// If you change the port, be sure to update the proxy in adminhttp.go too.
port: 4000,
// Don't proxy the WebSocket connection used for live reloading by running
// it on a separate port.
hmr: {
protocol: "ws",
port: 4001,
},
},
test: {
exclude: ["**/node_modules/**", "**/dist/**"],
testTimeout: 20000,
environment: "jsdom",
deps: {
inline: ["date-fns", /\.wasm\?url$/],
},
},
clearScreen: false,
customLogger: filteringLogger,
})

View File

@@ -1,529 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package web provides the Tailscale client for web.
package web
import (
"bytes"
"context"
"crypto/tls"
_ "embed"
"encoding/json"
"encoding/xml"
"fmt"
"html/template"
"io"
"log"
"net/http"
"net/http/httputil"
"net/netip"
"net/url"
"os"
"os/exec"
"strings"
"tailscale.com/client/tailscale"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/licenses"
"tailscale.com/net/netutil"
"tailscale.com/tailcfg"
"tailscale.com/util/groupmember"
"tailscale.com/util/httpm"
"tailscale.com/version/distro"
)
//go:embed web.html
var webHTML string
//go:embed web.css
var webCSS string
//go:embed auth-redirect.html
var authenticationRedirectHTML string
var tmpl *template.Template
// Server is the backend server for a Tailscale web client.
type Server struct {
lc *tailscale.LocalClient
devMode bool
devProxy *httputil.ReverseProxy // only filled when devMode is on
}
// NewServer constructs a new Tailscale web client server.
//
// lc is an optional parameter. When not filled, NewServer
// initializes its own tailscale.LocalClient.
func NewServer(devMode bool, lc *tailscale.LocalClient) (s *Server, cleanup func()) {
if lc == nil {
lc = &tailscale.LocalClient{}
}
s = &Server{
devMode: devMode,
lc: lc,
}
cleanup = func() {}
if s.devMode {
cleanup = s.startDevServer()
s.addProxyToDevServer()
}
return s, cleanup
}
func init() {
tmpl = template.Must(template.New("web.html").Parse(webHTML))
template.Must(tmpl.New("web.css").Parse(webCSS))
}
// authorize returns the name of the user accessing the web UI after verifying
// whether the user has access to the web UI. The function will write the
// error to the provided http.ResponseWriter.
// Note: This is different from a tailscale user, and is typically the local
// user on the node.
func authorize(w http.ResponseWriter, r *http.Request) (string, error) {
switch distro.Get() {
case distro.Synology:
user, err := synoAuthn()
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return "", err
}
if err := authorizeSynology(user); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return "", err
}
return user, nil
case distro.QNAP:
user, resp, err := qnapAuthn(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return "", err
}
if resp.IsAdmin == 0 {
http.Error(w, err.Error(), http.StatusForbidden)
return "", err
}
return user, nil
}
return "", nil
}
// authorizeSynology checks whether the provided user has access to the web UI
// by consulting the membership of the "administrators" group.
func authorizeSynology(name string) error {
yes, err := groupmember.IsMemberOfGroup("administrators", name)
if err != nil {
return err
}
if !yes {
return fmt.Errorf("not a member of administrators group")
}
return 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
}
func synoAuthn() (string, error) {
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("auth: %v: %s", err, out)
}
return strings.TrimSpace(string(out)), nil
}
func authRedirect(w http.ResponseWriter, r *http.Request) bool {
if distro.Get() == distro.Synology {
return synoTokenRedirect(w, r)
}
return false
}
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get("X-Syno-Token") != "" {
return false
}
if r.URL.Query().Get("SynoToken") != "" {
return false
}
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
return false
}
// We need a SynoToken for authenticate.cgi.
// So we tell the client to get one.
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
return true
}
const synoTokenRedirectHTML = `<html><body>
Redirecting with session token...
<script>
var serverURL = window.location.protocol + "//" + window.location.host;
var req = new XMLHttpRequest();
req.overrideMimeType("application/json");
req.open("GET", serverURL + "/webman/login.cgi", true);
req.onload = function() {
var jsonResponse = JSON.parse(req.responseText);
var token = jsonResponse["SynoToken"];
document.location.href = serverURL + "/webman/3rdparty/Tailscale/?SynoToken=" + token;
};
req.send(null);
</script>
</body></html>
`
// ServeHTTP processes all requests for the Tailscale web client.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if s.devMode {
if r.URL.Path == "/api/data" {
user, err := authorize(w, r)
if err != nil {
return
}
switch r.Method {
case httpm.GET:
s.serveGetNodeDataJSON(w, r, user)
case httpm.POST:
s.servePostNodeUpdate(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
}
// When in dev mode, proxy to the Vite dev server.
s.devProxy.ServeHTTP(w, r)
return
}
if authRedirect(w, r) {
return
}
user, err := authorize(w, r)
if err != nil {
return
}
switch {
case r.URL.Path == "/redirect" || r.URL.Path == "/redirect/":
io.WriteString(w, authenticationRedirectHTML)
return
case r.Method == "POST":
s.servePostNodeUpdate(w, r)
return
default:
s.serveGetNodeData(w, r, user)
return
}
}
type nodeData struct {
Profile tailcfg.UserProfile
SynologyUser string
Status string
DeviceName string
IP string
AdvertiseExitNode bool
AdvertiseRoutes string
LicensesURL string
TUNMode bool
IsSynology bool
DSMVersion int // 6 or 7, if IsSynology=true
IsUnraid bool
UnraidToken string
IPNVersion string
}
func (s *Server) getNodeData(ctx context.Context, user string) (*nodeData, error) {
st, err := s.lc.Status(ctx)
if err != nil {
return nil, err
}
prefs, err := s.lc.GetPrefs(ctx)
if err != nil {
return nil, err
}
profile := st.User[st.Self.UserID]
deviceName := strings.Split(st.Self.DNSName, ".")[0]
versionShort := strings.Split(st.Version, "-")[0]
data := &nodeData{
SynologyUser: user,
Profile: profile,
Status: st.BackendState,
DeviceName: deviceName,
LicensesURL: licenses.LicensesURL(),
TUNMode: st.TUN,
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
DSMVersion: distro.DSMVersion(),
IsUnraid: distro.Get() == distro.Unraid,
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
IPNVersion: versionShort,
}
exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0")
exitNodeRouteV6 := netip.MustParsePrefix("::/0")
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
data.AdvertiseExitNode = true
} else {
if data.AdvertiseRoutes != "" {
data.AdvertiseRoutes += ","
}
data.AdvertiseRoutes += r.String()
}
}
if len(st.TailscaleIPs) != 0 {
data.IP = st.TailscaleIPs[0].String()
}
return data, nil
}
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request, user string) {
data, err := s.getNodeData(r.Context(), user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
buf := new(bytes.Buffer)
if err := tmpl.Execute(buf, *data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(buf.Bytes())
}
func (s *Server) serveGetNodeDataJSON(w http.ResponseWriter, r *http.Request, user string) {
data, err := s.getNodeData(r.Context(), user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(*data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
return
}
type nodeUpdate struct {
AdvertiseRoutes string
AdvertiseExitNode bool
Reauthenticate bool
ForceLogout bool
}
func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
st, err := s.lc.Status(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var postData nodeUpdate
type mi map[string]any
if err := json.NewDecoder(r.Body).Decode(&postData); err != nil {
w.WriteHeader(400)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
mp := &ipn.MaskedPrefs{
AdvertiseRoutesSet: true,
WantRunningSet: true,
}
mp.Prefs.WantRunning = true
mp.Prefs.AdvertiseRoutes = routes
log.Printf("Doing edit: %v", mp.Pretty())
if _, err := s.lc.EditPrefs(r.Context(), mp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
var reauth, logout bool
if postData.Reauthenticate {
reauth = true
}
if postData.ForceLogout {
logout = true
}
log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
url, err := s.tailscaleUp(r.Context(), st, postData)
log.Printf("tailscaleUp = (URL %v, %v)", url != "", err)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
if url != "" {
json.NewEncoder(w).Encode(mi{"url": url})
} else {
io.WriteString(w, "{}")
}
return
}
func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData nodeUpdate) (authURL string, retErr error) {
if postData.ForceLogout {
if err := s.lc.Logout(ctx); err != nil {
return "", fmt.Errorf("Logout error: %w", err)
}
return "", nil
}
origAuthURL := st.AuthURL
isRunning := st.BackendState == ipn.Running.String()
forceReauth := postData.Reauthenticate
if !forceReauth {
if origAuthURL != "" {
return origAuthURL, nil
}
if isRunning {
return "", nil
}
}
// printAuthURL reports whether we should print out the
// provided auth URL from an IPN notify.
printAuthURL := func(url string) bool {
return url != origAuthURL
}
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
watcher, err := s.lc.WatchIPNBus(watchCtx, 0)
if err != nil {
return "", err
}
defer watcher.Close()
go func() {
if !isRunning {
s.lc.Start(ctx, ipn.Options{})
}
if forceReauth {
s.lc.StartLoginInteractive(ctx)
}
}()
for {
n, err := watcher.Next()
if err != nil {
return "", err
}
if n.ErrMessage != nil {
msg := *n.ErrMessage
return "", fmt.Errorf("backend error: %v", msg)
}
if url := n.BrowseToURL; url != nil && printAuthURL(*url) {
return *url, nil
}
}
}

View File

@@ -1,64 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package web
import (
"net/url"
"testing"
)
func TestQnapAuthnURL(t *testing.T) {
query := url.Values{
"qtoken": []string{"token"},
}
tests := []struct {
name string
in string
want string
}{
{
name: "localhost http",
in: "http://localhost:8088/",
want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "localhost https",
in: "https://localhost:5000/",
want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "IP http",
in: "http://10.1.20.4:80/",
want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "IP6 https",
in: "https://[ff7d:0:1:2::1]/",
want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "hostname https",
in: "https://qnap.example.com/",
want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "invalid URL",
in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.",
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
},
{
name: "err != nil",
in: "http://192.168.0.%31/",
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u := qnapAuthnURL(tt.in, query)
if u != tt.want {
t.Errorf("expected url: %q, got: %q", tt.want, u)
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,998 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package clientupdate implements tailscale client update for all supported
// platforms. This package can be used from both tailscaled and tailscale
// binaries.
package clientupdate
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"tailscale.com/hostinfo"
"tailscale.com/net/tshttpproxy"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/util/must"
"tailscale.com/util/winutil"
"tailscale.com/version"
"tailscale.com/version/distro"
)
const (
CurrentTrack = ""
StableTrack = "stable"
UnstableTrack = "unstable"
)
func versionToTrack(v string) (string, error) {
_, rest, ok := strings.Cut(v, ".")
if !ok {
return "", fmt.Errorf("malformed version %q", v)
}
minorStr, _, ok := strings.Cut(rest, ".")
if !ok {
return "", fmt.Errorf("malformed version %q", v)
}
minor, err := strconv.Atoi(minorStr)
if err != nil {
return "", fmt.Errorf("malformed version %q", v)
}
if minor%2 == 0 {
return "stable", nil
}
return "unstable", nil
}
type updater struct {
UpdateArgs
track string
update func() error
}
// UpdateArgs contains arguments needed to run an update.
type UpdateArgs struct {
// Version can be a specific version number or one of the predefined track
// constants:
//
// - CurrentTrack will use the latest version from the same track as the
// running binary
// - StableTrack and UnstableTrack will use the latest versions of the
// corresponding tracks
//
// Leaving this empty is the same as using CurrentTrack.
Version string
// AppStore forces a local app store check, even if the current binary was
// not installed via an app store.
AppStore bool
// Logf is a logger for update progress messages.
Logf logger.Logf
// Confirm is called when a new version is available and should return true
// if this new version should be installed. When Confirm returns false, the
// update is aborted.
Confirm func(newVer string) bool
}
func (args UpdateArgs) validate() error {
if args.Confirm == nil {
return errors.New("missing Confirm callback in UpdateArgs")
}
if args.Logf == nil {
return errors.New("missing Logf callback in UpdateArgs")
}
return nil
}
// Update runs a single update attempt using the platform-specific mechanism.
//
// On Windows, this copies the calling binary and re-executes it to apply the
// update. The calling binary should handle an "update" subcommand and call
// this function again for the re-executed binary to proceed.
func Update(args UpdateArgs) error {
if err := args.validate(); err != nil {
return err
}
up := &updater{
UpdateArgs: args,
}
switch up.Version {
case StableTrack, UnstableTrack:
up.track = up.Version
case CurrentTrack:
if version.IsUnstableBuild() {
up.track = UnstableTrack
} else {
up.track = StableTrack
}
default:
var err error
up.track, err = versionToTrack(args.Version)
if err != nil {
return err
}
}
switch runtime.GOOS {
case "windows":
up.update = up.updateWindows
case "linux":
switch distro.Get() {
case distro.Synology:
up.update = up.updateSynology
case distro.Debian: // includes Ubuntu
up.update = up.updateDebLike
case distro.Arch:
up.update = up.updateArchLike
case distro.Alpine:
up.update = up.updateAlpineLike
}
switch {
case haveExecutable("pacman"):
up.update = up.updateArchLike
case haveExecutable("apt-get"): // TODO(awly): add support for "apt"
// The distro.Debian switch case above should catch most apt-based
// systems, but add this fallback just in case.
up.update = up.updateDebLike
case haveExecutable("dnf"):
up.update = up.updateFedoraLike("dnf")
case haveExecutable("yum"):
up.update = up.updateFedoraLike("yum")
case haveExecutable("apk"):
up.update = up.updateAlpineLike
}
case "darwin":
switch {
case !args.AppStore && !version.IsSandboxedMacOS():
return errors.ErrUnsupported
case !args.AppStore && strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
up.update = up.updateMacSys
default:
up.update = up.updateMacAppStore
}
case "freebsd":
up.update = up.updateFreeBSD
}
if up.update == nil {
return errors.ErrUnsupported
}
return up.update()
}
func (up *updater) confirm(ver string) bool {
if version.Short() == ver {
up.Logf("already running %v; no update needed", ver)
return false
}
if up.Confirm != nil {
return up.Confirm(ver)
}
return true
}
func (up *updater) updateSynology() error {
if up.Version != "" {
return errors.New("installing a specific version on Synology is not supported")
}
// Get the latest version and list of SPKs from pkgs.tailscale.com.
osName := fmt.Sprintf("dsm%d", distro.DSMVersion())
arch, err := synoArch(hostinfo.New())
if err != nil {
return err
}
latest, err := latestPackages(up.track)
if err != nil {
return err
}
if latest.Version == "" {
return fmt.Errorf("no latest version found for %q track", up.track)
}
spkName := latest.SPKs[osName][arch]
if spkName == "" {
return fmt.Errorf("cannot find Synology package for os=%s arch=%s, please report a bug with your device model", osName, arch)
}
if !up.confirm(latest.Version) {
return nil
}
if err := requireRoot(); err != nil {
return err
}
// Download the SPK into a temporary directory.
spkDir, err := os.MkdirTemp("", "tailscale-update")
if err != nil {
return err
}
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/%s", up.track, spkName)
spkPath := filepath.Join(spkDir, path.Base(url))
// TODO(awly): we should sign SPKs and validate signatures here too.
if err := up.downloadURLToFile(url, spkPath); err != nil {
return err
}
// Install the SPK. Run via nohup to allow install to succeed when we're
// connected over tailscale ssh and this parent process dies. Otherwise, if
// you abort synopkg install mid-way, tailscaled is not restarted.
cmd := exec.Command("nohup", "synopkg", "install", spkPath)
// Don't attach cmd.Stdout to os.Stdout because nohup will redirect that
// into nohup.out file. synopkg doesn't have any progress output anyway, it
// just spits out a JSON result when done.
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("synopkg install failed: %w\noutput:\n%s", err, out)
}
return nil
}
// synoArch returns the Synology CPU architecture matching one of the SPK
// architectures served from pkgs.tailscale.com.
func synoArch(hinfo *tailcfg.Hostinfo) (string, error) {
// Most Synology boxes just use a different arch name from GOARCH.
arch := map[string]string{
"amd64": "x86_64",
"386": "i686",
"arm64": "armv8",
}[hinfo.GoArch]
// Here's the fun part, some older ARM boxes require you to use SPKs
// specifically for their CPU.
//
// See https://github.com/SynoCommunity/spksrc/wiki/Synology-and-SynoCommunity-Package-Architectures
// for a complete list. Here, we override GOARCH for those older boxes that
// support at least DSM6.
//
// This is an artisanal hand-crafted list based on the wiki page. Some
// values may be wrong, since we don't have all those devices to actually
// test with.
switch hinfo.DeviceModel {
case "DS213air", "DS213", "DS413j",
"DS112", "DS112+", "DS212", "DS212+", "RS212", "RS812", "DS212j", "DS112j",
"DS111", "DS211", "DS211+", "DS411slim", "DS411", "RS411", "DS211j", "DS411j":
arch = "88f6281"
case "NVR1218", "NVR216", "VS960HD", "VS360HD":
arch = "hi3535"
case "DS1517", "DS1817", "DS416", "DS2015xs", "DS715", "DS1515", "DS215+":
arch = "alpine"
case "DS216se", "DS115j", "DS114", "DS214se", "DS414slim", "RS214", "DS14", "EDS14", "DS213j":
arch = "armada370"
case "DS115", "DS215j":
arch = "armada375"
case "DS419slim", "DS218j", "RS217", "DS116", "DS216j", "DS216", "DS416slim", "RS816", "DS416j":
arch = "armada38x"
case "RS815", "DS214", "DS214+", "DS414", "RS814":
arch = "armadaxp"
case "DS414j":
arch = "comcerto2k"
case "DS216play":
arch = "monaco"
}
if arch == "" {
return "", fmt.Errorf("cannot determine CPU architecture for Synology model %q (Go arch %q), please report a bug at https://github.com/tailscale/tailscale/issues/new/choose", hinfo.DeviceModel, hinfo.GoArch)
}
return arch, nil
}
func (up *updater) updateDebLike() error {
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
}
if !up.confirm(ver) {
return nil
}
if err := requireRoot(); err != nil {
return err
}
if updated, err := updateDebianAptSourcesList(up.track); err != nil {
return err
} else if updated {
up.Logf("Updated %s to use the %s track", aptSourcesFile, up.track)
}
cmd := exec.Command("apt-get", "update",
// Only update the tailscale repo, not the other ones, treating
// the tailscale.list file as the main "sources.list" file.
"-o", "Dir::Etc::SourceList=sources.list.d/tailscale.list",
// Disable the "sources.list.d" directory:
"-o", "Dir::Etc::SourceParts=-",
// Don't forget about packages in the other repos just because
// we're not updating them:
"-o", "APT::Get::List-Cleanup=0",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
cmd = exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
return nil
}
const aptSourcesFile = "/etc/apt/sources.list.d/tailscale.list"
// updateDebianAptSourcesList updates the /etc/apt/sources.list.d/tailscale.list
// file to make sure it has the provided track (stable or unstable) in it.
//
// If it already has the right track (including containing both stable and
// unstable), it does nothing.
func updateDebianAptSourcesList(dstTrack string) (rewrote bool, err error) {
was, err := os.ReadFile(aptSourcesFile)
if err != nil {
return false, err
}
newContent, err := updateDebianAptSourcesListBytes(was, dstTrack)
if err != nil {
return false, err
}
if bytes.Equal(was, newContent) {
return false, nil
}
return true, os.WriteFile(aptSourcesFile, newContent, 0644)
}
func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent []byte, err error) {
trackURLPrefix := []byte("https://pkgs.tailscale.com/" + dstTrack + "/")
var buf bytes.Buffer
var changes int
bs := bufio.NewScanner(bytes.NewReader(was))
hadCorrect := false
commentLine := regexp.MustCompile(`^\s*\#`)
pkgsURL := regexp.MustCompile(`\bhttps://pkgs\.tailscale\.com/((un)?stable)/`)
for bs.Scan() {
line := bs.Bytes()
if !commentLine.Match(line) {
line = pkgsURL.ReplaceAllFunc(line, func(m []byte) []byte {
if bytes.Equal(m, trackURLPrefix) {
hadCorrect = true
} else {
changes++
}
return trackURLPrefix
})
}
buf.Write(line)
buf.WriteByte('\n')
}
if hadCorrect || (changes == 1 && bytes.Equal(bytes.TrimSpace(was), bytes.TrimSpace(buf.Bytes()))) {
// Unchanged or close enough.
return was, nil
}
if changes != 1 {
// No changes, or an unexpected number of changes (what?). Bail.
// They probably editted it by hand and we don't know what to do.
return nil, fmt.Errorf("unexpected/unsupported %s contents", aptSourcesFile)
}
return buf.Bytes(), nil
}
func (up *updater) updateArchLike() (err error) {
if up.Version != "" {
return errors.New("installing a specific version on Arch-based distros is not supported")
}
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "pacman --sync --refresh tailscale"`, err)
}
}()
out, err := exec.Command("pacman", "--sync", "--refresh", "--info", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed checking pacman for latest tailscale version: %w, output: %q", err, out)
}
ver, err := parsePacmanVersion(out)
if err != nil {
return err
}
if !up.confirm(ver) {
return nil
}
cmd := exec.Command("pacman", "--sync", "--noconfirm", "tailscale")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using pacman: %w", err)
}
return nil
}
func parsePacmanVersion(out []byte) (string, error) {
for _, line := range strings.Split(string(out), "\n") {
// The line we're looking for looks like this:
// Version : 1.44.2-1
if !strings.HasPrefix(line, "Version") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
}
ver := strings.TrimSpace(parts[1])
// Trim the Arch patch version.
ver = strings.Split(ver, "-")[0]
if ver == "" {
return "", fmt.Errorf("version output from pacman is malformed: %q, cannot determine upgrade version", line)
}
return ver, nil
}
return "", fmt.Errorf("could not find latest version of tailscale via pacman")
}
const yumRepoConfigFile = "/etc/yum.repos.d/tailscale.repo"
// updateFedoraLike updates tailscale on any distros in the Fedora family,
// specifically anything that uses "dnf" or "yum" package managers. The actual
// package manager is passed via packageManager.
func (up *updater) updateFedoraLike(packageManager string) func() error {
return func() (err error) {
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "%s upgrade tailscale"`, err, packageManager)
}
}()
ver, err := requestedTailscaleVersion(up.Version, up.track)
if err != nil {
return err
}
if !up.confirm(ver) {
return nil
}
if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.track); err != nil {
return err
} else if updated {
up.Logf("Updated %s to use the %s track", yumRepoConfigFile, up.track)
}
cmd := exec.Command(packageManager, "install", "--assumeyes", fmt.Sprintf("tailscale-%s-1", ver))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
return nil
}
}
// updateYUMRepoTrack updates the repoFile file to make sure it has the
// provided track (stable or unstable) in it.
func updateYUMRepoTrack(repoFile, dstTrack string) (rewrote bool, err error) {
was, err := os.ReadFile(repoFile)
if err != nil {
return false, err
}
urlRe := regexp.MustCompile(`^(baseurl|gpgkey)=https://pkgs\.tailscale\.com/(un)?stable/`)
urlReplacement := fmt.Sprintf("$1=https://pkgs.tailscale.com/%s/", dstTrack)
s := bufio.NewScanner(bytes.NewReader(was))
newContent := bytes.NewBuffer(make([]byte, 0, len(was)))
for s.Scan() {
line := s.Text()
// Handle repo section name, like "[tailscale-stable]".
if len(line) > 0 && line[0] == '[' {
if !strings.HasPrefix(line, "[tailscale-") {
return false, fmt.Errorf("%q does not look like a tailscale repo file, it contains an unexpected %q section", repoFile, line)
}
fmt.Fprintf(newContent, "[tailscale-%s]\n", dstTrack)
continue
}
// Update the track mentioned in repo name.
if strings.HasPrefix(line, "name=") {
fmt.Fprintf(newContent, "name=Tailscale %s\n", dstTrack)
continue
}
// Update the actual repo URLs.
if strings.HasPrefix(line, "baseurl=") || strings.HasPrefix(line, "gpgkey=") {
fmt.Fprintln(newContent, urlRe.ReplaceAllString(line, urlReplacement))
continue
}
fmt.Fprintln(newContent, line)
}
if bytes.Equal(was, newContent.Bytes()) {
return false, nil
}
return true, os.WriteFile(repoFile, newContent.Bytes(), 0644)
}
func (up *updater) updateAlpineLike() (err error) {
if up.Version != "" {
return errors.New("installing a specific version on Alpine-based distros is not supported")
}
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "apk upgrade tailscale"`, err)
}
}()
out, err := exec.Command("apk", "update").CombinedOutput()
if err != nil {
return fmt.Errorf("failed refresh apk repository indexes: %w, output: %q", err, out)
}
out, err = exec.Command("apk", "info", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed checking apk for latest tailscale version: %w, output: %q", err, out)
}
ver, err := parseAlpinePackageVersion(out)
if err != nil {
return fmt.Errorf(`failed to parse latest version from "apk info tailscale": %w`, err)
}
if !up.confirm(ver) {
return nil
}
cmd := exec.Command("apk", "upgrade", "tailscale")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using apk: %w", err)
}
return nil
}
func parseAlpinePackageVersion(out []byte) (string, error) {
s := bufio.NewScanner(bytes.NewReader(out))
for s.Scan() {
// The line should look like this:
// tailscale-1.44.2-r0 description:
line := strings.TrimSpace(s.Text())
if !strings.HasPrefix(line, "tailscale-") {
continue
}
parts := strings.SplitN(line, "-", 3)
if len(parts) < 3 {
return "", fmt.Errorf("malformed info line: %q", line)
}
return parts[1], nil
}
return "", errors.New("tailscale version not found in output")
}
func (up *updater) updateMacSys() error {
return errors.New("NOTREACHED: On MacSys builds, `tailscale update` is handled in Swift to launch the GUI updater")
}
func (up *updater) updateMacAppStore() error {
out, err := exec.Command("defaults", "read", "/Library/Preferences/com.apple.commerce.plist", "AutoUpdate").CombinedOutput()
if err != nil {
return fmt.Errorf("can't check App Store auto-update setting: %w, output: %q", err, string(out))
}
const on = "1\n"
if string(out) != on {
up.Logf("NOTE: Automatic updating for App Store apps is turned off. You can change this setting in System Settings (search for update).")
}
out, err = exec.Command("softwareupdate", "--list").CombinedOutput()
if err != nil {
return fmt.Errorf("can't check App Store for available updates: %w, output: %q", err, string(out))
}
newTailscale := parseSoftwareupdateList(out)
if newTailscale == "" {
up.Logf("no Tailscale update available")
return nil
}
newTailscaleVer := strings.TrimPrefix(newTailscale, "Tailscale-")
if !up.confirm(newTailscaleVer) {
return nil
}
cmd := exec.Command("sudo", "softwareupdate", "--install", newTailscale)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("can't install App Store update for Tailscale: %w", err)
}
return nil
}
var macOSAppStoreListPattern = regexp.MustCompile(`(?m)^\s+\*\s+Label:\s*(Tailscale-\d[\d\.]+)`)
// parseSoftwareupdateList searches the output of `softwareupdate --list` on
// Darwin and returns the matching Tailscale package label. If there is none,
// returns the empty string.
//
// See TestParseSoftwareupdateList for example inputs.
func parseSoftwareupdateList(stdout []byte) string {
matches := macOSAppStoreListPattern.FindSubmatch(stdout)
if len(matches) < 2 {
return ""
}
return string(matches[1])
}
// 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.
const winMSIEnv = "TS_UPDATE_WIN_MSI"
var (
verifyAuthenticode func(string) error // or nil on non-Windows
markTempFileFunc func(string) error // or nil on non-Windows
)
func (up *updater) updateWindows() error {
if msi := os.Getenv(winMSIEnv); msi != "" {
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
}
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
}
if !winutil.IsCurrentProcessElevated() {
return errors.New("must be run as Administrator")
}
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
}
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
msiTarget := filepath.Join(msiDir, path.Base(url))
if err := up.downloadURLToFile(url, 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...")
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)
cmd.Stdout = os.Stderr
cmd.Stderr = os.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", "/promptrestart", "/qn")
cmd.Dir = filepath.Dir(msi)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err = cmd.Run()
if err == nil {
break
}
uninstallVersion := version.Short()
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 = os.Stdout
cmd.Stderr = os.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 makeSelfCopy() (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 f2.Name(), f2.Close()
}
func (up *updater) downloadURLToFile(urlSrc, fileDst string) (ret error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = tshttpproxy.ProxyFromEnvironment
defer tr.CloseIdleConnections()
c := &http.Client{Transport: tr}
quickCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
headReq := must.Get(http.NewRequestWithContext(quickCtx, "HEAD", urlSrc, nil))
res, err := c.Do(headReq)
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("HEAD %s: %v", urlSrc, res.Status)
}
if res.ContentLength <= 0 {
return fmt.Errorf("HEAD %s: unexpected Content-Length %v", urlSrc, res.ContentLength)
}
up.Logf("Download size: %v", res.ContentLength)
hashReq := must.Get(http.NewRequestWithContext(quickCtx, "GET", urlSrc+".sha256", nil))
hashRes, err := c.Do(hashReq)
if err != nil {
return err
}
hashHex, err := io.ReadAll(io.LimitReader(hashRes.Body, 100))
hashRes.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s.sha256: %v", urlSrc, res.Status)
}
if err != nil {
return err
}
wantHash, err := hex.DecodeString(string(strings.TrimSpace(string(hashHex))))
if err != nil {
return err
}
hash := sha256.New()
dlReq := must.Get(http.NewRequestWithContext(context.Background(), "GET", urlSrc, nil))
dlRes, err := c.Do(dlReq)
if err != nil {
return err
}
// TODO(bradfitz): resume from existing partial file on disk
if dlRes.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: %v", urlSrc, dlRes.Status)
}
of, err := os.Create(fileDst)
if err != nil {
return err
}
defer func() {
if ret != nil {
of.Close()
// TODO(bradfitz): os.Remove(fileDst) too? or keep it to resume from/debug later.
}
}()
pw := &progressWriter{total: res.ContentLength, logf: up.Logf}
n, err := io.Copy(io.MultiWriter(hash, of, pw), io.LimitReader(dlRes.Body, res.ContentLength))
if err != nil {
return err
}
if n != res.ContentLength {
return fmt.Errorf("downloaded %v; want %v", n, res.ContentLength)
}
if err := of.Close(); err != nil {
return err
}
pw.print()
if !bytes.Equal(hash.Sum(nil), wantHash) {
return fmt.Errorf("SHA-256 of downloaded MSI didn't match expected value")
}
up.Logf("hash matched")
return 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 (up *updater) updateFreeBSD() (err error) {
if up.Version != "" {
return errors.New("installing a specific version on FreeBSD is not supported")
}
if err := requireRoot(); err != nil {
return err
}
defer func() {
if err != nil {
err = fmt.Errorf(`%w; you can try updating using "pkg upgrade tailscale"`, err)
}
}()
out, err := exec.Command("pkg", "update").CombinedOutput()
if err != nil {
return fmt.Errorf("failed refresh pkg repository indexes: %w, output: %q", err, out)
}
out, err = exec.Command("pkg", "rquery", "%v", "tailscale").CombinedOutput()
if err != nil {
return fmt.Errorf("failed checking pkg for latest tailscale version: %w, output: %q", err, out)
}
ver := string(bytes.TrimSpace(out))
if !up.confirm(ver) {
return nil
}
cmd := exec.Command("pkg", "upgrade", "tailscale")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed tailscale update using pkg: %w", err)
}
return nil
}
func haveExecutable(name string) bool {
path, err := exec.LookPath(name)
return err == nil && path != ""
}
func requestedTailscaleVersion(ver, track string) (string, error) {
if ver != "" {
return ver, nil
}
return LatestTailscaleVersion(track)
}
// LatestTailscaleVersion returns the latest released version for the given
// track from pkgs.tailscale.com.
func LatestTailscaleVersion(track string) (string, error) {
if track == CurrentTrack {
if version.IsUnstableBuild() {
track = UnstableTrack
} else {
track = StableTrack
}
}
latest, err := latestPackages(track)
if err != nil {
return "", err
}
if latest.Version == "" {
return "", fmt.Errorf("no latest version found for %q track", track)
}
return latest.Version, nil
}
type trackPackages struct {
Version string
Tarballs map[string]string
Exes []string
MSIs map[string]string
MacZips map[string]string
SPKs map[string]map[string]string
}
func latestPackages(track string) (*trackPackages, error) {
url := fmt.Sprintf("https://pkgs.tailscale.com/%s/?mode=json&os=%s", track, runtime.GOOS)
res, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("fetching latest tailscale version: %w", err)
}
defer res.Body.Close()
var latest trackPackages
if err := json.NewDecoder(res.Body).Decode(&latest); err != nil {
return nil, fmt.Errorf("decoding JSON: %v: %w", res.Status, err)
}
return &latest, nil
}
func requireRoot() error {
if os.Geteuid() == 0 {
return nil
}
switch runtime.GOOS {
case "linux":
return errors.New("must be root; use sudo")
case "freebsd", "openbsd":
return errors.New("must be root; use doas")
default:
return errors.New("must be root")
}
}

View File

@@ -1,486 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package clientupdate
import (
"fmt"
"os"
"path/filepath"
"testing"
"tailscale.com/tailcfg"
)
func TestUpdateDebianAptSourcesListBytes(t *testing.T) {
tests := []struct {
name string
toTrack string
in string
want string // empty means want no change
wantErr string
}{
{
name: "stable-to-unstable",
toTrack: UnstableTrack,
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
want: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
},
{
name: "stable-unchanged",
toTrack: StableTrack,
in: "# Tailscale packages for debian buster\ndeb https://pkgs.tailscale.com/stable/debian bullseye main\n",
},
{
name: "if-both-stable-and-unstable-dont-change",
toTrack: StableTrack,
in: "# Tailscale packages for debian buster\n" +
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
},
{
name: "if-both-stable-and-unstable-dont-change-unstable",
toTrack: UnstableTrack,
in: "# Tailscale packages for debian buster\n" +
"deb https://pkgs.tailscale.com/stable/debian bullseye main\n" +
"deb https://pkgs.tailscale.com/unstable/debian bullseye main\n",
},
{
name: "signed-by-form",
toTrack: UnstableTrack,
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu jammy main\n",
want: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/unstable/ubuntu jammy main\n",
},
{
name: "unsupported-lines",
toTrack: UnstableTrack,
in: "# Tailscale packages for ubuntu jammy\ndeb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/foobar/ubuntu jammy main\n",
wantErr: "unexpected/unsupported /etc/apt/sources.list.d/tailscale.list contents",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
newContent, err := updateDebianAptSourcesListBytes([]byte(tt.in), tt.toTrack)
if err != nil {
if err.Error() != tt.wantErr {
t.Fatalf("error = %v; want %q", err, tt.wantErr)
}
return
}
if tt.wantErr != "" {
t.Fatalf("got no error; want %q", tt.wantErr)
}
var gotChange string
if string(newContent) != tt.in {
gotChange = string(newContent)
}
if gotChange != tt.want {
t.Errorf("wrong result\n got: %q\nwant: %q", gotChange, tt.want)
}
})
}
}
func TestParseSoftwareupdateList(t *testing.T) {
tests := []struct {
name string
input []byte
want string
}{
{
name: "update-at-end-of-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
* Label: Tailscale-1.23.4
Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES,
`),
want: "Tailscale-1.23.4",
},
{
name: "update-in-middle-of-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: Tailscale-1.23.5000
Title: The Tailscale VPN, Version: 1.23.4, Size: 1023K, Recommended: YES,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
`),
want: "Tailscale-1.23.5000",
},
{
name: "update-not-in-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: ProAppsQTCodecs-1.0
Title: ProApps QuickTime codecs, Version: 1.0, Size: 968K, Recommended: YES,
`),
want: "",
},
{
name: "decoy-in-list",
input: []byte(`
Software Update Tool
Finding available software
Software Update found the following new or updated software:
* Label: MacBookAirEFIUpdate2.4-2.4
Title: MacBook Air EFI Firmware Update, Version: 2.4, Size: 3817K, Recommended: YES, Action: restart,
* Label: Malware-1.0
Title: * Label: Tailscale-0.99.0, Version: 1.0, Size: 968K, Recommended: NOT REALLY TBH,
`),
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := parseSoftwareupdateList(test.input)
if test.want != got {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
func TestParsePacmanVersion(t *testing.T) {
tests := []struct {
desc string
out string
want string
wantErr bool
}{
{
desc: "valid version",
out: `
:: Synchronizing package databases...
endeavouros is up to date
core is up to date
extra is up to date
multilib is up to date
Repository : extra
Name : tailscale
Version : 1.44.2-1
Description : A mesh VPN that makes it easy to connect your devices, wherever they are.
Architecture : x86_64
URL : https://tailscale.com
Licenses : MIT
Groups : None
Provides : None
Depends On : glibc
Optional Deps : None
Conflicts With : None
Replaces : None
Download Size : 7.98 MiB
Installed Size : 32.47 MiB
Packager : Christian Heusel <gromit@archlinux.org>
Build Date : Tue 18 Jul 2023 12:28:37 PM PDT
Validated By : MD5 Sum SHA-256 Sum Signature
`,
want: "1.44.2",
},
{
desc: "version without Arch patch number",
out: `
... snip ...
Name : tailscale
Version : 1.44.2
Description : A mesh VPN that makes it easy to connect your devices, wherever they are.
... snip ...
`,
want: "1.44.2",
},
{
desc: "missing version",
out: `
... snip ...
Name : tailscale
Description : A mesh VPN that makes it easy to connect your devices, wherever they are.
... snip ...
`,
wantErr: true,
},
{
desc: "empty version",
out: `
... snip ...
Name : tailscale
Version :
Description : A mesh VPN that makes it easy to connect your devices, wherever they are.
... snip ...
`,
wantErr: true,
},
{
desc: "empty input",
out: "",
wantErr: true,
},
{
desc: "sneaky version in description",
out: `
... snip ...
Name : tailscale
Description : A mesh VPN that makes it easy to connect your devices, wherever they are. Version : 1.2.3
Version : 1.44.2
... snip ...
`,
want: "1.44.2",
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
got, err := parsePacmanVersion([]byte(tt.out))
if err == nil && tt.wantErr {
t.Fatalf("got nil error and version %q, want non-nil error", got)
}
if err != nil && !tt.wantErr {
t.Fatalf("got error: %q, want nil", err)
}
if got != tt.want {
t.Fatalf("got version: %q, want %q", got, tt.want)
}
})
}
}
func TestUpdateYUMRepoTrack(t *testing.T) {
tests := []struct {
desc string
before string
track string
after string
rewrote bool
wantErr bool
}{
{
desc: "same track",
before: `
[tailscale-stable]
name=Tailscale stable
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
enabled=1
type=rpm
repo_gpgcheck=1
gpgcheck=0
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
`,
track: StableTrack,
after: `
[tailscale-stable]
name=Tailscale stable
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
enabled=1
type=rpm
repo_gpgcheck=1
gpgcheck=0
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
`,
},
{
desc: "change track",
before: `
[tailscale-stable]
name=Tailscale stable
baseurl=https://pkgs.tailscale.com/stable/fedora/$basearch
enabled=1
type=rpm
repo_gpgcheck=1
gpgcheck=0
gpgkey=https://pkgs.tailscale.com/stable/fedora/repo.gpg
`,
track: UnstableTrack,
after: `
[tailscale-unstable]
name=Tailscale unstable
baseurl=https://pkgs.tailscale.com/unstable/fedora/$basearch
enabled=1
type=rpm
repo_gpgcheck=1
gpgcheck=0
gpgkey=https://pkgs.tailscale.com/unstable/fedora/repo.gpg
`,
rewrote: true,
},
{
desc: "non-tailscale repo file",
before: `
[fedora]
name=Fedora $releasever - $basearch
#baseurl=http://download.example/pub/fedora/linux/releases/$releasever/Everything/$basearch/os/
metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever&arch=$basearch
enabled=1
countme=1
metadata_expire=7d
repo_gpgcheck=0
type=rpm
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-$basearch
skip_if_unavailable=False
`,
track: StableTrack,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "tailscale.repo")
if err := os.WriteFile(path, []byte(tt.before), 0644); err != nil {
t.Fatal(err)
}
rewrote, err := updateYUMRepoTrack(path, tt.track)
if err == nil && tt.wantErr {
t.Fatal("got nil error, want non-nil")
}
if err != nil && !tt.wantErr {
t.Fatalf("got error %q, want nil", err)
}
if err != nil {
return
}
if rewrote != tt.rewrote {
t.Errorf("got rewrote flag %v, want %v", rewrote, tt.rewrote)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(after) != tt.after {
t.Errorf("got repo file after update:\n%swant:\n%s", after, tt.after)
}
})
}
}
func TestParseAlpinePackageVersion(t *testing.T) {
tests := []struct {
desc string
out string
want string
wantErr bool
}{
{
desc: "valid version",
out: `
tailscale-1.44.2-r0 description:
The easiest, most secure way to use WireGuard and 2FA
tailscale-1.44.2-r0 webpage:
https://tailscale.com/
tailscale-1.44.2-r0 installed size:
32 MiB
`,
want: "1.44.2",
},
{
desc: "wrong package output",
out: `
busybox-1.36.1-r0 description:
Size optimized toolbox of many common UNIX utilities
busybox-1.36.1-r0 webpage:
https://busybox.net/
busybox-1.36.1-r0 installed size:
924 KiB
`,
wantErr: true,
},
{
desc: "missing version",
out: `
tailscale description:
The easiest, most secure way to use WireGuard and 2FA
tailscale webpage:
https://tailscale.com/
tailscale installed size:
32 MiB
`,
wantErr: true,
},
{
desc: "empty output",
out: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
got, err := parseAlpinePackageVersion([]byte(tt.out))
if err == nil && tt.wantErr {
t.Fatalf("got nil error and version %q, want non-nil error", got)
}
if err != nil && !tt.wantErr {
t.Fatalf("got error: %q, want nil", err)
}
if got != tt.want {
t.Fatalf("got version: %q, want %q", got, tt.want)
}
})
}
}
func TestSynoArch(t *testing.T) {
tests := []struct {
goarch string
model string
want string
wantErr bool
}{
{goarch: "amd64", model: "DS224+", want: "x86_64"},
{goarch: "arm64", model: "DS124", want: "armv8"},
{goarch: "386", model: "DS415play", want: "i686"},
{goarch: "arm", model: "DS213air", want: "88f6281"},
{goarch: "arm", model: "NVR1218", want: "hi3535"},
{goarch: "arm", model: "DS1517", want: "alpine"},
{goarch: "arm", model: "DS216se", want: "armada370"},
{goarch: "arm", model: "DS115", want: "armada375"},
{goarch: "arm", model: "DS419slim", want: "armada38x"},
{goarch: "arm", model: "RS815", want: "armadaxp"},
{goarch: "arm", model: "DS414j", want: "comcerto2k"},
{goarch: "arm", model: "DS216play", want: "monaco"},
{goarch: "riscv64", model: "DS999", wantErr: true},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s-%s", tt.goarch, tt.model), func(t *testing.T) {
got, err := synoArch(&tailcfg.Hostinfo{GoArch: tt.goarch, DeviceModel: tt.model})
if err != nil {
if !tt.wantErr {
t.Fatalf("got unexpected error %v", err)
}
return
}
if tt.wantErr {
t.Fatalf("got %q, expected an error", got)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}

View File

@@ -1,28 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Windows-specific stuff that can't go in clientupdate.go because it needs
// x/sys/windows.
package clientupdate
import (
"golang.org/x/sys/windows"
"tailscale.com/util/winutil/authenticode"
)
func init() {
markTempFileFunc = markTempFileWindows
verifyAuthenticode = verifyTailscale
}
func markTempFileWindows(name string) error {
name16 := windows.StringToUTF16Ptr(name)
return windows.MoveFileEx(name16, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT)
}
const certSubjectTailscale = "Tailscale Inc."
func verifyTailscale(path string) error {
return authenticode.Verify(path, certSubjectTailscale)
}

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Program addlicense adds a license header to a file.
// It is intended for use with 'go generate',
@@ -14,24 +15,26 @@ import (
)
var (
year = flag.Int("year", 0, "copyright year")
file = flag.String("file", "", "file to modify")
)
func usage() {
fmt.Fprintf(os.Stderr, `
usage: addlicense -file FILE <subcommand args...>
usage: addlicense -year YEAR -file FILE <subcommand args...>
`[1:])
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, `
addlicense adds a Tailscale license to the beginning of file.
addlicense adds a Tailscale license to the beginning of file,
using year as the copyright year.
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
addlicense -year 2021 -file pull_strings.go stringer -type=pull
`[1:])
os.Exit(2)
}
@@ -51,7 +54,7 @@ func main() {
check(err)
f, err := os.OpenFile(*file, os.O_TRUNC|os.O_WRONLY, 0644)
check(err)
_, err = fmt.Fprint(f, license)
_, err = fmt.Fprintf(f, license, *year)
check(err)
_, err = f.Write(b)
check(err)
@@ -67,7 +70,8 @@ func check(err error) {
}
var license = `
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) %d Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
`[1:]

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Cloner is a tool to automate the creation of a Clone method.
//
@@ -131,8 +132,6 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
} else {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
}
} else if ft.Elem().String() == "encoding/json.RawMessage" {
writef("\tdst.%s[i] = append(src.%s[i][:0:0], src.%s[i]...)", fname, fname, fname)
} else {
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
}

View File

@@ -1,33 +1,144 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build linux
package main
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"tailscale.com/kube"
"tailscale.com/tailcfg"
"tailscale.com/util/multierr"
)
// checkSecretPermissions checks the secret access permissions of the current
// pod. It returns an error if the basic permissions tailscale needs are
// missing, and reports whether the patch permission is additionally present.
//
// Errors encountered during the access checking process are logged, but ignored
// so that the pod tries to fail alive if the permissions exist and there's just
// something wrong with SelfSubjectAccessReviews. There shouldn't be, pods
// should always be able to use SSARs to assess their own permissions, but since
// we didn't use to check permissions this way we'll be cautious in case some
// old version of k8s deviates from the current behavior.
func checkSecretPermissions(ctx context.Context, secretName string) (canPatch bool, err error) {
var errs []error
for _, verb := range []string{"get", "update"} {
ok, err := checkPermission(ctx, verb, secretName)
if err != nil {
log.Printf("error checking %s permission on secret %s: %v", verb, secretName, err)
} else if !ok {
errs = append(errs, fmt.Errorf("missing %s permission on secret %q", verb, secretName))
}
}
if len(errs) > 0 {
return false, multierr.New(errs...)
}
ok, err := checkPermission(ctx, "patch", secretName)
if err != nil {
log.Printf("error checking patch permission on secret %s: %v", secretName, err)
return false, nil
}
return ok, nil
}
// checkPermission reports whether the current pod has permission to use the
// given verb (e.g. get, update, patch) on secretName.
func checkPermission(ctx context.Context, verb, secretName string) (bool, error) {
sar := map[string]any{
"apiVersion": "authorization.k8s.io/v1",
"kind": "SelfSubjectAccessReview",
"spec": map[string]any{
"resourceAttributes": map[string]any{
"namespace": kubeNamespace,
"verb": verb,
"resource": "secrets",
"name": secretName,
},
},
}
bs, err := json.Marshal(sar)
if err != nil {
return false, err
}
req, err := http.NewRequest("POST", "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", bytes.NewReader(bs))
if err != nil {
return false, err
}
resp, err := doKubeRequest(ctx, req)
if err != nil {
return false, err
}
defer resp.Body.Close()
bs, err = io.ReadAll(resp.Body)
if err != nil {
return false, err
}
var res struct {
Status struct {
Allowed bool `json:"allowed"`
} `json:"status"`
}
if err := json.Unmarshal(bs, &res); err != nil {
return false, err
}
return res.Status.Allowed, nil
}
// findKeyInKubeSecret inspects the kube secret secretName for a data
// field called "authkey", and returns its value if present.
func findKeyInKubeSecret(ctx context.Context, secretName string) (string, error) {
s, err := kc.GetSecret(ctx, secretName)
req, err := http.NewRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", kubeNamespace, secretName), nil)
if err != nil {
return "", err
}
ak, ok := s.Data["authkey"]
if !ok {
return "", nil
resp, err := doKubeRequest(ctx, req)
if err != nil {
if resp != nil && resp.StatusCode == http.StatusNotFound {
// Kube secret doesn't exist yet, can't have an authkey.
return "", nil
}
return "", err
}
return string(ak), nil
defer resp.Body.Close()
bs, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
// We use a map[string]any here rather than import corev1.Secret,
// because we only do very limited things to the secret, and
// importing corev1 adds 12MiB to the compiled binary.
var s map[string]any
if err := json.Unmarshal(bs, &s); err != nil {
return "", err
}
if d, ok := s["data"].(map[string]any); ok {
if v, ok := d["authkey"].(string); ok {
bs, err := base64.StdEncoding.DecodeString(v)
if err != nil {
return "", err
}
return string(bs), nil
}
}
return "", nil
}
// storeDeviceInfo writes deviceID into the "device_id" data field of the kube
@@ -35,38 +146,65 @@ func findKeyInKubeSecret(ctx context.Context, secretName string) (string, error)
func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID, fqdn string) error {
// First check if the secret exists at all. Even if running on
// kubernetes, we do not necessarily store state in a k8s secret.
if _, err := kc.GetSecret(ctx, secretName); err != nil {
if s, ok := err.(*kube.Status); ok {
if s.Code >= 400 && s.Code <= 499 {
// Assume the secret doesn't exist, or we don't have
// permission to access it.
return nil
}
req, err := http.NewRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", kubeNamespace, secretName), nil)
if err != nil {
return err
}
resp, err := doKubeRequest(ctx, req)
if err != nil {
if resp != nil && resp.StatusCode >= 400 && resp.StatusCode <= 499 {
// Assume the secret doesn't exist, or we don't have
// permission to access it.
return nil
}
return err
}
m := &kube.Secret{
Data: map[string][]byte{
"device_id": []byte(deviceID),
"device_fqdn": []byte(fqdn),
m := map[string]map[string]string{
"stringData": {
"device_id": string(deviceID),
"device_fqdn": fqdn,
},
}
return kc.StrategicMergePatchSecret(ctx, secretName, m, "tailscale-container")
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(m); err != nil {
return err
}
req, err = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s?fieldManager=tailscale-container", kubeNamespace, secretName), &b)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/strategic-merge-patch+json")
if _, err := doKubeRequest(ctx, req); err != nil {
return err
}
return nil
}
// deleteAuthKey deletes the 'authkey' field of the given kube
// secret. No-op if there is no authkey in the secret.
func deleteAuthKey(ctx context.Context, secretName string) error {
// m is a JSON Patch data structure, see https://jsonpatch.com/ or RFC 6902.
m := []kube.JSONPatch{
m := []struct {
Op string `json:"op"`
Path string `json:"path"`
}{
{
Op: "remove",
Path: "/data/authkey",
},
}
if err := kc.JSONPatchSecret(ctx, secretName, m); err != nil {
if s, ok := err.(*kube.Status); ok && s.Code == http.StatusUnprocessableEntity {
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(m); err != nil {
return err
}
req, err := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s?fieldManager=tailscale-container", kubeNamespace, secretName), &b)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json-patch+json")
if resp, err := doKubeRequest(ctx, req); err != nil {
if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity {
// This is kubernetes-ese for "the field you asked to
// delete already doesn't exist", aka no-op.
return nil
@@ -76,22 +214,65 @@ func deleteAuthKey(ctx context.Context, secretName string) error {
return nil
}
var kc *kube.Client
var (
kubeHost string
kubeNamespace string
kubeToken string
kubeHTTP *http.Transport
)
func initKube(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)
// If running in Kubernetes, set things up so that doKubeRequest
// can talk successfully to the kube apiserver.
if os.Getenv("KUBERNETES_SERVICE_HOST") == "" {
return
}
var err error
kc, err = kube.New()
kubeHost = os.Getenv("KUBERNETES_SERVICE_HOST") + ":" + os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")
bs, err := os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/namespace"))
if err != nil {
log.Fatalf("Error creating kube client: %v", err)
log.Fatalf("Error reading kube namespace: %v", err)
}
if root != "/" {
// If we are running in a test, we need to set the URL to the
// httptest server.
kc.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
kubeNamespace = strings.TrimSpace(string(bs))
bs, err = os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/token"))
if err != nil {
log.Fatalf("Error reading kube token: %v", err)
}
kubeToken = strings.TrimSpace(string(bs))
bs, err = os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/ca.crt"))
if err != nil {
log.Fatalf("Error reading kube CA cert: %v", err)
}
cp := x509.NewCertPool()
cp.AppendCertsFromPEM(bs)
kubeHTTP = &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: cp,
},
IdleConnTimeout: time.Second,
}
}
// doKubeRequest sends r to the kube apiserver.
func doKubeRequest(ctx context.Context, r *http.Request) (*http.Response, error) {
if kubeHTTP == nil {
panic("not in kubernetes")
}
r.URL.Scheme = "https"
r.URL.Host = kubeHost
r.Header.Set("Authorization", "Bearer "+kubeToken)
r.Header.Set("Accept", "application/json")
resp, err := kubeHTTP.RoundTrip(r)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return resp, fmt.Errorf("got non-200/201 status code %d", resp.StatusCode)
}
return resp, nil
}

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build linux
@@ -11,8 +12,7 @@
// As with most container things, configuration is passed through environment
// variables. All configuration is optional.
//
// - TS_AUTHKEY: the authkey to use for login.
// - TS_HOSTNAME: the hostname to request for the node.
// - TS_AUTH_KEY: the authkey to use for login.
// - TS_ROUTES: subnet routes to advertise.
// - TS_DEST_IP: proxy all incoming Tailscale traffic to the given
// destination.
@@ -42,7 +42,7 @@
// TS_KUBE_SECRET="" and TS_STATE_DIR=/path/to/storage/dir. The state dir should
// be persistent storage.
//
// Additionally, if TS_AUTHKEY is not set and the TS_KUBE_SECRET contains an
// Additionally, if TS_AUTH_KEY is not set and the TS_KUBE_SECRET contains an
// "authkey" field, that key is used as the tailscale authkey.
package main
@@ -73,8 +73,7 @@ func main() {
tailscale.I_Acknowledge_This_API_Is_Unstable = true
cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
AuthKey: defaultEnv("TS_AUTH_KEY", ""),
Routes: defaultEnv("TS_ROUTES", ""),
ProxyTo: defaultEnv("TS_DEST_IP", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
@@ -123,7 +122,7 @@ func main() {
defer cancel()
if cfg.InKubernetes && cfg.KubeSecret != "" {
canPatch, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret)
canPatch, err := checkSecretPermissions(ctx, cfg.KubeSecret)
if err != nil {
log.Fatalf("Some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
}
@@ -356,11 +355,7 @@ 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
args = append(args, "--state=kube:"+cfg.KubeSecret, "--statedir=/tmp")
case cfg.StateDir != "":
args = append(args, "--statedir="+cfg.StateDir)
default:
@@ -399,9 +394,6 @@ func tailscaleUp(ctx context.Context, cfg *settings) error {
if cfg.Routes != "" {
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)...)
}
@@ -530,7 +522,6 @@ func installIPTablesRule(ctx context.Context, dstStr string, tsIPs []netip.Prefi
// settings is all the configuration for containerboot.
type settings struct {
AuthKey string
Hostname string
Routes string
ProxyTo string
DaemonExtraArgs string
@@ -557,15 +548,6 @@ func defaultEnv(name, defVal string) string {
return defVal
}
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 {

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build linux
@@ -145,24 +146,6 @@ func TestContainerBoot(t *testing.T) {
{
// Userspace mode, ephemeral storage, authkey provided on every run.
Name: "authkey",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
},
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 --authkey=tskey-key",
},
},
{
Notify: runningNotify,
},
},
},
{
// Userspace mode, ephemeral storage, authkey provided on every run.
Name: "authkey-old-flag",
Env: map[string]string{
"TS_AUTH_KEY": "tskey-key",
},
@@ -181,7 +164,7 @@ func TestContainerBoot(t *testing.T) {
{
Name: "authkey_disk_state",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_KEY": "tskey-key",
"TS_STATE_DIR": filepath.Join(d, "tmp"),
},
Phases: []phase{
@@ -199,8 +182,8 @@ func TestContainerBoot(t *testing.T) {
{
Name: "routes",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
"TS_AUTH_KEY": "tskey-key",
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
},
Phases: []phase{
{
@@ -221,7 +204,7 @@ func TestContainerBoot(t *testing.T) {
{
Name: "routes_kernel_ipv4",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_KEY": "tskey-key",
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
"TS_USERSPACE": "false",
},
@@ -244,7 +227,7 @@ func TestContainerBoot(t *testing.T) {
{
Name: "routes_kernel_ipv6",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_KEY": "tskey-key",
"TS_ROUTES": "::/64,1::/64",
"TS_USERSPACE": "false",
},
@@ -267,7 +250,7 @@ func TestContainerBoot(t *testing.T) {
{
Name: "routes_kernel_all_families",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_KEY": "tskey-key",
"TS_ROUTES": "::/64,1.2.3.0/24",
"TS_USERSPACE": "false",
},
@@ -290,7 +273,7 @@ func TestContainerBoot(t *testing.T) {
{
Name: "proxy",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_KEY": "tskey-key",
"TS_DEST_IP": "1.2.3.4",
"TS_USERSPACE": "false",
},
@@ -312,7 +295,7 @@ func TestContainerBoot(t *testing.T) {
{
Name: "authkey_once",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_KEY": "tskey-key",
"TS_AUTH_ONCE": "true",
},
Phases: []phase{
@@ -371,7 +354,7 @@ func TestContainerBoot(t *testing.T) {
// Explicitly set to an empty value, to override the default of "tailscale".
"TS_KUBE_SECRET": "",
"TS_STATE_DIR": filepath.Join(d, "tmp"),
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_KEY": "tskey-key",
},
KubeSecret: map[string]string{},
Phases: []phase{
@@ -393,7 +376,7 @@ func TestContainerBoot(t *testing.T) {
Env: map[string]string{
"KUBERNETES_SERVICE_HOST": kube.Host,
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
"TS_AUTHKEY": "tskey-key",
"TS_AUTH_KEY": "tskey-key",
},
KubeSecret: map[string]string{},
KubeDenyPatch: true,
@@ -549,22 +532,6 @@ func TestContainerBoot(t *testing.T) {
},
},
},
{
Name: "hostname",
Env: map[string]string{
"TS_HOSTNAME": "my-server",
},
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 --hostname=my-server",
},
}, {
Notify: runningNotify,
},
},
},
}
for _, test := range tests {
@@ -607,7 +574,7 @@ func TestContainerBoot(t *testing.T) {
}()
var wantCmds []string
for i, p := range test.Phases {
for _, p := range test.Phases {
lapi.Notify(p.Notify)
wantCmds = append(wantCmds, p.WantCmds...)
waitArgs(t, 2*time.Second, d, argFile, strings.Join(wantCmds, "\n"))
@@ -626,7 +593,7 @@ func TestContainerBoot(t *testing.T) {
return nil
})
if err != nil {
t.Fatalf("phase %d: %v", i, err)
t.Fatal(err)
}
err = tstest.WaitFor(2*time.Second, func() error {
for path, want := range p.WantFiles {
@@ -983,13 +950,13 @@ func (k *kubeServer) serveSecret(w http.ResponseWriter, r *http.Request) {
}
case "application/strategic-merge-patch+json":
req := struct {
Data map[string][]byte `json:"data"`
Data map[string]string `json:"stringData"`
}{}
if err := json.Unmarshal(bs, &req); err != nil {
panic(fmt.Sprintf("json decode failed: %v. Body:\n\n%s", err, string(bs)))
}
for key, val := range req.Data {
k.secret[key] = string(val)
k.secret[key] = val
}
default:
panic(fmt.Sprintf("unknown content type %q", r.Header.Get("Content-Type")))

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
@@ -14,7 +15,6 @@ import (
"time"
"tailscale.com/syncs"
"tailscale.com/util/slicesx"
)
const refreshTimeout = time.Minute
@@ -53,13 +53,6 @@ func refreshBootstrapDNS() {
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
defer cancel()
dnsEntries := resolveList(ctx, strings.Split(*bootstrapDNS, ","))
// Randomize the order of the IPs for each name to avoid the client biasing
// to IPv6
for k := range dnsEntries {
ips := dnsEntries[k]
slicesx.Shuffle(ips)
dnsEntries[k] = ips
}
j, err := json.MarshalIndent(dnsEntries, "", "\t")
if err != nil {
// leave the old values in place

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
@@ -11,12 +12,14 @@ import (
"net/url"
"reflect"
"testing"
"tailscale.com/tstest"
)
func BenchmarkHandleBootstrapDNS(b *testing.B) {
tstest.Replace(b, bootstrapDNS, "log.tailscale.io,login.tailscale.com,controlplane.tailscale.com,login.us.tailscale.com")
prev := *bootstrapDNS
*bootstrapDNS = "log.tailscale.io,login.tailscale.com,controlplane.tailscale.com,login.us.tailscale.com"
defer func() {
*bootstrapDNS = prev
}()
refreshBootstrapDNS()
w := new(bitbucketResponseWriter)
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape("log.tailscale.io"), nil)

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
@@ -81,7 +82,7 @@ func (m *manualCertManager) TLSConfig() *tls.Config {
return &tls.Config{
Certificates: nil,
NextProtos: []string{
"http/1.1",
"h2", "http/1.1", // enable HTTP/2
},
GetCertificate: m.getCertificate,
}

View File

@@ -3,80 +3,26 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus
filippo.io/edwards25519/field from filippo.io/edwards25519
W 💣 github.com/Microsoft/go-winio from tailscale.com/safesocket
W 💣 github.com/Microsoft/go-winio/internal/fs from github.com/Microsoft/go-winio
W 💣 github.com/Microsoft/go-winio/internal/socket from github.com/Microsoft/go-winio
W github.com/Microsoft/go-winio/internal/stringbuffer from github.com/Microsoft/go-winio/internal/fs
W github.com/Microsoft/go-winio/pkg/guid from github.com/Microsoft/go-winio+
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/golang/protobuf/proto from github.com/matttproud/golang_protobuf_extensions/pbutil+
L github.com/google/nftables from tailscale.com/util/linuxfw
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
L 💣 github.com/google/nftables/binaryutil from github.com/google/nftables+
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/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/interfaces+
LW github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
github.com/klauspost/compress/flate from nhooyr.io/websocket
github.com/matttproud/golang_protobuf_extensions/pbutil from github.com/prometheus/common/expfmt
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
L github.com/mdlayher/netlink/nltest from github.com/google/nftables
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz
github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus
github.com/prometheus/client_model/go from github.com/prometheus/client_golang/prometheus+
github.com/prometheus/common/expfmt from github.com/prometheus/client_golang/prometheus+
github.com/prometheus/common/internal/bitbucket.org/ww/goautoneg from github.com/prometheus/common/expfmt
github.com/prometheus/common/model from github.com/prometheus/client_golang/prometheus+
LD github.com/prometheus/procfs from github.com/prometheus/client_golang/prometheus
LD github.com/prometheus/procfs/internal/fs from github.com/prometheus/procfs
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
L 💣 github.com/tailscale/netlink from tailscale.com/util/linuxfw
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+
go4.org/netipx from tailscale.com/wgengine/filter+
go4.org/netipx from tailscale.com/wgengine/filter
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
google.golang.org/protobuf/encoding/prototext from github.com/golang/protobuf/proto+
google.golang.org/protobuf/encoding/protowire from github.com/golang/protobuf/proto+
google.golang.org/protobuf/internal/descfmt from google.golang.org/protobuf/internal/filedesc
google.golang.org/protobuf/internal/descopts from google.golang.org/protobuf/internal/filedesc+
google.golang.org/protobuf/internal/detrand from google.golang.org/protobuf/internal/descfmt+
google.golang.org/protobuf/internal/encoding/defval from google.golang.org/protobuf/internal/encoding/tag+
google.golang.org/protobuf/internal/encoding/messageset from google.golang.org/protobuf/encoding/prototext+
google.golang.org/protobuf/internal/encoding/tag from google.golang.org/protobuf/internal/impl
google.golang.org/protobuf/internal/encoding/text from google.golang.org/protobuf/encoding/prototext+
google.golang.org/protobuf/internal/errors from google.golang.org/protobuf/encoding/prototext+
google.golang.org/protobuf/internal/filedesc from google.golang.org/protobuf/internal/encoding/tag+
google.golang.org/protobuf/internal/filetype from google.golang.org/protobuf/runtime/protoimpl
google.golang.org/protobuf/internal/flags from google.golang.org/protobuf/encoding/prototext+
google.golang.org/protobuf/internal/genid from google.golang.org/protobuf/encoding/prototext+
💣 google.golang.org/protobuf/internal/impl from google.golang.org/protobuf/internal/filetype+
google.golang.org/protobuf/internal/order from google.golang.org/protobuf/encoding/prototext+
google.golang.org/protobuf/internal/pragma from google.golang.org/protobuf/encoding/prototext+
google.golang.org/protobuf/internal/set from google.golang.org/protobuf/encoding/prototext
💣 google.golang.org/protobuf/internal/strs from google.golang.org/protobuf/encoding/prototext+
google.golang.org/protobuf/internal/version from google.golang.org/protobuf/runtime/protoimpl
google.golang.org/protobuf/proto from github.com/golang/protobuf/proto+
google.golang.org/protobuf/reflect/protodesc from github.com/golang/protobuf/proto
💣 google.golang.org/protobuf/reflect/protoreflect from github.com/golang/protobuf/proto+
google.golang.org/protobuf/reflect/protoregistry from github.com/golang/protobuf/proto+
google.golang.org/protobuf/runtime/protoiface from github.com/golang/protobuf/proto+
google.golang.org/protobuf/runtime/protoimpl from github.com/golang/protobuf/proto+
google.golang.org/protobuf/types/descriptorpb from google.golang.org/protobuf/reflect/protodesc
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/xsync from nhooyr.io/websocket
@@ -88,7 +34,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/derp/derphttp from tailscale.com/cmd/derper
tailscale.com/disco from tailscale.com/derp
tailscale.com/envknob from tailscale.com/derp+
tailscale.com/health from tailscale.com/net/tlsdial
tailscale.com/hostinfo from tailscale.com/net/interfaces+
tailscale.com/ipn from tailscale.com/client/tailscale
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
@@ -98,13 +43,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
💣 tailscale.com/net/interfaces from tailscale.com/net/netns+
tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netknob from tailscale.com/net/netns
tailscale.com/net/netmon from tailscale.com/net/sockstats+
tailscale.com/net/netns from tailscale.com/derp/derphttp
tailscale.com/net/netutil from tailscale.com/client/tailscale
tailscale.com/net/packet from tailscale.com/wgengine/filter
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
tailscale.com/net/stun 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/tsaddr from tailscale.com/ipn+
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
@@ -115,41 +57,31 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tstime from tailscale.com/derp+
tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/wgengine/filter+
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
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/empty from tailscale.com/ipn
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
tailscale.com/types/key from tailscale.com/cmd/derper+
tailscale.com/types/lazy from tailscale.com/version+
tailscale.com/types/logger from tailscale.com/cmd/derper+
tailscale.com/types/netmap from tailscale.com/ipn
tailscale.com/types/opt from tailscale.com/client/tailscale+
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/ptr from tailscale.com/hostinfo
tailscale.com/types/structs from tailscale.com/ipn+
tailscale.com/types/tkatype from tailscale.com/types/key+
tailscale.com/types/views from tailscale.com/ipn/ipnstate+
W tailscale.com/util/clientmetric from tailscale.com/net/tshttpproxy
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
tailscale.com/util/cmpx from tailscale.com/cmd/derper+
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/httpm from tailscale.com/client/tailscale
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/syncs+
tailscale.com/util/multierr from tailscale.com/health+
tailscale.com/util/set from tailscale.com/health+
tailscale.com/util/mak from tailscale.com/syncs
tailscale.com/util/singleflight from tailscale.com/net/dnscache
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
tailscale.com/util/vizerror from tailscale.com/tsweb
tailscale.com/util/strs from tailscale.com/hostinfo+
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/hostinfo+
@@ -163,18 +95,17 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
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/curve25519 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/exp/constraints from golang.org/x/exp/slices
golang.org/x/exp/maps from tailscale.com/types/views
golang.org/x/exp/slices from tailscale.com/net/tsaddr+
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
golang.org/x/net/http/httpproxy from net/http+
golang.org/x/net/http/httpproxy from net/http
golang.org/x/net/http2/hpack from net/http
golang.org/x/net/idna from golang.org/x/crypto/acme/autocert+
golang.org/x/net/proxy from tailscale.com/net/netns
@@ -202,7 +133,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
crypto/cipher from crypto/aes+
crypto/des from crypto/tls+
crypto/dsa from crypto/x509
crypto/ecdh from crypto/ecdsa+
crypto/ecdsa from crypto/tls+
crypto/ed25519 from crypto/tls+
crypto/elliptic from crypto/ecdsa+
@@ -231,17 +161,14 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
expvar 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+
hash/crc32 from compress/gzip+
hash/fnv from google.golang.org/protobuf/internal/detrand
hash/maphash from go4.org/mem
html from net/http/pprof+
io from bufio+
io/fs from crypto/x509+
io/ioutil from github.com/mitchellh/go-ps+
log from expvar+
log/internal from log
math from compress/flate+
math/big from crypto/dsa+
math/bits from compress/flate+
@@ -253,7 +180,7 @@ 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+
@@ -266,7 +193,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
regexp from internal/profile+
regexp/syntax from regexp
runtime/debug from golang.org/x/crypto/acme+
runtime/metrics from github.com/prometheus/client_golang/prometheus+
runtime/pprof from net/http/pprof
runtime/trace from net/http/pprof
sort from compress/flate+

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// The derper binary is a simple DERP server.
package main // import "tailscale.com/cmd/derper"
@@ -33,12 +34,11 @@ import (
"tailscale.com/net/stun"
"tailscale.com/tsweb"
"tailscale.com/types/key"
"tailscale.com/util/cmpx"
)
var (
dev = flag.Bool("dev", false, "run in localhost development mode (overrides -a)")
addr = flag.String("a", ":443", "server HTTP/HTTPS listen address, in form \":port\", \"ip:port\", or for IPv6 \"[ip]:port\". If the IP is omitted, it defaults to all interfaces. Serves HTTPS if the port is 443 and/or -certmode is manual, otherwise HTTP.")
dev = flag.Bool("dev", false, "run in localhost development mode")
addr = flag.String("a", ":443", "server HTTPS listen address, in form \":port\", \"ip:port\", or for IPv6 \"[ip]:port\". If the IP is omitted, it defaults to all interfaces.")
httpPort = flag.Int("http-port", 80, "The port on which to serve HTTP. Set to -1 to disable. The listener is bound to the same IP (if any) as specified in the -a flag.")
stunPort = flag.Int("stun-port", 3478, "The UDP port on which to serve STUN. The listener is bound to the same IP (if any) as specified in the -a flag.")
configPath = flag.String("c", "", "config file path")
@@ -182,9 +182,8 @@ func main() {
}
mux.HandleFunc("/derp/probe", probeHandler)
go refreshBootstrapDNSLoop()
mux.HandleFunc("/bootstrap-dns", tsweb.BrowserHeaderHandlerFunc(handleBootstrapDNS))
mux.HandleFunc("/bootstrap-dns", handleBootstrapDNS)
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tsweb.AddBrowserHeaders(w)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
io.WriteString(w, `<html><body>
@@ -204,7 +203,6 @@ func main() {
}
}))
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(serveNoContent))
@@ -279,6 +277,18 @@ func main() {
defer tlsActiveVersion.Add(label, -1)
}
// Set HTTP headers to appease automated security scanners.
//
// Security automation gets cranky when HTTPS sites don't
// set HSTS, and when they don't specify a content
// security policy for XSS mitigation.
//
// DERP's HTTP interface is only ever used for debug
// access (for which trivial safe policies work just
// fine), and by DERP clients which don't obey any of
// these browser-centric headers anyway.
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'self'; block-all-mixed-content; plugin-types 'none'")
mux.ServeHTTP(w, r)
})
if *httpPort > -1 {
@@ -427,7 +437,11 @@ func defaultMeshPSKFile() string {
}
func rateLimitedListenAndServeTLS(srv *http.Server) error {
ln, err := net.Listen("tcp", cmpx.Or(srv.Addr, ":https"))
addr := srv.Addr
if addr == "" {
addr = ":https"
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
@@ -9,7 +10,6 @@ import (
"fmt"
"log"
"net"
"net/netip"
"strings"
"time"
@@ -17,6 +17,7 @@ import (
"tailscale.com/derp/derphttp"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/util/strs"
)
func startMesh(s *derp.Server) error {
@@ -50,7 +51,7 @@ func startMeshWithHost(s *derp.Server, host string) error {
}
var d net.Dialer
var r net.Resolver
if base, ok := strings.CutSuffix(host, ".tailscale.com"); ok && port == "443" {
if base, ok := strs.CutSuffix(host, ".tailscale.com"); ok && port == "443" {
subCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
vpcHost := base + "-vpc.tailscale.com"
@@ -68,7 +69,7 @@ func startMeshWithHost(s *derp.Server, host string) error {
return d.DialContext(ctx, network, addr)
})
add := func(k key.NodePublic, _ netip.AddrPort) { s.AddPacketForwarder(k, c) }
add := func(k key.NodePublic) { s.AddPacketForwarder(k, c) }
remove := func(k key.NodePublic) { s.RemovePacketForwarder(k, c) }
go c.RunWatchConnectionLoop(context.Background(), s.PublicKey(), logf, add, remove)
return nil

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main

View File

@@ -1,46 +1,74 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// The derpprobe binary probes derpers.
package main
package main // import "tailscale.com/cmd/derper/derpprobe"
import (
"bytes"
"context"
crand "crypto/rand"
"crypto/x509"
"encoding/json"
"errors"
"flag"
"fmt"
"html"
"io"
"log"
"net"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"tailscale.com/prober"
"tailscale.com/tsweb"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/net/stun"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
var (
derpMapURL = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map (https:// or file://)")
listen = flag.String("listen", ":8030", "HTTP listen address")
probeOnce = flag.Bool("once", false, "probe once and print results, then exit; ignores the listen flag")
spread = flag.Bool("spread", true, "whether to spread probing over time")
interval = flag.Duration("interval", 15*time.Second, "probe interval")
)
// certReissueAfter is the time after which we expect all certs to be
// reissued, at minimum.
//
// This is currently set to the date of the LetsEncrypt ALPN revocation event of Jan 2022:
// https://community.letsencrypt.org/t/questions-about-renewing-before-tls-alpn-01-revocations/170449
//
// If there's another revocation event, bump this again.
var certReissueAfter = time.Unix(1643226768, 0)
var (
mu sync.Mutex
state = map[nodePair]pairStatus{}
lastDERPMap *tailcfg.DERPMap
lastDERPMapAt time.Time
certs = map[string]*x509.Certificate{}
)
func main() {
flag.Parse()
p := prober.New().WithSpread(*spread).WithOnce(*probeOnce).WithMetricNamespace("derpprobe")
dp, err := prober.DERP(p, *derpMapURL, *interval, *interval, *interval)
if err != nil {
log.Fatal(err)
}
p.Run("derpmap-probe", *interval, nil, dp.ProbeMap)
// proactively load the DERP map. Nothing terrible happens if this fails, so we ignore
// the error. The Slack bot will print a notification that the DERP map was empty.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, _ = getDERPMap(ctx)
if *probeOnce {
log.Printf("Waiting for all probes (may take up to 1m)")
p.Wait()
st := getOverallStatus(p)
log.Printf("Starting probe (may take up to 1m)")
probe()
log.Printf("Probe results:")
st := getOverallStatus()
for _, s := range st.good {
log.Printf("good: %s", s)
}
@@ -50,10 +78,15 @@ func main() {
return
}
mux := http.NewServeMux()
tsweb.Debugger(mux)
mux.HandleFunc("/", http.HandlerFunc(serveFunc(p)))
log.Fatal(http.ListenAndServe(*listen, mux))
go probeLoop()
go slackLoop()
log.Fatal(http.ListenAndServe(*listen, http.HandlerFunc(serve)))
}
func setCert(name string, cert *x509.Certificate) {
mu.Lock()
defer mu.Unlock()
certs[name] = cert
}
type overallStatus struct {
@@ -68,43 +101,471 @@ func (st *overallStatus) addGoodf(format string, a ...any) {
st.good = append(st.good, fmt.Sprintf(format, a...))
}
func getOverallStatus(p *prober.Prober) (o overallStatus) {
for p, i := range p.ProbeInfo() {
if i.End.IsZero() {
// Do not show probes that have not finished yet.
continue
}
if i.Result {
o.addGoodf("%s: %s", p, i.Latency)
} else {
o.addBadf("%s: %s", p, i.Error)
func getOverallStatus() (o overallStatus) {
mu.Lock()
defer mu.Unlock()
if lastDERPMap == nil {
o.addBadf("no DERP map")
return
}
now := time.Now()
if age := now.Sub(lastDERPMapAt); age > time.Minute {
o.addBadf("DERPMap hasn't been successfully refreshed in %v", age.Round(time.Second))
}
addPairMeta := func(pair nodePair) {
st, ok := state[pair]
age := now.Sub(st.at).Round(time.Second)
switch {
case !ok:
o.addBadf("no state for %v", pair)
case st.err != nil:
o.addBadf("%v: %v", pair, st.err)
case age > 90*time.Second:
o.addBadf("%v: update is %v old", pair, age)
default:
o.addGoodf("%v: %v, %v ago", pair, st.latency.Round(time.Millisecond), age)
}
}
sort.Strings(o.bad)
sort.Strings(o.good)
for _, reg := range sortedRegions(lastDERPMap) {
for _, from := range reg.Nodes {
addPairMeta(nodePair{"UDP", from.Name})
for _, to := range reg.Nodes {
addPairMeta(nodePair{from.Name, to.Name})
}
}
}
var subjs []string
for k := range certs {
subjs = append(subjs, k)
}
sort.Strings(subjs)
soon := time.Now().Add(14 * 24 * time.Hour) // in 2 weeks; autocert does 30 days by default
for _, s := range subjs {
cert := certs[s]
if cert.NotBefore.Before(certReissueAfter) {
o.addBadf("cert %q needs reissuing; NotBefore=%v", s, cert.NotBefore.Format(time.RFC3339))
continue
}
if cert.NotAfter.Before(soon) {
o.addBadf("cert %q expiring soon (%v); wasn't auto-refreshed", s, cert.NotAfter.Format(time.RFC3339))
continue
}
o.addGoodf("cert %q good %v - %v", s, cert.NotBefore.Format(time.RFC3339), cert.NotAfter.Format(time.RFC3339))
}
return
}
func serveFunc(p *prober.Prober) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
st := getOverallStatus(p)
summary := "All good"
if (float64(len(st.bad)) / float64(len(st.bad)+len(st.good))) > 0.25 {
// Returning a 500 allows monitoring this server externally and configuring
// an alert on HTTP response code.
w.WriteHeader(500)
summary = fmt.Sprintf("%d problems", len(st.bad))
func serve(w http.ResponseWriter, r *http.Request) {
st := getOverallStatus()
summary := "All good"
if (float64(len(st.bad)) / float64(len(st.bad)+len(st.good))) > 0.25 {
// This will generate an alert and page a human.
// It also ends up in Slack, but as part of the alert handling pipeline not
// because we generated a Slack notification from here.
w.WriteHeader(500)
summary = fmt.Sprintf("%d problems", len(st.bad))
}
io.WriteString(w, "<html><head><style>.bad { font-weight: bold; color: #700; }</style></head>\n")
fmt.Fprintf(w, "<body><h1>derp probe</h1>\n%s:<ul>", summary)
for _, s := range st.bad {
fmt.Fprintf(w, "<li class=bad>%s</li>\n", html.EscapeString(s))
}
for _, s := range st.good {
fmt.Fprintf(w, "<li>%s</li>\n", html.EscapeString(s))
}
io.WriteString(w, "</ul></body></html>\n")
}
func notifySlack(text string) error {
type SlackRequestBody struct {
Text string `json:"text"`
}
slackBody, err := json.Marshal(SlackRequestBody{Text: text})
if err != nil {
return err
}
webhookUrl := os.Getenv("SLACK_WEBHOOK")
if webhookUrl == "" {
return errors.New("No SLACK_WEBHOOK configured")
}
req, err := http.NewRequest("POST", webhookUrl, bytes.NewReader(slackBody))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return errors.New(resp.Status)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "ok" {
return errors.New("Non-ok response returned from Slack")
}
return nil
}
// We only page a human if it looks like there is a significant outage across multiple regions.
// To Slack, we report all failures great and small.
func slackLoop() {
inBadState := false
for {
time.Sleep(time.Second * 30)
st := getOverallStatus()
if len(st.bad) > 0 && !inBadState {
err := notifySlack(strings.Join(st.bad, "\n"))
if err == nil {
inBadState = true
} else {
log.Printf("%d problems, notify Slack failed: %v", len(st.bad), err)
}
}
io.WriteString(w, "<html><head><style>.bad { font-weight: bold; color: #700; }</style></head>\n")
fmt.Fprintf(w, "<body><h1>derp probe</h1>\n%s:<ul>", summary)
for _, s := range st.bad {
fmt.Fprintf(w, "<li class=bad>%s</li>\n", html.EscapeString(s))
if len(st.bad) == 0 && inBadState {
err := notifySlack("All DERPs recovered.")
if err == nil {
inBadState = false
}
}
for _, s := range st.good {
fmt.Fprintf(w, "<li>%s</li>\n", html.EscapeString(s))
}
io.WriteString(w, "</ul></body></html>\n")
}
}
func sortedRegions(dm *tailcfg.DERPMap) []*tailcfg.DERPRegion {
ret := make([]*tailcfg.DERPRegion, 0, len(dm.Regions))
for _, r := range dm.Regions {
ret = append(ret, r)
}
sort.Slice(ret, func(i, j int) bool { return ret[i].RegionID < ret[j].RegionID })
return ret
}
type nodePair struct {
from string // DERPNode.Name, or "UDP" for a STUN query to 'to'
to string // DERPNode.Name
}
func (p nodePair) String() string { return fmt.Sprintf("(%s→%s)", p.from, p.to) }
type pairStatus struct {
err error
latency time.Duration
at time.Time
}
func setDERPMap(dm *tailcfg.DERPMap) {
mu.Lock()
defer mu.Unlock()
lastDERPMap = dm
lastDERPMapAt = time.Now()
}
func setState(p nodePair, latency time.Duration, err error) {
mu.Lock()
defer mu.Unlock()
st := pairStatus{
err: err,
latency: latency,
at: time.Now(),
}
state[p] = st
if err != nil {
log.Printf("%+v error: %v", p, err)
} else {
log.Printf("%+v: %v", p, latency.Round(time.Millisecond))
}
}
func probeLoop() {
ticker := time.NewTicker(15 * time.Second)
for {
err := probe()
if err != nil {
log.Printf("probe: %v", err)
}
<-ticker.C
}
}
func probe() error {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
dm, err := getDERPMap(ctx)
if err != nil {
return err
}
var wg sync.WaitGroup
wg.Add(len(dm.Regions))
for _, reg := range dm.Regions {
reg := reg
go func() {
defer wg.Done()
for _, from := range reg.Nodes {
latency, err := probeUDP(ctx, dm, from)
setState(nodePair{"UDP", from.Name}, latency, err)
for _, to := range reg.Nodes {
latency, err := probeNodePair(ctx, dm, from, to)
setState(nodePair{from.Name, to.Name}, latency, err)
}
}
}()
}
wg.Wait()
return ctx.Err()
}
func probeUDP(ctx context.Context, dm *tailcfg.DERPMap, n *tailcfg.DERPNode) (latency time.Duration, err error) {
pc, err := net.ListenPacket("udp", ":0")
if err != nil {
return 0, err
}
defer pc.Close()
uc := pc.(*net.UDPConn)
tx := stun.NewTxID()
req := stun.Request(tx)
for _, ipStr := range []string{n.IPv4, n.IPv6} {
if ipStr == "" {
continue
}
port := n.STUNPort
if port == -1 {
continue
}
if port == 0 {
port = 3478
}
for {
ip := net.ParseIP(ipStr)
_, err := uc.WriteToUDP(req, &net.UDPAddr{IP: ip, Port: port})
if err != nil {
return 0, err
}
buf := make([]byte, 1500)
uc.SetReadDeadline(time.Now().Add(2 * time.Second))
t0 := time.Now()
n, _, err := uc.ReadFromUDP(buf)
d := time.Since(t0)
if err != nil {
if ctx.Err() != nil {
return 0, fmt.Errorf("timeout reading from %v: %v", ip, err)
}
if d < time.Second {
return 0, fmt.Errorf("error reading from %v: %v", ip, err)
}
time.Sleep(100 * time.Millisecond)
continue
}
txBack, _, err := stun.ParseResponse(buf[:n])
if err != nil {
return 0, fmt.Errorf("parsing STUN response from %v: %v", ip, err)
}
if txBack != tx {
return 0, fmt.Errorf("read wrong tx back from %v", ip)
}
if latency == 0 || d < latency {
latency = d
}
break
}
}
return latency, nil
}
func probeNodePair(ctx context.Context, dm *tailcfg.DERPMap, from, to *tailcfg.DERPNode) (latency time.Duration, err error) {
// The passed in context is a minute for the whole region. The
// idea is that each node pair in the region will be done
// serially and regularly in the future, reusing connections
// (at least in the happy path). For now they don't reuse
// connections and probe at most once every 15 seconds. We
// bound the duration of a single node pair within a region
// so one bad one can't starve others.
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
fromc, err := newConn(ctx, dm, from)
if err != nil {
return 0, err
}
defer fromc.Close()
toc, err := newConn(ctx, dm, to)
if err != nil {
return 0, err
}
defer toc.Close()
// Wait a bit for from's node to hear about to existing on the
// other node in the region, in the case where the two nodes
// are different.
if from.Name != to.Name {
time.Sleep(100 * time.Millisecond) // pretty arbitrary
}
// Make a random packet
pkt := make([]byte, 8)
crand.Read(pkt)
t0 := time.Now()
// Send the random packet.
sendc := make(chan error, 1)
go func() {
sendc <- fromc.Send(toc.SelfPublicKey(), pkt)
}()
select {
case <-ctx.Done():
return 0, fmt.Errorf("timeout sending via %q: %w", from.Name, ctx.Err())
case err := <-sendc:
if err != nil {
return 0, fmt.Errorf("error sending via %q: %w", from.Name, err)
}
}
// Receive the random packet.
recvc := make(chan any, 1) // either derp.ReceivedPacket or error
go func() {
for {
m, err := toc.Recv()
if err != nil {
recvc <- err
return
}
switch v := m.(type) {
case derp.ReceivedPacket:
recvc <- v
default:
log.Printf("%v: ignoring Recv frame type %T", to.Name, v)
// Loop.
}
}
}()
select {
case <-ctx.Done():
return 0, fmt.Errorf("timeout receiving from %q: %w", to.Name, ctx.Err())
case v := <-recvc:
if err, ok := v.(error); ok {
return 0, fmt.Errorf("error receiving from %q: %w", to.Name, err)
}
p := v.(derp.ReceivedPacket)
if p.Source != fromc.SelfPublicKey() {
return 0, fmt.Errorf("got data packet from unexpected source, %v", p.Source)
}
if !bytes.Equal(p.Data, pkt) {
return 0, fmt.Errorf("unexpected data packet %q", p.Data)
}
}
return time.Since(t0), nil
}
func newConn(ctx context.Context, dm *tailcfg.DERPMap, n *tailcfg.DERPNode) (*derphttp.Client, error) {
priv := key.NewNode()
dc := derphttp.NewRegionClient(priv, log.Printf, func() *tailcfg.DERPRegion {
rid := n.RegionID
return &tailcfg.DERPRegion{
RegionID: rid,
RegionCode: fmt.Sprintf("%s-%s", dm.Regions[rid].RegionCode, n.Name),
RegionName: dm.Regions[rid].RegionName,
Nodes: []*tailcfg.DERPNode{n},
}
})
dc.IsProber = true
err := dc.Connect(ctx)
if err != nil {
return nil, err
}
cs, ok := dc.TLSConnectionState()
if !ok {
dc.Close()
return nil, errors.New("no TLS state")
}
if len(cs.PeerCertificates) == 0 {
dc.Close()
return nil, errors.New("no peer certificates")
}
if cs.ServerName != n.HostName {
dc.Close()
return nil, fmt.Errorf("TLS server name %q != derp hostname %q", cs.ServerName, n.HostName)
}
setCert(cs.ServerName, cs.PeerCertificates[0])
errc := make(chan error, 1)
go func() {
m, err := dc.Recv()
if err != nil {
errc <- err
return
}
switch m.(type) {
case derp.ServerInfoMessage:
errc <- nil
default:
errc <- fmt.Errorf("unexpected first message type %T", errc)
}
}()
select {
case err := <-errc:
if err != nil {
go dc.Close()
return nil, err
}
case <-ctx.Done():
go dc.Close()
return nil, fmt.Errorf("timeout waiting for ServerInfoMessage: %w", ctx.Err())
}
return dc, nil
}
var httpOrFileClient = &http.Client{Transport: httpOrFileTransport()}
func httpOrFileTransport() http.RoundTripper {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.RegisterProtocol("file", http.NewFileTransport(http.Dir("/")))
return tr
}
func getDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
req, err := http.NewRequestWithContext(ctx, "GET", *derpMapURL, nil)
if err != nil {
return nil, err
}
res, err := httpOrFileClient.Do(req)
if err != nil {
mu.Lock()
defer mu.Unlock()
if lastDERPMap != nil && time.Since(lastDERPMapAt) < 10*time.Minute {
// Assume that control is restarting and use
// the same one for a bit.
return lastDERPMap, nil
}
return nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return nil, fmt.Errorf("fetching %s: %s", *derpMapURL, res.Status)
}
dm := new(tailcfg.DERPMap)
if err := json.NewDecoder(res.Body).Decode(dm); err != nil {
return nil, fmt.Errorf("decoding %s JSON: %v", *derpMapURL, err)
}
setDERPMap(dm)
return dm, nil
}

51
cmd/dist/dist.go vendored
View File

@@ -1,51 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// The dist command builds Tailscale release packages for distribution.
package main
import (
"context"
"errors"
"flag"
"log"
"os"
"tailscale.com/release/dist"
"tailscale.com/release/dist/cli"
"tailscale.com/release/dist/synology"
"tailscale.com/release/dist/unixpkgs"
)
var synologyPackageCenter bool
func getTargets(signers unixpkgs.Signers) ([]dist.Target, error) {
var ret []dist.Target
ret = append(ret, unixpkgs.Targets(signers)...)
// Synology packages can be built either for sideloading, or for
// distribution by Synology in their package center. When
// distributed through the package center, apps can request
// additional permissions to use a tuntap interface and control
// the NAS's network stack, rather than be forced to run in
// userspace mode.
//
// Since only we can provide packages to Synology for
// distribution, we default to building the "sideload" variant of
// packages that we distribute on pkgs.tailscale.com.
ret = append(ret, synology.Targets(synologyPackageCenter)...)
return ret, nil
}
func main() {
cmd := cli.CLI(getTargets)
for _, subcmd := range cmd.Subcommands {
if subcmd.Name == "build" {
subcmd.FlagSet.BoolVar(&synologyPackageCenter, "synology-package-center", false, "build synology packages with extra metadata for the official package center")
}
}
if err := cmd.ParseAndRun(context.Background(), os.Args[1:]); err != nil && !errors.Is(err, flag.ErrHelp) {
log.Fatal(err)
}
}

View File

@@ -1 +0,0 @@
get-authkey

View File

@@ -1,74 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// get-authkey allocates an authkey using an OAuth API client
// https://tailscale.com/s/oauth-clients and prints it
// to stdout for scripts to capture and use.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"strings"
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/util/cmpx"
)
func main() {
// Required to use our client API. We're fine with the instability since the
// client lives in the same repo as this code.
tailscale.I_Acknowledge_This_API_Is_Unstable = true
reusable := flag.Bool("reusable", false, "allocate a reusable authkey")
ephemeral := flag.Bool("ephemeral", false, "allocate an ephemeral authkey")
preauth := flag.Bool("preauth", true, "set the authkey as pre-authorized")
tags := flag.String("tags", "", "comma-separated list of tags to apply to the authkey")
flag.Parse()
clientID := os.Getenv("TS_API_CLIENT_ID")
clientSecret := os.Getenv("TS_API_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
log.Fatal("TS_API_CLIENT_ID and TS_API_CLIENT_SECRET must be set")
}
if *tags == "" {
log.Fatal("at least one tag must be specified")
}
baseURL := cmpx.Or(os.Getenv("TS_BASE_URL"), "https://api.tailscale.com")
credentials := clientcredentials.Config{
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: baseURL + "/api/v2/oauth/token",
Scopes: []string{"device"},
}
ctx := context.Background()
tsClient := tailscale.NewClient("-", nil)
tsClient.HTTPClient = credentials.Client(ctx)
tsClient.BaseURL = baseURL
caps := tailscale.KeyCapabilities{
Devices: tailscale.KeyDeviceCapabilities{
Create: tailscale.KeyDeviceCreateCapabilities{
Reusable: *reusable,
Ephemeral: *ephemeral,
Preauthorized: *preauth,
Tags: strings.Split(*tags, ","),
},
},
}
authkey, _, err := tsClient.CreateKey(ctx, caps)
if err != nil {
log.Fatal(err.Error())
}
fmt.Println(authkey)
}

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Command gitops-pusher allows users to use a GitOps flow for managing Tailscale ACLs.
//
@@ -22,9 +23,6 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"github.com/tailscale/hujson"
"golang.org/x/oauth2/clientcredentials"
"tailscale.com/client/tailscale"
"tailscale.com/util/httpm"
)
var (
@@ -44,9 +42,9 @@ func modifiedExternallyError() {
}
}
func apply(cache *Cache, client *http.Client, tailnet, apiKey string) func(context.Context, []string) error {
func apply(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
return func(ctx context.Context, args []string) error {
controlEtag, err := getACLETag(ctx, client, tailnet, apiKey)
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
if err != nil {
return err
}
@@ -75,7 +73,7 @@ func apply(cache *Cache, client *http.Client, tailnet, apiKey string) func(conte
return nil
}
if err := applyNewACL(ctx, client, tailnet, apiKey, *policyFname, controlEtag); err != nil {
if err := applyNewACL(ctx, tailnet, apiKey, *policyFname, controlEtag); err != nil {
return err
}
@@ -85,9 +83,9 @@ func apply(cache *Cache, client *http.Client, tailnet, apiKey string) func(conte
}
}
func test(cache *Cache, client *http.Client, tailnet, apiKey string) func(context.Context, []string) error {
func test(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
return func(ctx context.Context, args []string) error {
controlEtag, err := getACLETag(ctx, client, tailnet, apiKey)
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
if err != nil {
return err
}
@@ -115,16 +113,16 @@ func test(cache *Cache, client *http.Client, tailnet, apiKey string) func(contex
return nil
}
if err := testNewACLs(ctx, client, tailnet, apiKey, *policyFname); err != nil {
if err := testNewACLs(ctx, tailnet, apiKey, *policyFname); err != nil {
return err
}
return nil
}
}
func getChecksums(cache *Cache, client *http.Client, tailnet, apiKey string) func(context.Context, []string) error {
func getChecksums(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
return func(ctx context.Context, args []string) error {
controlEtag, err := getACLETag(ctx, client, tailnet, apiKey)
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
if err != nil {
return err
}
@@ -153,24 +151,8 @@ func main() {
log.Fatal("set envvar TS_TAILNET to your tailnet's name")
}
apiKey, ok := os.LookupEnv("TS_API_KEY")
oauthId, oiok := os.LookupEnv("TS_OAUTH_ID")
oauthSecret, osok := os.LookupEnv("TS_OAUTH_SECRET")
if !ok && (!oiok || !osok) {
log.Fatal("set envvar TS_API_KEY to your Tailscale API key or TS_OAUTH_ID and TS_OAUTH_SECRET to your Tailscale OAuth ID and Secret")
}
if ok && (oiok || osok) {
log.Fatal("set either the envvar TS_API_KEY or TS_OAUTH_ID and TS_OAUTH_SECRET")
}
var client *http.Client
if oiok {
oauthConfig := &clientcredentials.Config{
ClientID: oauthId,
ClientSecret: oauthSecret,
TokenURL: fmt.Sprintf("https://%s/api/v2/oauth/token", *apiServer),
}
client = oauthConfig.Client(context.Background())
} else {
client = http.DefaultClient
if !ok {
log.Fatal("set envvar TS_API_KEY to your Tailscale API key")
}
cache, err := LoadCache(*cacheFname)
if err != nil {
@@ -187,7 +169,7 @@ func main() {
ShortUsage: "gitops-pusher [options] apply",
ShortHelp: "Pushes changes to CONTROL",
LongHelp: `Pushes changes to CONTROL`,
Exec: apply(cache, client, tailnet, apiKey),
Exec: apply(cache, tailnet, apiKey),
}
testCmd := &ffcli.Command{
@@ -195,7 +177,7 @@ func main() {
ShortUsage: "gitops-pusher [options] test",
ShortHelp: "Tests ACL changes",
LongHelp: "Tests ACL changes",
Exec: test(cache, client, tailnet, apiKey),
Exec: test(cache, tailnet, apiKey),
}
cksumCmd := &ffcli.Command{
@@ -203,7 +185,7 @@ func main() {
ShortUsage: "Shows checksums of ACL files",
ShortHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison",
LongHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison",
Exec: getChecksums(cache, client, tailnet, apiKey),
Exec: getChecksums(cache, tailnet, apiKey),
}
root := &ffcli.Command{
@@ -246,14 +228,14 @@ func sumFile(fname string) (string, error) {
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
func applyNewACL(ctx context.Context, client *http.Client, tailnet, apiKey, policyFname, oldEtag string) error {
func applyNewACL(ctx context.Context, tailnet, apiKey, policyFname, oldEtag string) error {
fin, err := os.Open(policyFname)
if err != nil {
return err
}
defer fin.Close()
req, err := http.NewRequestWithContext(ctx, httpm.POST, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl", *apiServer, tailnet), fin)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl", *apiServer, tailnet), fin)
if err != nil {
return err
}
@@ -262,7 +244,7 @@ func applyNewACL(ctx context.Context, client *http.Client, tailnet, apiKey, poli
req.Header.Set("Content-Type", "application/hujson")
req.Header.Set("If-Match", `"`+oldEtag+`"`)
resp, err := client.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
@@ -271,7 +253,7 @@ func applyNewACL(ctx context.Context, client *http.Client, tailnet, apiKey, poli
got := resp.StatusCode
want := http.StatusOK
if got != want {
var ate ACLGitopsTestError
var ate ACLTestError
err := json.NewDecoder(resp.Body).Decode(&ate)
if err != nil {
return err
@@ -283,7 +265,7 @@ func applyNewACL(ctx context.Context, client *http.Client, tailnet, apiKey, poli
return nil
}
func testNewACLs(ctx context.Context, client *http.Client, tailnet, apiKey, policyFname string) error {
func testNewACLs(ctx context.Context, tailnet, apiKey, policyFname string) error {
data, err := os.ReadFile(policyFname)
if err != nil {
return err
@@ -293,7 +275,7 @@ func testNewACLs(ctx context.Context, client *http.Client, tailnet, apiKey, poli
return err
}
req, err := http.NewRequestWithContext(ctx, httpm.POST, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl/validate", *apiServer, tailnet), bytes.NewBuffer(data))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl/validate", *apiServer, tailnet), bytes.NewBuffer(data))
if err != nil {
return err
}
@@ -301,13 +283,13 @@ func testNewACLs(ctx context.Context, client *http.Client, tailnet, apiKey, poli
req.SetBasicAuth(apiKey, "")
req.Header.Set("Content-Type", "application/hujson")
resp, err := client.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var ate ACLGitopsTestError
var ate ACLTestError
err = json.NewDecoder(resp.Body).Decode(&ate)
if err != nil {
return err
@@ -328,12 +310,12 @@ func testNewACLs(ctx context.Context, client *http.Client, tailnet, apiKey, poli
var lineColMessageSplit = regexp.MustCompile(`line ([0-9]+), column ([0-9]+): (.*)$`)
// ACLGitopsTestError is redefined here so we can add a custom .Error() response
type ACLGitopsTestError struct {
tailscale.ACLTestError
type ACLTestError struct {
Message string `json:"message"`
Data []ACLTestErrorDetail `json:"data"`
}
func (ate ACLGitopsTestError) Error() string {
func (ate ACLTestError) Error() string {
var sb strings.Builder
if *githubSyntax && lineColMessageSplit.MatchString(ate.Message) {
@@ -350,30 +332,22 @@ func (ate ACLGitopsTestError) Error() string {
fmt.Fprintln(&sb)
for _, data := range ate.Data {
if data.User != "" {
fmt.Fprintf(&sb, "For user %s:\n", data.User)
}
if len(data.Errors) > 0 {
fmt.Fprint(&sb, "Errors found:\n")
for _, err := range data.Errors {
fmt.Fprintf(&sb, "- %s\n", err)
}
}
if len(data.Warnings) > 0 {
fmt.Fprint(&sb, "Warnings found:\n")
for _, err := range data.Warnings {
fmt.Fprintf(&sb, "- %s\n", err)
}
fmt.Fprintf(&sb, "For user %s:\n", data.User)
for _, err := range data.Errors {
fmt.Fprintf(&sb, "- %s\n", err)
}
}
return sb.String()
}
func getACLETag(ctx context.Context, client *http.Client, tailnet, apiKey string) (string, error) {
req, err := http.NewRequestWithContext(ctx, httpm.GET, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl", *apiServer, tailnet), nil)
type ACLTestErrorDetail struct {
User string `json:"user"`
Errors []string `json:"errors"`
}
func getACLETag(ctx context.Context, tailnet, apiKey string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://%s/api/v2/tailnet/%s/acl", *apiServer, tailnet), nil)
if err != nil {
return "", err
}
@@ -381,7 +355,7 @@ func getACLETag(ctx context.Context, client *http.Client, tailnet, apiKey string
req.SetBasicAuth(apiKey, "")
req.Header.Set("Accept", "application/hujson")
resp, err := client.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}

View File

@@ -1,55 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"encoding/json"
"strings"
"testing"
"tailscale.com/client/tailscale"
)
func TestEmbeddedTypeUnmarshal(t *testing.T) {
var gitopsErr ACLGitopsTestError
gitopsErr.Message = "gitops response error"
gitopsErr.Data = []tailscale.ACLTestFailureSummary{
{
User: "GitopsError",
Errors: []string{"this was initially created as a gitops error"},
},
}
var aclTestErr tailscale.ACLTestError
aclTestErr.Message = "native ACL response error"
aclTestErr.Data = []tailscale.ACLTestFailureSummary{
{
User: "ACLError",
Errors: []string{"this was initially created as an ACL error"},
},
}
t.Run("unmarshal gitops type from acl type", func(t *testing.T) {
b, _ := json.Marshal(aclTestErr)
var e ACLGitopsTestError
err := json.Unmarshal(b, &e)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(e.Error(), "For user ACLError") { // the gitops error prints out the user, the acl error doesn't
t.Fatalf("user heading for 'ACLError' not found in gitops error: %v", e.Error())
}
})
t.Run("unmarshal acl type from gitops type", func(t *testing.T) {
b, _ := json.Marshal(gitopsErr)
var e tailscale.ACLTestError
err := json.Unmarshal(b, &e)
if err != nil {
t.Fatal(err)
}
expectedErr := `Status: 0, Message: "gitops response error", Data: [{User:GitopsError Errors:[this was initially created as a gitops error] Warnings:[]}]`
if e.Error() != expectedErr {
t.Fatalf("got %v\n, expected %v", e.Error(), expectedErr)
}
})
}

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// The hello binary runs hello.ts.net.
package main // import "tailscale.com/cmd/hello"

View File

@@ -1,24 +0,0 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: tailscale-auth-proxy
rules:
- apiGroups: [""]
resources: ["users", "groups"]
verbs: ["impersonate"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: tailscale-auth-proxy
subjects:
- kind: ServiceAccount
name: operator
namespace: tailscale
roleRef:
kind: ClusterRole
name: tailscale-auth-proxy
apiGroup: rbac.authorization.k8s.io

View File

@@ -1,5 +1,6 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
apiVersion: v1
kind: Namespace
@@ -124,7 +125,7 @@ spec:
secretName: operator-oauth
containers:
- name: operator
image: tailscale/k8s-operator:unstable
image: tailscale/k8s-operator:latest
resources:
requests:
cpu: 500m
@@ -145,11 +146,9 @@ spec:
- name: CLIENT_SECRET_FILE
value: /oauth/client_secret
- name: PROXY_IMAGE
value: tailscale/tailscale:unstable
value: tailscale/tailscale:latest
- name: PROXY_TAGS
value: tag:k8s
- name: AUTH_PROXY
value: "false"
volumeMounts:
- name: oauth
mountPath: /oauth

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// tailscale-operator provides a way to expose services running in a Kubernetes
// cluster to your Tailnet.
@@ -7,10 +8,8 @@ package main
import (
"context"
"crypto/tls"
_ "embed"
"fmt"
"net/http"
"os"
"strings"
"time"
@@ -25,8 +24,8 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/transport"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -37,16 +36,13 @@ import (
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"
"sigs.k8s.io/yaml"
"tailscale.com/client/tailscale"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/store/kubestore"
"tailscale.com/tsnet"
"tailscale.com/types/logger"
"tailscale.com/types/opt"
"tailscale.com/util/dnsname"
"tailscale.com/version"
)
func main() {
@@ -55,17 +51,15 @@ func main() {
tailscale.I_Acknowledge_This_API_Is_Unstable = true
var (
hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator")
kubeSecret = defaultEnv("OPERATOR_SECRET", "")
operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator")
tsNamespace = defaultEnv("OPERATOR_NAMESPACE", "")
tslogging = defaultEnv("OPERATOR_LOGGING", "info")
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")
image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest")
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
shouldRunAuthProxy = defaultBool("AUTH_PROXY", false)
hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator")
kubeSecret = defaultEnv("OPERATOR_SECRET", "")
operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator")
tsNamespace = defaultEnv("OPERATOR_NAMESPACE", "")
tslogging = defaultEnv("OPERATOR_LOGGING", "info")
clientIDPath = defaultEnv("CLIENT_ID_FILE", "")
clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "")
image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest")
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
)
var opts []kzap.Opts
@@ -99,13 +93,6 @@ func main() {
}
tsClient := tailscale.NewClient("-", nil)
tsClient.HTTPClient = credentials.Client(context.Background())
if shouldRunAuthProxy {
hostinfo.SetApp("k8s-operator-proxy")
} else {
hostinfo.SetApp("k8s-operator")
}
s := &tsnet.Server{
Hostname: hostname,
Logf: zlog.Named("tailscaled").Debugf,
@@ -168,7 +155,7 @@ waitOnline:
loginDone = true
case "NeedsMachineAuth":
if !machineAuthShown {
startlog.Infof("Machine approval required, please visit the admin panel to approve")
startlog.Infof("Machine authorization required, please visit the admin panel to authorize")
machineAuthShown = true
}
default:
@@ -177,39 +164,36 @@ waitOnline:
time.Sleep(time.Second)
}
sr := &ServiceReconciler{
tsClient: tsClient,
defaultTags: strings.Split(tags, ","),
operatorNamespace: tsNamespace,
proxyImage: image,
logger: zlog.Named("service-reconciler"),
}
// For secrets and statefulsets, we only get permission to touch the objects
// in the controller's own namespace. This cannot be expressed by
// .Watches(...) below, instead you have to add a per-type field selector to
// the cache that sits a few layers below the builder stuff, which will
// implicitly filter what parts of the world the builder code gets to see at
// all.
nsFilter := cache.ByObject{
Field: client.InNamespace(tsNamespace).AsSelector(),
nsFilter := cache.ObjectSelector{
Field: fields.SelectorFromSet(fields.Set{"metadata.namespace": tsNamespace}),
}
restConfig := config.GetConfigOrDie()
mgr, err := manager.New(restConfig, manager.Options{
Cache: cache.Options{
ByObject: map[client.Object]cache.ByObject{
mgr, err := manager.New(config.GetConfigOrDie(), manager.Options{
NewCache: cache.BuilderWithOptions(cache.Options{
SelectorsByObject: map[client.Object]cache.ObjectSelector{
&corev1.Secret{}: nsFilter,
&appsv1.StatefulSet{}: nsFilter,
},
},
}),
})
if err != nil {
startlog.Fatalf("could not create manager: %v", err)
}
sr := &ServiceReconciler{
Client: mgr.GetClient(),
tsClient: tsClient,
defaultTags: strings.Split(tags, ","),
operatorNamespace: tsNamespace,
proxyImage: image,
proxyPriorityClassName: priorityClassName,
logger: zlog.Named("service-reconciler"),
}
reconcileFilter := handler.EnqueueRequestsFromMapFunc(func(_ context.Context, o client.Object) []reconcile.Request {
reconcileFilter := handler.EnqueueRequestsFromMapFunc(func(o client.Object) []reconcile.Request {
ls := o.GetLabels()
if ls[LabelManaged] != "true" {
return nil
@@ -229,35 +213,14 @@ waitOnline:
err = builder.
ControllerManagedBy(mgr).
For(&corev1.Service{}).
Watches(&appsv1.StatefulSet{}, reconcileFilter).
Watches(&corev1.Secret{}, reconcileFilter).
Watches(&source.Kind{Type: &appsv1.StatefulSet{}}, reconcileFilter).
Watches(&source.Kind{Type: &corev1.Secret{}}, reconcileFilter).
Complete(sr)
if err != nil {
startlog.Fatalf("could not create controller: %v", err)
}
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
if shouldRunAuthProxy {
cfg, err := restConfig.TransportConfig()
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
}
// Kubernetes uses SPDY for exec and port-forward, however SPDY is
// incompatible with HTTP/2; so disable HTTP/2 in the proxy.
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig, err = transport.TLSConfigFor(cfg)
if err != nil {
startlog.Fatalf("could not get transport.TLSConfigFor(): %v", err)
}
tr.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper)
rt, err := transport.HTTPWrappersForConfig(cfg, tr)
if err != nil {
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
}
go runAuthProxy(s, rt, zlog.Named("auth-proxy").Infof)
}
startlog.Infof("Startup complete, operator running")
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
startlog.Fatalf("could not start manager: %v", err)
}
@@ -271,20 +234,18 @@ const (
FinalizerName = "tailscale.com/finalizer"
AnnotationExpose = "tailscale.com/expose"
AnnotationTags = "tailscale.com/tags"
AnnotationHostname = "tailscale.com/hostname"
AnnotationExpose = "tailscale.com/expose"
AnnotationTags = "tailscale.com/tags"
)
// ServiceReconciler is a simple ControllerManagedBy example implementation.
type ServiceReconciler struct {
client.Client
tsClient tsClient
defaultTags []string
operatorNamespace string
proxyImage string
proxyPriorityClassName string
logger *zap.SugaredLogger
tsClient tsClient
defaultTags []string
operatorNamespace string
proxyImage string
logger *zap.SugaredLogger
}
type tsClient interface {
@@ -408,11 +369,6 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
// This function adds a finalizer to svc, ensuring that we can handle orderly
// deprovisioning later.
func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
hostname, err := nameForService(svc)
if err != nil {
return err
}
if !slices.Contains(svc.Finalizers, FinalizerName) {
// This log line is printed exactly once during initial provisioning,
// because once the finalizer is in place this block gets skipped. So,
@@ -439,7 +395,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
if err != nil {
return fmt.Errorf("failed to create or get API key secret: %w", err)
}
_, err = a.reconcileSTS(ctx, logger, svc, hsvc, secretName, hostname)
_, err = a.reconcileSTS(ctx, logger, svc, hsvc, secretName)
if err != nil {
return fmt.Errorf("failed to reconcile statefulset: %w", err)
}
@@ -568,9 +524,6 @@ func (a *ServiceReconciler) getDeviceInfo(ctx context.Context, svc *corev1.Servi
if err != nil {
return "", "", err
}
if sec == nil {
return "", "", nil
}
id = string(sec.Data["device_id"])
if id == "" {
return "", "", nil
@@ -594,7 +547,6 @@ func (a *ServiceReconciler) newAuthKey(ctx context.Context, tags []string) (stri
},
},
}
key, _, err := a.tsClient.CreateKey(ctx, caps)
if err != nil {
return "", err
@@ -605,7 +557,7 @@ func (a *ServiceReconciler) newAuthKey(ctx context.Context, tags []string) (stri
//go:embed manifests/proxy.yaml
var proxyYaml []byte
func (a *ServiceReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, parentSvc, headlessSvc *corev1.Service, authKeySecret, hostname string) (*appsv1.StatefulSet, error) {
func (a *ServiceReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, parentSvc, headlessSvc *corev1.Service, authKeySecret string) (*appsv1.StatefulSet, error) {
var ss appsv1.StatefulSet
if err := yaml.Unmarshal(proxyYaml, &ss); err != nil {
return nil, fmt.Errorf("failed to unmarshal proxy spec: %w", err)
@@ -620,10 +572,6 @@ func (a *ServiceReconciler) reconcileSTS(ctx context.Context, logger *zap.Sugare
corev1.EnvVar{
Name: "TS_KUBE_SECRET",
Value: authKeySecret,
},
corev1.EnvVar{
Name: "TS_HOSTNAME",
Value: hostname,
})
ss.ObjectMeta = metav1.ObjectMeta{
Name: headlessSvc.Name,
@@ -639,11 +587,15 @@ func (a *ServiceReconciler) reconcileSTS(ctx context.Context, logger *zap.Sugare
ss.Spec.Template.ObjectMeta.Labels = map[string]string{
"app": string(parentSvc.UID),
}
ss.Spec.Template.Spec.PriorityClassName = a.proxyPriorityClassName
logger.Debugf("reconciling statefulset %s/%s", ss.GetNamespace(), ss.GetName())
return createOrUpdate(ctx, a.Client, a.operatorNamespace, &ss, func(s *appsv1.StatefulSet) { s.Spec = ss.Spec })
}
func (a *ServiceReconciler) InjectClient(c client.Client) error {
a.Client = c
return nil
}
// ptrObject is a type constraint for pointer types that implement
// client.Object.
type ptrObject[T any] interface {
@@ -724,15 +676,6 @@ func getSingleObject[T any, O ptrObject[T]](ctx context.Context, c client.Client
return ret, nil
}
func defaultBool(envName string, defVal bool) bool {
vs := os.Getenv(envName)
if vs == "" {
return defVal
}
v, _ := opt.Bool(vs).Get()
return v
}
func defaultEnv(envName, defVal string) string {
v := os.Getenv(envName)
if v == "" {
@@ -740,13 +683,3 @@ func defaultEnv(envName, defVal string) string {
}
return v
}
func nameForService(svc *corev1.Service) (string, error) {
if h, ok := svc.Annotations[AnnotationHostname]; ok {
if err := dnsname.ValidLabel(h); err != nil {
return "", fmt.Errorf("invalid Tailscale hostname %q: %w", h, err)
}
return h, nil
}
return svc.Namespace + "-" + svc.Name, nil
}

View File

@@ -1,5 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
@@ -14,6 +15,7 @@ import (
"go.uber.org/zap"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
@@ -64,7 +66,7 @@ func TestLoadBalancerClass(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
expectEqual(t, fc, expectedSTS(shortName, fullName))
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, then verify reconcile again and verify
@@ -110,8 +112,6 @@ func TestLoadBalancerClass(t *testing.T) {
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
s.Spec.Type = corev1.ServiceTypeClusterIP
s.Spec.LoadBalancerClass = nil
})
mustUpdateStatus(t, fc, "default", "test", func(s *corev1.Service) {
// Fake client doesn't automatically delete the LoadBalancer status when
// changing away from the LoadBalancer type, we have to do
// controller-manager's work by hand.
@@ -187,7 +187,7 @@ func TestAnnotations(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
expectEqual(t, fc, expectedSTS(shortName, fullName))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
@@ -284,7 +284,7 @@ func TestAnnotationIntoLB(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
expectEqual(t, fc, expectedSTS(shortName, fullName))
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, since it would have normally happened at
@@ -328,7 +328,7 @@ func TestAnnotationIntoLB(t *testing.T) {
expectReconciled(t, sr, "default", "test")
// None of the proxy machinery should have changed...
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
expectEqual(t, fc, expectedSTS(shortName, fullName))
// ... but the service should have a LoadBalancer status.
want = &corev1.Service{
@@ -400,7 +400,7 @@ func TestLBIntoAnnotation(t *testing.T) {
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
expectEqual(t, fc, expectedSTS(shortName, fullName))
// Normally the Tailscale proxy pod would come up here and write its info
// into the secret. Simulate that, then verify reconcile again and verify
@@ -449,8 +449,6 @@ func TestLBIntoAnnotation(t *testing.T) {
}
s.Spec.Type = corev1.ServiceTypeClusterIP
s.Spec.LoadBalancerClass = nil
})
mustUpdateStatus(t, fc, "default", "test", func(s *corev1.Service) {
// Fake client doesn't automatically delete the LoadBalancer status when
// changing away from the LoadBalancer type, we have to do
// controller-manager's work by hand.
@@ -459,7 +457,7 @@ func TestLBIntoAnnotation(t *testing.T) {
expectReconciled(t, sr, "default", "test")
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test", ""))
expectEqual(t, fc, expectedSTS(shortName, fullName))
want = &corev1.Service{
TypeMeta: metav1.TypeMeta{
@@ -483,153 +481,6 @@ func TestLBIntoAnnotation(t *testing.T) {
expectEqual(t, fc, want)
}
func TestCustomHostname(t *testing.T) {
fc := fake.NewFakeClient()
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
sr := &ServiceReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
logger: zl.Sugar(),
}
// Create a service that we should manage, and check that the initial round
// of objects looks right.
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
Annotations: map[string]string{
"tailscale.com/expose": "true",
"tailscale.com/hostname": "reindeer-flotilla",
},
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeClusterIP,
},
})
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test")
expectEqual(t, fc, expectedSecret(fullName))
expectEqual(t, fc, expectedHeadlessService(shortName))
expectEqual(t, fc, expectedSTS(shortName, fullName, "reindeer-flotilla", ""))
want := &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
Finalizers: []string{"tailscale.com/finalizer"},
UID: types.UID("1234-UID"),
Annotations: map[string]string{
"tailscale.com/expose": "true",
"tailscale.com/hostname": "reindeer-flotilla",
},
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeClusterIP,
},
}
expectEqual(t, fc, want)
// Turn the service back into a ClusterIP service, which should make the
// operator clean up.
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
delete(s.ObjectMeta.Annotations, "tailscale.com/expose")
})
// synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet
// didn't create any child resources since this is all faked, so the
// deletion goes through immediately.
expectReconciled(t, sr, "default", "test")
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
// Second time around, the rest of cleanup happens.
expectReconciled(t, sr, "default", "test")
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
want = &corev1.Service{
TypeMeta: metav1.TypeMeta{
Kind: "Service",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
UID: types.UID("1234-UID"),
Annotations: map[string]string{
"tailscale.com/hostname": "reindeer-flotilla",
},
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeClusterIP,
},
}
expectEqual(t, fc, want)
}
func TestCustomPriorityClassName(t *testing.T) {
fc := fake.NewFakeClient()
ft := &fakeTSClient{}
zl, err := zap.NewDevelopment()
if err != nil {
t.Fatal(err)
}
sr := &ServiceReconciler{
Client: fc,
tsClient: ft,
defaultTags: []string{"tag:k8s"},
operatorNamespace: "operator-ns",
proxyImage: "tailscale/tailscale",
proxyPriorityClassName: "tailscale-critical",
logger: zl.Sugar(),
}
// Create a service that we should manage, and check that the initial round
// of objects looks right.
mustCreate(t, fc, &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
// The apiserver is supposed to set the UID, but the fake client
// doesn't. So, set it explicitly because other code later depends
// on it being set.
UID: types.UID("1234-UID"),
Annotations: map[string]string{
"tailscale.com/expose": "true",
"tailscale.com/hostname": "custom-priority-class-name",
},
},
Spec: corev1.ServiceSpec{
ClusterIP: "10.20.30.40",
Type: corev1.ServiceTypeClusterIP,
},
})
expectReconciled(t, sr, "default", "test")
fullName, shortName := findGenName(t, fc, "default", "test")
expectEqual(t, fc, expectedSTS(shortName, fullName, "custom-priority-class-name", "tailscale-critical"))
}
func expectedSecret(name string) *corev1.Secret {
return &corev1.Secret{
TypeMeta: metav1.TypeMeta{
@@ -678,7 +529,7 @@ func expectedHeadlessService(name string) *corev1.Service {
}
}
func expectedSTS(stsName, secretName, hostname, priorityClassName string) *appsv1.StatefulSet {
func expectedSTS(stsName, secretName string) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
Kind: "StatefulSet",
@@ -707,7 +558,6 @@ func expectedSTS(stsName, secretName, hostname, priorityClassName string) *appsv
},
Spec: corev1.PodSpec{
ServiceAccountName: "proxies",
PriorityClassName: priorityClassName,
InitContainers: []corev1.Container{
{
Name: "sysctler",
@@ -719,16 +569,15 @@ func expectedSTS(stsName, secretName, hostname, priorityClassName string) *appsv
},
},
},
Containers: []corev1.Container{
Containers: []v1.Container{
{
Name: "tailscale",
Image: "tailscale/tailscale",
Env: []corev1.EnvVar{
Env: []v1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_DEST_IP", Value: "10.20.30.40"},
{Name: "TS_KUBE_SECRET", Value: secretName},
{Name: "TS_HOSTNAME", Value: hostname},
},
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
@@ -781,21 +630,6 @@ func mustUpdate[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, n
}
}
func mustUpdateStatus[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string, update func(O)) {
t.Helper()
obj := O(new(T))
if err := client.Get(context.Background(), types.NamespacedName{
Name: name,
Namespace: ns,
}, obj); err != nil {
t.Fatalf("getting %q: %v", name, err)
}
update(obj)
if err := client.Status().Update(context.Background(), obj); err != nil {
t.Fatalf("updating %q: %v", name, err)
}
}
func expectEqual[T any, O ptrObject[T]](t *testing.T, client client.Client, want O) {
t.Helper()
got := O(new(T))
@@ -879,6 +713,7 @@ func (c *fakeTSClient) CreateKey(ctx context.Context, caps tailscale.KeyCapabili
k := &tailscale.Key{
ID: "key",
Created: time.Now(),
Expires: time.Now().Add(24 * time.Hour),
Capabilities: caps,
}
return "secret-authkey", k, nil

View File

@@ -1,120 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"context"
"crypto/tls"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/tsnet"
"tailscale.com/types/logger"
)
type whoIsKey struct{}
// authProxy is an http.Handler that authenticates requests using the Tailscale
// LocalAPI and then proxies them to the Kubernetes API.
type authProxy struct {
logf logger.Logf
lc *tailscale.LocalClient
rp *httputil.ReverseProxy
}
func (h *authProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
who, err := h.lc.WhoIs(r.Context(), r.RemoteAddr)
if err != nil {
h.logf("failed to authenticate caller: %v", err)
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
return
}
r = r.WithContext(context.WithValue(r.Context(), whoIsKey{}, who))
h.rp.ServeHTTP(w, r)
}
// runAuthProxy runs an HTTP server that authenticates requests using the
// Tailscale LocalAPI and then proxies them to the Kubernetes API.
// It listens on :443 and uses the Tailscale HTTPS certificate.
// s will be started if it is not already running.
// rt is used to proxy requests to the Kubernetes API.
//
// It never returns.
func runAuthProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf) {
ln, err := s.Listen("tcp", ":443")
if err != nil {
log.Fatalf("could not listen on :443: %v", err)
}
u, err := url.Parse(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
if err != nil {
log.Fatalf("runAuthProxy: failed to parse URL %v", err)
}
lc, err := s.LocalClient()
if err != nil {
log.Fatalf("could not get local client: %v", err)
}
ap := &authProxy{
logf: logf,
lc: lc,
rp: &httputil.ReverseProxy{
Director: func(r *http.Request) {
// We want to proxy to the Kubernetes API, but we want to use
// the caller's identity to do so. We do this by impersonating
// the caller using the Kubernetes User Impersonation feature:
// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation
// Out of paranoia, remove all authentication headers that might
// have been set by the client.
r.Header.Del("Authorization")
r.Header.Del("Impersonate-Group")
r.Header.Del("Impersonate-User")
r.Header.Del("Impersonate-Uid")
for k := range r.Header {
if strings.HasPrefix(k, "Impersonate-Extra-") {
r.Header.Del(k)
}
}
// Now add the impersonation headers that we want.
who := r.Context().Value(whoIsKey{}).(*apitype.WhoIsResponse)
if who.Node.IsTagged() {
// Use the nodes FQDN as the username, and the nodes tags as the groups.
// "Impersonate-Group" requires "Impersonate-User" to be set.
r.Header.Set("Impersonate-User", strings.TrimSuffix(who.Node.Name, "."))
for _, tag := range who.Node.Tags {
r.Header.Add("Impersonate-Group", tag)
}
} else {
r.Header.Set("Impersonate-User", who.UserProfile.LoginName)
}
// Replace the URL with the Kubernetes APIServer.
r.URL.Scheme = u.Scheme
r.URL.Host = u.Host
},
Transport: rt,
},
}
hs := &http.Server{
// Kubernetes uses SPDY for exec and port-forward, however SPDY is
// incompatible with HTTP/2; so disable HTTP/2 in the proxy.
TLSConfig: &tls.Config{
GetCertificate: lc.GetCertificate,
NextProtos: []string{"http/1.1"},
},
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
Handler: ap,
}
if err := hs.ServeTLS(ln, "", ""); err != nil {
log.Fatalf("runAuthProxy: failed to serve %v", err)
}
}

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