Compare commits
1 Commits
icio/testw
...
will/webcl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b35416b37 |
12
.github/workflows/checklocks.yml
vendored
12
.github/workflows/checklocks.yml
vendored
@@ -18,17 +18,11 @@ jobs:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build checklocks
|
||||
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
|
||||
|
||||
- name: Run checklocks vet
|
||||
# TODO(#12625): add more packages as we add annotations
|
||||
run: |-
|
||||
./tool/go vet -vettool=/tmp/checklocks \
|
||||
./envknob \
|
||||
./ipn/store/mem \
|
||||
./net/stun/stuntest \
|
||||
./net/wsconn \
|
||||
./proxymap
|
||||
# TODO: remove || true once we have applied checklocks annotations everywhere.
|
||||
run: ./tool/go vet -vettool=/tmp/checklocks ./... || true
|
||||
|
||||
14
.github/workflows/codeql-analysis.yml
vendored
14
.github/workflows/codeql-analysis.yml
vendored
@@ -45,17 +45,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
# Install a more recent Go that understands modern go.mod content.
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -66,7 +60,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@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -80,4 +74,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3.28.9
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
2
.github/workflows/docker-file-build.yml
vendored
2
.github/workflows/docker-file-build.yml
vendored
@@ -10,6 +10,6 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@v4
|
||||
- name: "Build Docker image"
|
||||
run: docker build .
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
id-token: "write"
|
||||
contents: "read"
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: "actions/checkout@v4"
|
||||
with:
|
||||
ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}"
|
||||
- uses: "DeterminateSystems/nix-installer-action@main"
|
||||
|
||||
64
.github/workflows/go-licenses.yml
vendored
Normal file
64
.github/workflows/go-licenses.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: go-licenses
|
||||
|
||||
on:
|
||||
# run action when a change lands in the main branch which updates go.mod or
|
||||
# our license template file. Also allow manual triggering.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- go.mod
|
||||
- .github/licenses.tmpl
|
||||
- .github/workflows/go-licenses.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-licenses:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install go-licenses
|
||||
run: |
|
||||
go install github.com/google/go-licenses@v1.2.2-0.20220825154955-5eedde1c6584
|
||||
|
||||
- name: Run go-licenses
|
||||
env:
|
||||
# include all build tags to include platform-specific dependencies
|
||||
GOFLAGS: "-tags=android,cgo,darwin,freebsd,ios,js,linux,openbsd,wasm,windows"
|
||||
run: |
|
||||
[ -d licenses ] || mkdir licenses
|
||||
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
|
||||
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: License Updater <noreply+license-updater@tailscale.com>
|
||||
committer: License Updater <noreply+license-updater@tailscale.com>
|
||||
branch: licenses/cli
|
||||
commit-message: "licenses: update tailscale{,d} licenses"
|
||||
title: "licenses: update tailscale{,d} licenses"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
team-reviewers: opensource-license-reviewers
|
||||
9
.github/workflows/golangci-lint.yml
vendored
9
.github/workflows/golangci-lint.yml
vendored
@@ -23,17 +23,18 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@2e788936b09dd82dc280e845628a40d2ba6b204c # v6.3.1
|
||||
# Note: this is the 'v3' tag as of 2023-08-14
|
||||
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
|
||||
with:
|
||||
version: v1.60
|
||||
version: v1.54.2
|
||||
|
||||
# Show only new issues if it's a pull request.
|
||||
only-new-issues: true
|
||||
|
||||
10
.github/workflows/govulncheck.yml
vendored
10
.github/workflows/govulncheck.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install govulncheck
|
||||
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@@ -24,13 +24,13 @@ jobs:
|
||||
|
||||
- name: Post to slack
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0
|
||||
uses: slackapi/slack-github-action@v1.24.0
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
|
||||
channel-id: 'C05PXRM304B'
|
||||
payload: |
|
||||
{
|
||||
"channel": "C05PXRM304B",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
|
||||
20
.github/workflows/installer.yml
vendored
20
.github/workflows/installer.yml
vendored
@@ -6,13 +6,11 @@ on:
|
||||
- "main"
|
||||
paths:
|
||||
- scripts/installer.sh
|
||||
- .github/workflows/installer.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
paths:
|
||||
- scripts/installer.sh
|
||||
- .github/workflows/installer.yml
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -31,11 +29,14 @@ jobs:
|
||||
- "debian:stable-slim"
|
||||
- "debian:testing-slim"
|
||||
- "debian:sid-slim"
|
||||
- "ubuntu:18.04"
|
||||
- "ubuntu:20.04"
|
||||
- "ubuntu:22.04"
|
||||
- "ubuntu:24.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"
|
||||
@@ -48,7 +49,7 @@ jobs:
|
||||
- "opensuse/leap:latest"
|
||||
- "opensuse/tumbleweed:latest"
|
||||
- "archlinux:latest"
|
||||
- "alpine:3.21"
|
||||
- "alpine:3.14"
|
||||
- "alpine:latest"
|
||||
- "alpine:edge"
|
||||
deps:
|
||||
@@ -58,16 +59,15 @@ jobs:
|
||||
# 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 (pacman)
|
||||
# Refresh the package databases to ensure that the tailscale package is
|
||||
# defined.
|
||||
run: pacman -Sy
|
||||
if: contains(matrix.image, 'archlinux')
|
||||
- name: install dependencies (yum)
|
||||
# tar and gzip are needed by the actions/checkout below.
|
||||
run: yum install -y --allowerasing tar gzip ${{ matrix.deps }}
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
|| contains(matrix.image, 'parrotsec')
|
||||
|| contains(matrix.image, 'kalilinux')
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: run installer
|
||||
run: scripts/installer.sh
|
||||
# Package installation can fail in docker because systemd is not running
|
||||
|
||||
6
.github/workflows/kubemanifests.yaml
vendored
6
.github/workflows/kubemanifests.yaml
vendored
@@ -2,8 +2,8 @@ name: "Kubernetes manifests"
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cmd/k8s-operator/**'
|
||||
- 'k8s-operator/**'
|
||||
- './cmd/k8s-operator/'
|
||||
- './k8s-operator/'
|
||||
- '.github/workflows/kubemanifests.yaml'
|
||||
|
||||
# Cancel workflow run if there is a newer push to the same PR for which it is
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: Build and lint Helm chart
|
||||
run: |
|
||||
eval `./tool/go run ./cmd/mkversion`
|
||||
|
||||
23
.github/workflows/ssh-integrationtest.yml
vendored
23
.github/workflows/ssh-integrationtest.yml
vendored
@@ -1,23 +0,0 @@
|
||||
# Run the ssh integration tests with `make sshintegrationtest`.
|
||||
# These tests can also be running locally.
|
||||
name: "ssh-integrationtest"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "ssh/**"
|
||||
- "tempfork/gliderlabs/ssh/**"
|
||||
- ".github/workflows/ssh-integrationtest"
|
||||
jobs:
|
||||
ssh-integrationtest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Run SSH integration tests
|
||||
run: |
|
||||
make sshintegrationtest
|
||||
137
.github/workflows/test.yml
vendored
137
.github/workflows/test.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
- shard: '4/4'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: build test wrapper
|
||||
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
|
||||
- name: integration tests as root
|
||||
@@ -64,6 +64,7 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- goarch: amd64
|
||||
coverflags: "-coverprofile=/tmp/coverage.out"
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '1/3'
|
||||
@@ -77,9 +78,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
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
|
||||
@@ -118,10 +119,15 @@ jobs:
|
||||
- name: build test wrapper
|
||||
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
|
||||
- name: test all
|
||||
run: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
|
||||
run: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ${{matrix.coverflags}} ./... ${{matrix.buildflags}}
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
TS_TEST_SHARD: ${{ matrix.shard }}
|
||||
- name: Publish to coveralls.io
|
||||
if: matrix.coverflags != '' # only publish results if we've tracked coverage
|
||||
uses: shogo82148/actions-goveralls@v1
|
||||
with:
|
||||
path-to-profile: /tmp/coverage.out
|
||||
- name: bench all
|
||||
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
|
||||
env:
|
||||
@@ -144,16 +150,16 @@ jobs:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
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
|
||||
@@ -177,38 +183,25 @@ jobs:
|
||||
# the equals signs cause great confusion.
|
||||
run: go test ./... -bench . -benchtime 1x -run "^$"
|
||||
|
||||
privileged:
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: golang:latest
|
||||
options: --privileged
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: chown
|
||||
run: chown -R $(id -u):$(id -g) $PWD
|
||||
- name: privileged tests
|
||||
run: ./tool/go test ./util/linuxfw ./derp/xdp
|
||||
|
||||
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: Run VM tests
|
||||
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
|
||||
env:
|
||||
HOME: "/var/lib/ghrunner/home"
|
||||
HOME: "/tmp"
|
||||
TMPDIR: "/tmp"
|
||||
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
race-build:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: build all
|
||||
run: ./tool/go install -race ./cmd/...
|
||||
- name: build tests
|
||||
@@ -248,13 +241,16 @@ jobs:
|
||||
goarch: amd64
|
||||
- goos: openbsd
|
||||
goarch: amd64
|
||||
# Plan9
|
||||
- goos: plan9
|
||||
goarch: amd64
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
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
|
||||
@@ -289,60 +285,13 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: build some
|
||||
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
|
||||
env:
|
||||
GOOS: ios
|
||||
GOARCH: arm64
|
||||
|
||||
crossmin: # cross-compile for platforms where we only check cmd/tailscale{,d}
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
# Plan9
|
||||
- goos: plan9
|
||||
goarch: amd64
|
||||
# AIX
|
||||
- goos: aix
|
||||
goarch: ppc64
|
||||
# Solaris
|
||||
- goos: solaris
|
||||
goarch: amd64
|
||||
# illumos
|
||||
- goos: illumos
|
||||
goarch: amd64
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
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 core
|
||||
run: ./tool/go build ./cmd/tailscale ./cmd/tailscaled
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: "0"
|
||||
|
||||
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,
|
||||
@@ -350,13 +299,13 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
# Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed
|
||||
# and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch
|
||||
# some Android breakages early.
|
||||
# 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/netmon ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version
|
||||
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
|
||||
@@ -365,9 +314,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
@@ -399,7 +348,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: test tailscale_go
|
||||
run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/...
|
||||
|
||||
@@ -456,35 +405,31 @@ jobs:
|
||||
fuzz-seconds: 300
|
||||
dry-run: false
|
||||
language: go
|
||||
- name: Set artifacts_path in env (workaround for actions/upload-artifact#176)
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
run: |
|
||||
echo "artifacts_path=$(realpath .)" >> $GITHUB_ENV
|
||||
- name: upload crash
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@v3
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
with:
|
||||
name: artifacts
|
||||
path: ${{ env.artifacts_path }}/out/artifacts
|
||||
path: ./out/artifacts
|
||||
|
||||
depaware:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- 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 --internal
|
||||
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go generate' is clean
|
||||
run: |
|
||||
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator|xdp')
|
||||
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator')
|
||||
./tool/go generate $pkgs
|
||||
echo
|
||||
echo
|
||||
@@ -494,7 +439,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go mod tidy' is clean
|
||||
run: |
|
||||
./tool/go mod tidy
|
||||
@@ -506,7 +451,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: check licenses
|
||||
run: ./scripts/check_license_headers.sh .
|
||||
|
||||
@@ -522,7 +467,7 @@ jobs:
|
||||
goarch: "386"
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: install staticcheck
|
||||
run: GOBIN=~/.local/bin ./tool/go install honnef.co/go/tools/cmd/staticcheck
|
||||
- name: run staticcheck
|
||||
@@ -563,10 +508,8 @@ jobs:
|
||||
# By having the job always run, but skipping its only step as needed, we
|
||||
# let the CI output collapse nicely in PRs.
|
||||
if: failure() && github.event_name == 'push'
|
||||
uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0
|
||||
uses: ruby/action-slack@v3.2.1
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
@@ -578,6 +521,8 @@ jobs:
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
check_mergeability:
|
||||
if: always()
|
||||
@@ -600,6 +545,6 @@ jobs:
|
||||
steps:
|
||||
- name: Decide if change is okay to merge
|
||||
if: github.event_name != 'push'
|
||||
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
|
||||
uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
9
.github/workflows/update-flake.yml
vendored
9
.github/workflows/update-flake.yml
vendored
@@ -21,22 +21,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run update-flakes
|
||||
run: ./update-flake.sh
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_retrieval_mode: "id"
|
||||
installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f #v7.0.6
|
||||
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>
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run go get
|
||||
run: |
|
||||
@@ -23,19 +23,18 @@ jobs:
|
||||
./tool/go mod tidy
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
|
||||
id: generate-token
|
||||
with:
|
||||
# TODO(will): this should use the code updater app rather than licensing.
|
||||
# It has the same permissions, so not a big deal, but still.
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_retrieval_mode: "id"
|
||||
installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
id: pull-request
|
||||
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f #v7.0.6
|
||||
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: OSS Updater <noreply+oss-updater@tailscale.com>
|
||||
|
||||
2
.github/workflows/webclient.yml
vendored
2
.github/workflows/webclient.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v4
|
||||
- name: Install deps
|
||||
run: ./tool/yarn --cwd client/web
|
||||
- name: Run lint
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -9,7 +9,6 @@
|
||||
|
||||
cmd/tailscale/tailscale
|
||||
cmd/tailscaled/tailscaled
|
||||
ssh/tailssh/testcontainers/tailscaled
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -43,9 +42,3 @@ client/web/build/assets
|
||||
|
||||
/gocross
|
||||
/dist
|
||||
|
||||
# Ignore xcode userstate and workspace data
|
||||
*.xcuserstate
|
||||
*.xcworkspacedata
|
||||
/tstest/tailmac/bin
|
||||
/tstest/tailmac/build
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,13 +1,17 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
# Note that this Dockerfile is currently NOT used to build any of the published
|
||||
# Tailscale container images and may have drifted from the image build mechanism
|
||||
# we use.
|
||||
# Tailscale images are currently built using https://github.com/tailscale/mkctr,
|
||||
# and the build script can be found in ./build_docker.sh.
|
||||
############################################################################
|
||||
#
|
||||
# WARNING: Tailscale is not yet officially supported in container
|
||||
# environments, such as Docker and Kubernetes. Though it should work, we
|
||||
# don't regularly test it, and we know there are some feature limitations.
|
||||
#
|
||||
# See current bugs tagged "containers":
|
||||
# https://github.com/tailscale/tailscale/labels/containers
|
||||
#
|
||||
############################################################################
|
||||
|
||||
# This Dockerfile includes all the tailscale binaries.
|
||||
#
|
||||
# To build the Dockerfile:
|
||||
@@ -27,7 +31,7 @@
|
||||
# $ docker exec tailscaled tailscale status
|
||||
|
||||
|
||||
FROM golang:1.23-alpine AS build-env
|
||||
FROM golang:1.21-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src/tailscale
|
||||
|
||||
@@ -42,7 +46,7 @@ RUN go install \
|
||||
gvisor.dev/gvisor/pkg/tcpip/stack \
|
||||
golang.org/x/crypto/ssh \
|
||||
golang.org/x/crypto/acme \
|
||||
github.com/coder/websocket \
|
||||
nhooyr.io/websocket \
|
||||
github.com/mdlayher/netlink
|
||||
|
||||
COPY . .
|
||||
|
||||
31
Makefile
31
Makefile
@@ -1,10 +1,8 @@
|
||||
IMAGE_REPO ?= tailscale/tailscale
|
||||
SYNO_ARCH ?= "x86_64"
|
||||
SYNO_ARCH ?= "amd64"
|
||||
SYNO_DSM ?= "7"
|
||||
TAGS ?= "latest"
|
||||
|
||||
PLATFORM ?= "flyio" ## flyio==linux/amd64. Set to "" to build all platforms.
|
||||
|
||||
vet: ## Run go vet
|
||||
./tool/go vet ./...
|
||||
|
||||
@@ -17,21 +15,19 @@ lint: ## Run golangci-lint
|
||||
updatedeps: ## Update depaware deps
|
||||
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
|
||||
# it finds in its $$PATH is the right one.
|
||||
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update --internal \
|
||||
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update \
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper \
|
||||
tailscale.com/cmd/k8s-operator \
|
||||
tailscale.com/cmd/stund
|
||||
|
||||
depaware: ## Run depaware checks
|
||||
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
|
||||
# it finds in its $$PATH is the right one.
|
||||
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check --internal \
|
||||
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check \
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper \
|
||||
tailscale.com/cmd/k8s-operator \
|
||||
tailscale.com/cmd/stund
|
||||
|
||||
buildwindows: ## Build tailscale CLI for windows/amd64
|
||||
@@ -92,7 +88,7 @@ publishdevimage: ## Build and publish tailscale image to location specified by $
|
||||
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
|
||||
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=client ./build_docker.sh
|
||||
TAGS="${TAGS}" REPOS=${REPO} PUSH=true TARGET=client ./build_docker.sh
|
||||
|
||||
publishdevoperator: ## Build and publish k8s-operator image to location specified by ${REPO}
|
||||
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
|
||||
@@ -100,24 +96,7 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie
|
||||
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
|
||||
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-operator ./build_docker.sh
|
||||
|
||||
publishdevnameserver: ## Build and publish k8s-nameserver image to location specified by ${REPO}
|
||||
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/k8s-nameserver" || (echo "REPO=... must not be tailscale/k8s-nameserver" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/k8s-nameserver" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-nameserver" && exit 1)
|
||||
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-nameserver ./build_docker.sh
|
||||
|
||||
.PHONY: sshintegrationtest
|
||||
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
|
||||
@GOOS=linux GOARCH=amd64 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
|
||||
GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
|
||||
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \
|
||||
echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers
|
||||
TAGS="${TAGS}" REPOS=${REPO} PUSH=true TARGET=operator ./build_docker.sh
|
||||
|
||||
help: ## Show this help
|
||||
@echo "\nSpecify a command. The choices are:\n"
|
||||
|
||||
@@ -37,7 +37,7 @@ not open source.
|
||||
|
||||
## Building
|
||||
|
||||
We always require the latest Go release, currently Go 1.23. (While we build
|
||||
We always require the latest Go release, currently Go 1.21. (While we build
|
||||
releases with our [Go fork](https://github.com/tailscale/go/), its use is not
|
||||
required.)
|
||||
|
||||
@@ -72,7 +72,7 @@ Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin)
|
||||
`Signed-off-by` lines in commits.
|
||||
|
||||
See `git log` for our commit message style. It's basically the same as
|
||||
[Go's style](https://go.dev/wiki/CommitMessage).
|
||||
[Go's style](https://github.com/golang/go/wiki/CommitMessage).
|
||||
|
||||
## About Us
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.81.0
|
||||
1.57.0
|
||||
|
||||
@@ -10,122 +10,24 @@
|
||||
package appc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/execqueue"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/slicesx"
|
||||
)
|
||||
|
||||
// rateLogger responds to calls to update by adding a count for the current period and
|
||||
// calling the callback if any previous period has finished since update was last called
|
||||
type rateLogger struct {
|
||||
interval time.Duration
|
||||
start time.Time
|
||||
periodStart time.Time
|
||||
periodCount int64
|
||||
now func() time.Time
|
||||
callback func(int64, time.Time, int64)
|
||||
}
|
||||
|
||||
func (rl *rateLogger) currentIntervalStart(now time.Time) time.Time {
|
||||
millisSince := now.Sub(rl.start).Milliseconds() % rl.interval.Milliseconds()
|
||||
return now.Add(-(time.Duration(millisSince)) * time.Millisecond)
|
||||
}
|
||||
|
||||
func (rl *rateLogger) update(numRoutes int64) {
|
||||
now := rl.now()
|
||||
periodEnd := rl.periodStart.Add(rl.interval)
|
||||
if periodEnd.Before(now) {
|
||||
if rl.periodCount != 0 {
|
||||
rl.callback(rl.periodCount, rl.periodStart, numRoutes)
|
||||
}
|
||||
rl.periodCount = 0
|
||||
rl.periodStart = rl.currentIntervalStart(now)
|
||||
}
|
||||
rl.periodCount++
|
||||
}
|
||||
|
||||
func newRateLogger(now func() time.Time, interval time.Duration, callback func(int64, time.Time, int64)) *rateLogger {
|
||||
nowTime := now()
|
||||
return &rateLogger{
|
||||
callback: callback,
|
||||
now: now,
|
||||
interval: interval,
|
||||
start: nowTime,
|
||||
periodStart: nowTime,
|
||||
}
|
||||
}
|
||||
|
||||
// RouteAdvertiser is an interface that allows the AppConnector to advertise
|
||||
// newly discovered routes that need to be served through the AppConnector.
|
||||
type RouteAdvertiser interface {
|
||||
// AdvertiseRoute adds one or more route advertisements skipping any that
|
||||
// are already advertised.
|
||||
AdvertiseRoute(...netip.Prefix) error
|
||||
|
||||
// UnadvertiseRoute removes any matching route advertisements.
|
||||
UnadvertiseRoute(...netip.Prefix) error
|
||||
}
|
||||
|
||||
var (
|
||||
metricStoreRoutesRateBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000}
|
||||
metricStoreRoutesNBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000, 10000}
|
||||
metricStoreRoutesRate []*clientmetric.Metric
|
||||
metricStoreRoutesN []*clientmetric.Metric
|
||||
)
|
||||
|
||||
func initMetricStoreRoutes() {
|
||||
for _, n := range metricStoreRoutesRateBuckets {
|
||||
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_rate_%d", n)))
|
||||
}
|
||||
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter("appc_store_routes_rate_over"))
|
||||
for _, n := range metricStoreRoutesNBuckets {
|
||||
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_n_routes_%d", n)))
|
||||
}
|
||||
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter("appc_store_routes_n_routes_over"))
|
||||
}
|
||||
|
||||
func recordMetric(val int64, buckets []int64, metrics []*clientmetric.Metric) {
|
||||
if len(buckets) < 1 {
|
||||
return
|
||||
}
|
||||
// finds the first bucket where val <=, or len(buckets) if none match
|
||||
// for bucket values of 1, 10, 100; 0-1 goes to [0], 2-10 goes to [1], 11-100 goes to [2], 101+ goes to [3]
|
||||
bucket, _ := slices.BinarySearch(buckets, val)
|
||||
metrics[bucket].Add(1)
|
||||
}
|
||||
|
||||
func metricStoreRoutes(rate, nRoutes int64) {
|
||||
if len(metricStoreRoutesRate) == 0 {
|
||||
initMetricStoreRoutes()
|
||||
}
|
||||
recordMetric(rate, metricStoreRoutesRateBuckets, metricStoreRoutesRate)
|
||||
recordMetric(nRoutes, metricStoreRoutesNBuckets, metricStoreRoutesN)
|
||||
}
|
||||
|
||||
// RouteInfo is a data structure used to persist the in memory state of an AppConnector
|
||||
// so that we can know, even after a restart, which routes came from ACLs and which were
|
||||
// learned from domains.
|
||||
type RouteInfo struct {
|
||||
// Control is the routes from the 'routes' section of an app connector acl.
|
||||
Control []netip.Prefix `json:",omitempty"`
|
||||
// Domains are the routes discovered by observing DNS lookups for configured domains.
|
||||
Domains map[string][]netip.Addr `json:",omitempty"`
|
||||
// Wildcards are the configured DNS lookup domains to observe. When a DNS query matches Wildcards,
|
||||
// its result is added to Domains.
|
||||
Wildcards []string `json:",omitempty"`
|
||||
// AdvertiseRoute adds a new route advertisement if the route is not already
|
||||
// being advertised.
|
||||
AdvertiseRoute(netip.Prefix) error
|
||||
}
|
||||
|
||||
// AppConnector is an implementation of an AppConnector that performs
|
||||
@@ -141,115 +43,29 @@ type AppConnector struct {
|
||||
logf logger.Logf
|
||||
routeAdvertiser RouteAdvertiser
|
||||
|
||||
// storeRoutesFunc will be called to persist routes if it is not nil.
|
||||
storeRoutesFunc func(*RouteInfo) error
|
||||
|
||||
// mu guards the fields that follow
|
||||
mu sync.Mutex
|
||||
|
||||
// domains is a map of lower case domain names with no trailing dot, to an
|
||||
// ordered list of resolved IP addresses.
|
||||
// domains is a map of lower case domain names with no trailing dot, to a
|
||||
// list of resolved IP addresses.
|
||||
domains map[string][]netip.Addr
|
||||
|
||||
// controlRoutes is the list of routes that were last supplied by control.
|
||||
controlRoutes []netip.Prefix
|
||||
|
||||
// wildcards is the list of domain strings that match subdomains.
|
||||
wildcards []string
|
||||
|
||||
// queue provides ordering for update operations
|
||||
queue execqueue.ExecQueue
|
||||
|
||||
writeRateMinute *rateLogger
|
||||
writeRateDay *rateLogger
|
||||
}
|
||||
|
||||
// NewAppConnector creates a new AppConnector.
|
||||
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser, routeInfo *RouteInfo, storeRoutesFunc func(*RouteInfo) error) *AppConnector {
|
||||
ac := &AppConnector{
|
||||
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConnector {
|
||||
return &AppConnector{
|
||||
logf: logger.WithPrefix(logf, "appc: "),
|
||||
routeAdvertiser: routeAdvertiser,
|
||||
storeRoutesFunc: storeRoutesFunc,
|
||||
}
|
||||
if routeInfo != nil {
|
||||
ac.domains = routeInfo.Domains
|
||||
ac.wildcards = routeInfo.Wildcards
|
||||
ac.controlRoutes = routeInfo.Control
|
||||
}
|
||||
ac.writeRateMinute = newRateLogger(time.Now, time.Minute, func(c int64, s time.Time, l int64) {
|
||||
ac.logf("routeInfo write rate: %d in minute starting at %v (%d routes)", c, s, l)
|
||||
metricStoreRoutes(c, l)
|
||||
})
|
||||
ac.writeRateDay = newRateLogger(time.Now, 24*time.Hour, func(c int64, s time.Time, l int64) {
|
||||
ac.logf("routeInfo write rate: %d in 24 hours starting at %v (%d routes)", c, s, l)
|
||||
})
|
||||
return ac
|
||||
}
|
||||
|
||||
// ShouldStoreRoutes returns true if the appconnector was created with the controlknob on
|
||||
// and is storing its discovered routes persistently.
|
||||
func (e *AppConnector) ShouldStoreRoutes() bool {
|
||||
return e.storeRoutesFunc != nil
|
||||
}
|
||||
|
||||
// storeRoutesLocked takes the current state of the AppConnector and persists it
|
||||
func (e *AppConnector) storeRoutesLocked() error {
|
||||
if !e.ShouldStoreRoutes() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// log write rate and write size
|
||||
numRoutes := int64(len(e.controlRoutes))
|
||||
for _, rs := range e.domains {
|
||||
numRoutes += int64(len(rs))
|
||||
}
|
||||
e.writeRateMinute.update(numRoutes)
|
||||
e.writeRateDay.update(numRoutes)
|
||||
|
||||
return e.storeRoutesFunc(&RouteInfo{
|
||||
Control: e.controlRoutes,
|
||||
Domains: e.domains,
|
||||
Wildcards: e.wildcards,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearRoutes removes all route state from the AppConnector.
|
||||
func (e *AppConnector) ClearRoutes() error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.controlRoutes = nil
|
||||
e.domains = nil
|
||||
e.wildcards = nil
|
||||
return e.storeRoutesLocked()
|
||||
}
|
||||
|
||||
// UpdateDomainsAndRoutes starts an asynchronous update of the configuration
|
||||
// given the new domains and routes.
|
||||
func (e *AppConnector) UpdateDomainsAndRoutes(domains []string, routes []netip.Prefix) {
|
||||
e.queue.Add(func() {
|
||||
// Add the new routes first.
|
||||
e.updateRoutes(routes)
|
||||
e.updateDomains(domains)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDomains asynchronously replaces the current set of configured domains
|
||||
// with the supplied set of domains. Domains must not contain a trailing dot,
|
||||
// and should be lower case. If the domain contains a leading '*' label it
|
||||
// matches all subdomains of a domain.
|
||||
// UpdateDomains replaces the current set of configured domains with the
|
||||
// supplied set of domains. Domains must not contain a trailing dot, and should
|
||||
// be lower case. If the domain contains a leading '*' label it matches all
|
||||
// subdomains of a domain.
|
||||
func (e *AppConnector) UpdateDomains(domains []string) {
|
||||
e.queue.Add(func() {
|
||||
e.updateDomains(domains)
|
||||
})
|
||||
}
|
||||
|
||||
// Wait waits for the currently scheduled asynchronous configuration changes to
|
||||
// complete.
|
||||
func (e *AppConnector) Wait(ctx context.Context) {
|
||||
e.queue.Wait(ctx)
|
||||
}
|
||||
|
||||
func (e *AppConnector) updateDomains(domains []string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
@@ -274,78 +90,11 @@ func (e *AppConnector) updateDomains(domains []string) {
|
||||
for _, wc := range e.wildcards {
|
||||
if dnsname.HasSuffix(d, wc) {
|
||||
e.domains[d] = addrs
|
||||
delete(oldDomains, d)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Everything left in oldDomains is a domain we're no longer tracking
|
||||
// and if we are storing route info we can unadvertise the routes
|
||||
if e.ShouldStoreRoutes() {
|
||||
toRemove := []netip.Prefix{}
|
||||
for _, addrs := range oldDomains {
|
||||
for _, a := range addrs {
|
||||
toRemove = append(toRemove, netip.PrefixFrom(a, a.BitLen()))
|
||||
}
|
||||
}
|
||||
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
|
||||
e.logf("failed to unadvertise routes on domain removal: %v: %v: %v", slicesx.MapKeys(oldDomains), toRemove, err)
|
||||
}
|
||||
}
|
||||
|
||||
e.logf("handling domains: %v and wildcards: %v", slicesx.MapKeys(e.domains), e.wildcards)
|
||||
}
|
||||
|
||||
// updateRoutes merges the supplied routes into the currently configured routes. The routes supplied
|
||||
// by control for UpdateRoutes are supplemental to the routes discovered by DNS resolution, but are
|
||||
// also more often whole ranges. UpdateRoutes will remove any single address routes that are now
|
||||
// covered by new ranges.
|
||||
func (e *AppConnector) updateRoutes(routes []netip.Prefix) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// If there was no change since the last update, no work to do.
|
||||
if slices.Equal(e.controlRoutes, routes) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
|
||||
e.logf("failed to advertise routes: %v: %v", routes, err)
|
||||
return
|
||||
}
|
||||
|
||||
var toRemove []netip.Prefix
|
||||
|
||||
// If we're storing routes and know e.controlRoutes is a good
|
||||
// representation of what should be in AdvertisedRoutes we can stop
|
||||
// advertising routes that used to be in e.controlRoutes but are not
|
||||
// in routes.
|
||||
if e.ShouldStoreRoutes() {
|
||||
toRemove = routesWithout(e.controlRoutes, routes)
|
||||
}
|
||||
|
||||
nextRoute:
|
||||
for _, r := range routes {
|
||||
for _, addr := range e.domains {
|
||||
for _, a := range addr {
|
||||
if r.Contains(a) && netip.PrefixFrom(a, a.BitLen()) != r {
|
||||
pfx := netip.PrefixFrom(a, a.BitLen())
|
||||
toRemove = append(toRemove, pfx)
|
||||
continue nextRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
|
||||
e.logf("failed to unadvertise routes: %v: %v", toRemove, err)
|
||||
}
|
||||
|
||||
e.controlRoutes = routes
|
||||
if err := e.storeRoutesLocked(); err != nil {
|
||||
e.logf("failed to store route info: %v", err)
|
||||
}
|
||||
e.logf("handling domains: %v and wildcards: %v", xmaps.Keys(e.domains), e.wildcards)
|
||||
}
|
||||
|
||||
// Domains returns the currently configured domain list.
|
||||
@@ -353,7 +102,7 @@ func (e *AppConnector) Domains() views.Slice[string] {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
return views.SliceOf(slicesx.MapKeys(e.domains))
|
||||
return views.SliceOf(xmaps.Keys(e.domains))
|
||||
}
|
||||
|
||||
// DomainRoutes returns a map of domains to resolved IP
|
||||
@@ -374,223 +123,97 @@ func (e *AppConnector) DomainRoutes() map[string][]netip.Addr {
|
||||
// response is being returned over the PeerAPI. The response is parsed and
|
||||
// matched against the configured domains, if matched the routeAdvertiser is
|
||||
// advised to advertise the discovered route.
|
||||
func (e *AppConnector) ObserveDNSResponse(res []byte) error {
|
||||
func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
var p dnsmessage.Parser
|
||||
if _, err := p.Start(res); err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
if err := p.SkipAllQuestions(); err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
// cnameChain tracks a chain of CNAMEs for a given query in order to reverse
|
||||
// a CNAME chain back to the original query for flattening. The keys are
|
||||
// CNAME record targets, and the value is the name the record answers, so
|
||||
// for www.example.com CNAME example.com, the map would contain
|
||||
// ["example.com"] = "www.example.com".
|
||||
var cnameChain map[string]string
|
||||
|
||||
// addressRecords is a list of address records found in the response.
|
||||
var addressRecords map[string][]netip.Addr
|
||||
|
||||
for {
|
||||
h, err := p.AnswerHeader()
|
||||
if err == dnsmessage.ErrSectionDone {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
if h.Class != dnsmessage.ClassINET {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch h.Type {
|
||||
case dnsmessage.TypeCNAME, dnsmessage.TypeA, dnsmessage.TypeAAAA:
|
||||
default:
|
||||
if h.Type != dnsmessage.TypeA && h.Type != dnsmessage.TypeAAAA {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
continue
|
||||
|
||||
}
|
||||
|
||||
domain := strings.TrimSuffix(strings.ToLower(h.Name.String()), ".")
|
||||
domain := h.Name.String()
|
||||
if len(domain) == 0 {
|
||||
return
|
||||
}
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
domain = strings.ToLower(domain)
|
||||
e.logf("[v2] observed DNS response for %s", domain)
|
||||
|
||||
e.mu.Lock()
|
||||
addrs, ok := e.domains[domain]
|
||||
// match wildcard domains
|
||||
if !ok {
|
||||
for _, wc := range e.wildcards {
|
||||
if dnsname.HasSuffix(domain, wc) {
|
||||
e.domains[domain] = nil
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if h.Type == dnsmessage.TypeCNAME {
|
||||
res, err := p.CNAMEResource()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cname := strings.TrimSuffix(strings.ToLower(res.CNAME.String()), ".")
|
||||
if len(cname) == 0 {
|
||||
continue
|
||||
}
|
||||
mak.Set(&cnameChain, cname, domain)
|
||||
continue
|
||||
}
|
||||
|
||||
var addr netip.Addr
|
||||
switch h.Type {
|
||||
case dnsmessage.TypeA:
|
||||
r, err := p.AResource()
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
addr := netip.AddrFrom4(r.A)
|
||||
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
|
||||
addr = netip.AddrFrom4(r.A)
|
||||
case dnsmessage.TypeAAAA:
|
||||
r, err := p.AAAAResource()
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
addr := netip.AddrFrom16(r.AAAA)
|
||||
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
|
||||
addr = netip.AddrFrom16(r.AAAA)
|
||||
default:
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
for domain, addrs := range addressRecords {
|
||||
domain, isRouted := e.findRoutedDomainLocked(domain, cnameChain)
|
||||
|
||||
// domain and none of the CNAMEs in the chain are routed
|
||||
if !isRouted {
|
||||
if slices.Contains(addrs, addr) {
|
||||
continue
|
||||
}
|
||||
|
||||
// advertise each address we have learned for the routed domain, that
|
||||
// was not already known.
|
||||
var toAdvertise []netip.Prefix
|
||||
for _, addr := range addrs {
|
||||
if !e.isAddrKnownLocked(domain, addr) {
|
||||
toAdvertise = append(toAdvertise, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
}
|
||||
if err := e.routeAdvertiser.AdvertiseRoute(netip.PrefixFrom(addr, addr.BitLen())); err != nil {
|
||||
e.logf("failed to advertise route for %s: %v: %v", domain, addr, err)
|
||||
continue
|
||||
}
|
||||
e.logf("[v2] advertised route for %v: %v", domain, addr)
|
||||
|
||||
if len(toAdvertise) > 0 {
|
||||
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
|
||||
e.scheduleAdvertisement(domain, toAdvertise...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// starting from the given domain that resolved to an address, find it, or any
|
||||
// of the domains in the CNAME chain toward resolving it, that are routed
|
||||
// domains, returning the routed domain name and a bool indicating whether a
|
||||
// routed domain was found.
|
||||
// e.mu must be held.
|
||||
func (e *AppConnector) findRoutedDomainLocked(domain string, cnameChain map[string]string) (string, bool) {
|
||||
var isRouted bool
|
||||
for {
|
||||
_, isRouted = e.domains[domain]
|
||||
if isRouted {
|
||||
break
|
||||
}
|
||||
|
||||
// match wildcard domains
|
||||
for _, wc := range e.wildcards {
|
||||
if dnsname.HasSuffix(domain, wc) {
|
||||
e.domains[domain] = nil
|
||||
isRouted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
next, ok := cnameChain[domain]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
domain = next
|
||||
}
|
||||
return domain, isRouted
|
||||
}
|
||||
|
||||
// isAddrKnownLocked returns true if the address is known to be associated with
|
||||
// the given domain. Known domain tables are updated for covered routes to speed
|
||||
// up future matches.
|
||||
// e.mu must be held.
|
||||
func (e *AppConnector) isAddrKnownLocked(domain string, addr netip.Addr) bool {
|
||||
if e.hasDomainAddrLocked(domain, addr) {
|
||||
return true
|
||||
}
|
||||
for _, route := range e.controlRoutes {
|
||||
if route.Contains(addr) {
|
||||
// record the new address associated with the domain for faster matching in subsequent
|
||||
// requests and for diagnostic records.
|
||||
e.addDomainAddrLocked(domain, addr)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scheduleAdvertisement schedules an advertisement of the given address
|
||||
// associated with the given domain.
|
||||
func (e *AppConnector) scheduleAdvertisement(domain string, routes ...netip.Prefix) {
|
||||
e.queue.Add(func() {
|
||||
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
|
||||
e.logf("failed to advertise routes for %s: %v: %v", domain, routes, err)
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
for _, route := range routes {
|
||||
if !route.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
addr := route.Addr()
|
||||
if !e.hasDomainAddrLocked(domain, addr) {
|
||||
e.addDomainAddrLocked(domain, addr)
|
||||
e.logf("[v2] advertised route for %v: %v", domain, addr)
|
||||
}
|
||||
}
|
||||
if err := e.storeRoutesLocked(); err != nil {
|
||||
e.logf("failed to store route info: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// hasDomainAddrLocked returns true if the address has been observed in a
|
||||
// resolution of domain.
|
||||
func (e *AppConnector) hasDomainAddrLocked(domain string, addr netip.Addr) bool {
|
||||
_, ok := slices.BinarySearchFunc(e.domains[domain], addr, compareAddr)
|
||||
return ok
|
||||
}
|
||||
|
||||
// addDomainAddrLocked adds the address to the list of addresses resolved for
|
||||
// domain and ensures the list remains sorted. Does not attempt to deduplicate.
|
||||
func (e *AppConnector) addDomainAddrLocked(domain string, addr netip.Addr) {
|
||||
e.domains[domain] = append(e.domains[domain], addr)
|
||||
slices.SortFunc(e.domains[domain], compareAddr)
|
||||
}
|
||||
|
||||
func compareAddr(l, r netip.Addr) int {
|
||||
return l.Compare(r)
|
||||
}
|
||||
|
||||
// routesWithout returns a without b where a and b
|
||||
// are unsorted slices of netip.Prefix
|
||||
func routesWithout(a, b []netip.Prefix) []netip.Prefix {
|
||||
m := make(map[netip.Prefix]bool, len(b))
|
||||
for _, p := range b {
|
||||
m[p] = true
|
||||
e.domains[domain] = append(addrs, addr)
|
||||
e.mu.Unlock()
|
||||
}
|
||||
return slicesx.Filter(make([]netip.Prefix, 0, len(a)), a, func(p netip.Prefix) bool {
|
||||
return !m[p]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,276 +4,110 @@
|
||||
package appc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/appc/appctest"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/slicesx"
|
||||
)
|
||||
|
||||
func fakeStoreRoutes(*RouteInfo) error { return nil }
|
||||
|
||||
func TestUpdateDomains(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, nil, nil)
|
||||
}
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
|
||||
a.Wait(ctx)
|
||||
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
addr := netip.MustParseAddr("192.0.0.8")
|
||||
a.domains["example.com"] = append(a.domains["example.com"], addr)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
a.Wait(ctx)
|
||||
|
||||
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// domains are explicitly downcased on set.
|
||||
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
|
||||
a.Wait(ctx)
|
||||
if got, want := slicesx.MapKeys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
a := NewAppConnector(t.Logf, nil)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRoutes(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
a.updateDomains([]string{"*.example.com"})
|
||||
addr := netip.MustParseAddr("192.0.0.8")
|
||||
a.domains["example.com"] = append(a.domains["example.com"], addr)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
|
||||
// This route should be collapsed into the range
|
||||
if err := a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
|
||||
if !slices.Equal(rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) {
|
||||
t.Fatalf("got %v, want %v", rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
|
||||
}
|
||||
|
||||
// This route should not be collapsed or removed
|
||||
if err := a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
|
||||
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")}
|
||||
a.updateRoutes(routes)
|
||||
|
||||
slices.SortFunc(rc.Routes(), prefixCompare)
|
||||
rc.SetRoutes(slices.Compact(rc.Routes()))
|
||||
slices.SortFunc(routes, prefixCompare)
|
||||
|
||||
// Ensure that the non-matching /32 is preserved, even though it's in the domains table.
|
||||
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
|
||||
t.Errorf("added routes: got %v, want %v", rc.Routes(), routes)
|
||||
}
|
||||
|
||||
// Ensure that the contained /32 is removed, replaced by the /24.
|
||||
wantRemoved := []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}
|
||||
if !slices.EqualFunc(rc.RemovedRoutes(), wantRemoved, prefixEqual) {
|
||||
t.Fatalf("unexpected removed routes: %v", rc.RemovedRoutes())
|
||||
}
|
||||
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
mak.Set(&a.domains, "example.com", []netip.Addr{netip.MustParseAddr("192.0.2.1")})
|
||||
rc.SetRoutes([]netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
|
||||
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}
|
||||
a.updateRoutes(routes)
|
||||
|
||||
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
|
||||
t.Fatalf("got %v, want %v", rc.Routes(), routes)
|
||||
}
|
||||
// domains are explicitly downcased on set.
|
||||
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
|
||||
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainRoutes(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
a.updateDomains([]string{"example.com"})
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(context.Background())
|
||||
rc := &routeCollector{}
|
||||
a := NewAppConnector(t.Logf, rc)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
|
||||
want := map[string][]netip.Addr{
|
||||
"example.com": {netip.MustParseAddr("192.0.0.8")},
|
||||
}
|
||||
want := map[string][]netip.Addr{
|
||||
"example.com": {netip.MustParseAddr("192.0.0.8")},
|
||||
}
|
||||
|
||||
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("DomainRoutes: got %v, want %v", got, want)
|
||||
}
|
||||
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("DomainRoutes: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveDNSResponse(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
rc := &routeCollector{}
|
||||
a := NewAppConnector(t.Logf, rc)
|
||||
|
||||
// a has no domains configured, so it should not advertise any routes
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
if got, want := rc.Routes(), ([]netip.Prefix)(nil); !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
// a has no domains configured, so it should not advertise any routes
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if got, want := rc.routes, ([]netip.Prefix)(nil); !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||
|
||||
a.updateDomains([]string{"example.com"})
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// a CNAME record chain should result in a route being added if the chain
|
||||
// matches a routed domain.
|
||||
a.updateDomains([]string{"www.example.com", "example.com"})
|
||||
if err := a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com.")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.9/32"))
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
|
||||
|
||||
// a CNAME record chain should result in a route being added if the chain
|
||||
// even if only found in the middle of the chain
|
||||
if err := a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org.")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.10/32"))
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
|
||||
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// don't re-advertise routes that have already been advertised
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if !slices.Equal(rc.Routes(), wantRoutes) {
|
||||
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
|
||||
}
|
||||
|
||||
// don't advertise addresses that are already in a control provided route
|
||||
pfx := netip.MustParsePrefix("192.0.2.0/24")
|
||||
a.updateRoutes([]netip.Prefix{pfx})
|
||||
wantRoutes = append(wantRoutes, pfx)
|
||||
if err := a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if !slices.Equal(rc.Routes(), wantRoutes) {
|
||||
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
|
||||
}
|
||||
if !slices.Contains(a.domains["example.com"], netip.MustParseAddr("192.0.2.1")) {
|
||||
t.Errorf("missing %v from %v", "192.0.2.1", a.domains["exmaple.com"])
|
||||
}
|
||||
// don't re-advertise routes that have already been advertised
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
if !slices.Equal(rc.routes, wantRoutes) {
|
||||
t.Errorf("got %v; want %v", rc.routes, wantRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildcardDomains(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
rc := &routeCollector{}
|
||||
a := NewAppConnector(t.Logf, rc)
|
||||
|
||||
a.updateDomains([]string{"*.example.com"})
|
||||
if err := a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8")); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
|
||||
t.Errorf("routes: got %v; want %v", got, want)
|
||||
}
|
||||
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("wildcards: got %v; want %v", got, want)
|
||||
}
|
||||
a.UpdateDomains([]string{"*.example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
|
||||
if got, want := rc.routes, []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
|
||||
t.Errorf("routes: got %v; want %v", got, want)
|
||||
}
|
||||
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("wildcards: got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
a.updateDomains([]string{"*.example.com", "example.com"})
|
||||
if _, ok := a.domains["foo.example.com"]; !ok {
|
||||
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard")
|
||||
}
|
||||
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("wildcards: got %v; want %v", got, want)
|
||||
}
|
||||
a.UpdateDomains([]string{"*.example.com", "example.com"})
|
||||
if _, ok := a.domains["foo.example.com"]; !ok {
|
||||
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard")
|
||||
}
|
||||
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("wildcards: got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// There was an early regression where the wildcard domain was added repeatedly, this guards against that.
|
||||
a.updateDomains([]string{"*.example.com", "example.com"})
|
||||
if len(a.wildcards) != 1 {
|
||||
t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
|
||||
}
|
||||
// There was an early regression where the wildcard domain was added repeatedly, this guards against that.
|
||||
a.UpdateDomains([]string{"*.example.com", "example.com"})
|
||||
if len(a.wildcards) != 1 {
|
||||
t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,325 +148,15 @@ func dnsResponse(domain, address string) []byte {
|
||||
return must.Get(b.Finish())
|
||||
}
|
||||
|
||||
func dnsCNAMEResponse(address string, domains ...string) []byte {
|
||||
addr := netip.MustParseAddr(address)
|
||||
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
|
||||
b.EnableCompression()
|
||||
b.StartAnswers()
|
||||
|
||||
if len(domains) >= 2 {
|
||||
for i, domain := range domains[:len(domains)-1] {
|
||||
b.CNAMEResource(
|
||||
dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName(domain),
|
||||
Type: dnsmessage.TypeCNAME,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0,
|
||||
},
|
||||
dnsmessage.CNAMEResource{
|
||||
CNAME: dnsmessage.MustNewName(domains[i+1]),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
domain := domains[len(domains)-1]
|
||||
|
||||
switch addr.BitLen() {
|
||||
case 32:
|
||||
b.AResource(
|
||||
dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName(domain),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0,
|
||||
},
|
||||
dnsmessage.AResource{
|
||||
A: addr.As4(),
|
||||
},
|
||||
)
|
||||
case 128:
|
||||
b.AAAAResource(
|
||||
dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName(domain),
|
||||
Type: dnsmessage.TypeAAAA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0,
|
||||
},
|
||||
dnsmessage.AAAAResource{
|
||||
AAAA: addr.As16(),
|
||||
},
|
||||
)
|
||||
default:
|
||||
panic("invalid address length")
|
||||
}
|
||||
return must.Get(b.Finish())
|
||||
// routeCollector is a test helper that collects the list of routes advertised
|
||||
type routeCollector struct {
|
||||
routes []netip.Prefix
|
||||
}
|
||||
|
||||
func prefixEqual(a, b netip.Prefix) bool {
|
||||
return a == b
|
||||
}
|
||||
|
||||
func prefixCompare(a, b netip.Prefix) int {
|
||||
if a.Addr().Compare(b.Addr()) == 0 {
|
||||
return a.Bits() - b.Bits()
|
||||
}
|
||||
return a.Addr().Compare(b.Addr())
|
||||
}
|
||||
|
||||
func prefixes(in ...string) []netip.Prefix {
|
||||
toRet := make([]netip.Prefix, len(in))
|
||||
for i, s := range in {
|
||||
toRet[i] = netip.MustParsePrefix(s)
|
||||
}
|
||||
return toRet
|
||||
}
|
||||
|
||||
func TestUpdateRouteRouteRemoval(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
|
||||
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
|
||||
if !slices.Equal(routes, rc.Routes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
|
||||
}
|
||||
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
|
||||
}
|
||||
}
|
||||
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
// nothing has yet been advertised
|
||||
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.2/32"))
|
||||
a.Wait(ctx)
|
||||
// the routes passed to UpdateDomainsAndRoutes have been advertised
|
||||
assertRoutes("simple update", prefixes("1.2.3.1/32", "1.2.3.2/32"), []netip.Prefix{})
|
||||
|
||||
// one route the same, one different
|
||||
a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.3/32"))
|
||||
a.Wait(ctx)
|
||||
// old behavior: routes are not removed, resulting routes are both old and new
|
||||
// (we have dupe 1.2.3.1 routes because the test RouteAdvertiser doesn't have the deduplication
|
||||
// the real one does)
|
||||
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.1/32", "1.2.3.3/32")
|
||||
wantRemovedRoutes := []netip.Prefix{}
|
||||
if shouldStore {
|
||||
// new behavior: routes are removed, resulting routes are new only
|
||||
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.1/32", "1.2.3.3/32")
|
||||
wantRemovedRoutes = prefixes("1.2.3.2/32")
|
||||
}
|
||||
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDomainRouteRemoval(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
|
||||
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
|
||||
if !slices.Equal(routes, rc.Routes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
|
||||
}
|
||||
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
|
||||
}
|
||||
}
|
||||
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com", "b.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// adding domains doesn't immediately cause any routes to be advertised
|
||||
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
for _, res := range [][]byte{
|
||||
dnsResponse("a.example.com.", "1.2.3.1"),
|
||||
dnsResponse("a.example.com.", "1.2.3.2"),
|
||||
dnsResponse("b.example.com.", "1.2.3.3"),
|
||||
dnsResponse("b.example.com.", "1.2.3.4"),
|
||||
} {
|
||||
if err := a.ObserveDNSResponse(res); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
}
|
||||
a.Wait(ctx)
|
||||
// observing dns responses causes routes to be advertised
|
||||
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// old behavior, routes are not removed
|
||||
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32")
|
||||
wantRemovedRoutes := []netip.Prefix{}
|
||||
if shouldStore {
|
||||
// new behavior, routes are removed for b.example.com
|
||||
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32")
|
||||
wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32")
|
||||
}
|
||||
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWildcardRouteRemoval(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
|
||||
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
|
||||
if !slices.Equal(routes, rc.Routes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
|
||||
}
|
||||
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
|
||||
}
|
||||
}
|
||||
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com", "*.b.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// adding domains doesn't immediately cause any routes to be advertised
|
||||
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
for _, res := range [][]byte{
|
||||
dnsResponse("a.example.com.", "1.2.3.1"),
|
||||
dnsResponse("a.example.com.", "1.2.3.2"),
|
||||
dnsResponse("1.b.example.com.", "1.2.3.3"),
|
||||
dnsResponse("2.b.example.com.", "1.2.3.4"),
|
||||
} {
|
||||
if err := a.ObserveDNSResponse(res); err != nil {
|
||||
t.Errorf("ObserveDNSResponse: %v", err)
|
||||
}
|
||||
}
|
||||
a.Wait(ctx)
|
||||
// observing dns responses causes routes to be advertised
|
||||
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// old behavior, routes are not removed
|
||||
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32")
|
||||
wantRemovedRoutes := []netip.Prefix{}
|
||||
if shouldStore {
|
||||
// new behavior, routes are removed for *.b.example.com
|
||||
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32")
|
||||
wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32")
|
||||
}
|
||||
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutesWithout(t *testing.T) {
|
||||
assert := func(msg string, got, want []netip.Prefix) {
|
||||
if !slices.Equal(want, got) {
|
||||
t.Errorf("%s: want %v, got %v", msg, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
assert("empty routes", routesWithout([]netip.Prefix{}, []netip.Prefix{}), []netip.Prefix{})
|
||||
assert("a empty", routesWithout([]netip.Prefix{}, prefixes("1.1.1.1/32", "1.1.1.2/32")), []netip.Prefix{})
|
||||
assert("b empty", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), []netip.Prefix{}), prefixes("1.1.1.1/32", "1.1.1.2/32"))
|
||||
assert("no overlap", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.3/32", "1.1.1.4/32")), prefixes("1.1.1.1/32", "1.1.1.2/32"))
|
||||
assert("a has fewer", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32")), []netip.Prefix{})
|
||||
assert("a has more", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32"), prefixes("1.1.1.1/32", "1.1.1.3/32")), prefixes("1.1.1.2/32", "1.1.1.4/32"))
|
||||
}
|
||||
|
||||
func TestRateLogger(t *testing.T) {
|
||||
clock := tstest.Clock{}
|
||||
wasCalled := false
|
||||
rl := newRateLogger(func() time.Time { return clock.Now() }, 1*time.Second, func(count int64, _ time.Time, _ int64) {
|
||||
if count != 3 {
|
||||
t.Fatalf("count for prev period: got %d, want 3", count)
|
||||
}
|
||||
wasCalled = true
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
clock.Advance(1 * time.Millisecond)
|
||||
rl.update(0)
|
||||
if wasCalled {
|
||||
t.Fatalf("wasCalled: got true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
clock.Advance(1 * time.Second)
|
||||
rl.update(0)
|
||||
if !wasCalled {
|
||||
t.Fatalf("wasCalled: got false, want true")
|
||||
}
|
||||
|
||||
wasCalled = false
|
||||
rl = newRateLogger(func() time.Time { return clock.Now() }, 1*time.Hour, func(count int64, _ time.Time, _ int64) {
|
||||
if count != 3 {
|
||||
t.Fatalf("count for prev period: got %d, want 3", count)
|
||||
}
|
||||
wasCalled = true
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
clock.Advance(1 * time.Minute)
|
||||
rl.update(0)
|
||||
if wasCalled {
|
||||
t.Fatalf("wasCalled: got true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
clock.Advance(1 * time.Hour)
|
||||
rl.update(0)
|
||||
if !wasCalled {
|
||||
t.Fatalf("wasCalled: got false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteStoreMetrics(t *testing.T) {
|
||||
metricStoreRoutes(1, 1)
|
||||
metricStoreRoutes(1, 1) // the 1 buckets value should be 2
|
||||
metricStoreRoutes(5, 5) // the 5 buckets value should be 1
|
||||
metricStoreRoutes(6, 6) // the 10 buckets value should be 1
|
||||
metricStoreRoutes(10001, 10001) // the over buckets value should be 1
|
||||
wanted := map[string]int64{
|
||||
"appc_store_routes_n_routes_1": 2,
|
||||
"appc_store_routes_rate_1": 2,
|
||||
"appc_store_routes_n_routes_5": 1,
|
||||
"appc_store_routes_rate_5": 1,
|
||||
"appc_store_routes_n_routes_10": 1,
|
||||
"appc_store_routes_rate_10": 1,
|
||||
"appc_store_routes_n_routes_over": 1,
|
||||
"appc_store_routes_rate_over": 1,
|
||||
}
|
||||
for _, x := range clientmetric.Metrics() {
|
||||
if x.Value() != wanted[x.Name()] {
|
||||
t.Errorf("%s: want: %d, got: %d", x.Name(), wanted[x.Name()], x.Value())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricBucketsAreSorted(t *testing.T) {
|
||||
if !slices.IsSorted(metricStoreRoutesRateBuckets) {
|
||||
t.Errorf("metricStoreRoutesRateBuckets must be in order")
|
||||
}
|
||||
if !slices.IsSorted(metricStoreRoutesNBuckets) {
|
||||
t.Errorf("metricStoreRoutesNBuckets must be in order")
|
||||
}
|
||||
// routeCollector implements RouteAdvertiser
|
||||
var _ RouteAdvertiser = (*routeCollector)(nil)
|
||||
|
||||
func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
|
||||
rc.routes = append(rc.routes, pfx)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package appctest contains code to help test App Connectors.
|
||||
package appctest
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// RouteCollector is a test helper that collects the list of routes advertised
|
||||
type RouteCollector struct {
|
||||
routes []netip.Prefix
|
||||
removedRoutes []netip.Prefix
|
||||
}
|
||||
|
||||
func (rc *RouteCollector) AdvertiseRoute(pfx ...netip.Prefix) error {
|
||||
rc.routes = append(rc.routes, pfx...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rc *RouteCollector) UnadvertiseRoute(toRemove ...netip.Prefix) error {
|
||||
routes := rc.routes
|
||||
rc.routes = rc.routes[:0]
|
||||
for _, r := range routes {
|
||||
if !slices.Contains(toRemove, r) {
|
||||
rc.routes = append(rc.routes, r)
|
||||
} else {
|
||||
rc.removedRoutes = append(rc.removedRoutes, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovedRoutes returns the list of routes that were removed.
|
||||
func (rc *RouteCollector) RemovedRoutes() []netip.Prefix {
|
||||
return rc.removedRoutes
|
||||
}
|
||||
|
||||
// Routes returns the ordered list of routes that were added, including
|
||||
// possible duplicates.
|
||||
func (rc *RouteCollector) Routes() []netip.Prefix {
|
||||
return rc.routes
|
||||
}
|
||||
|
||||
func (rc *RouteCollector) SetRoutes(routes []netip.Prefix) error {
|
||||
rc.routes = routes
|
||||
return nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build tailscale_go
|
||||
|
||||
package tailscaleroot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
tsRev, ok := tailscaleToolchainRev()
|
||||
if !ok {
|
||||
panic("binary built with tailscale_go build tag but failed to read build info or find tailscale.toolchain.rev in build info")
|
||||
}
|
||||
want := strings.TrimSpace(GoToolchainRev)
|
||||
if tsRev != want {
|
||||
if os.Getenv("TS_PERMIT_TOOLCHAIN_MISMATCH") == "1" {
|
||||
fmt.Fprintf(os.Stderr, "tailscale.toolchain.rev = %q, want %q; but ignoring due to TS_PERMIT_TOOLCHAIN_MISMATCH=1\n", tsRev, want)
|
||||
return
|
||||
}
|
||||
panic(fmt.Sprintf("binary built with tailscale_go build tag but Go toolchain %q doesn't match github.com/tailscale/tailscale expected value %q; override this failure with TS_PERMIT_TOOLCHAIN_MISMATCH=1", tsRev, want))
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,8 @@ import (
|
||||
)
|
||||
|
||||
// WriteFile writes data to filename+some suffix, then renames it into filename.
|
||||
// The perm argument is ignored on Windows, but if the target filename already
|
||||
// exists then the target file's attributes and ACLs are preserved. If the target
|
||||
// filename already exists but is not a regular file, WriteFile returns an error.
|
||||
// The perm argument is ignored on Windows. If the target filename already
|
||||
// exists but is not a regular file, WriteFile returns an error.
|
||||
func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
|
||||
fi, err := os.Stat(filename)
|
||||
if err == nil && !fi.Mode().IsRegular() {
|
||||
@@ -48,5 +47,5 @@ func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return rename(tmpName, filename)
|
||||
return os.Rename(tmpName, filename)
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func rename(srcFile, destFile string) error {
|
||||
return os.Rename(srcFile, destFile)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func rename(srcFile, destFile string) error {
|
||||
// Use replaceFile when possible to preserve the original file's attributes and ACLs.
|
||||
if err := replaceFile(destFile, srcFile); err == nil || err != windows.ERROR_FILE_NOT_FOUND {
|
||||
return err
|
||||
}
|
||||
// destFile doesn't exist. Just do a normal rename.
|
||||
return os.Rename(srcFile, destFile)
|
||||
}
|
||||
|
||||
func replaceFile(destFile, srcFile string) error {
|
||||
destFile16, err := windows.UTF16PtrFromString(destFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcFile16, err := windows.UTF16PtrFromString(srcFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return replaceFileW(destFile16, srcFile16, nil, 0, nil, nil)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var _SECURITY_RESOURCE_MANAGER_AUTHORITY = windows.SidIdentifierAuthority{[6]byte{0, 0, 0, 0, 0, 9}}
|
||||
|
||||
// makeRandomSID generates a SID derived from a v4 GUID.
|
||||
// This is basically the same algorithm used by browser sandboxes for generating
|
||||
// random SIDs.
|
||||
func makeRandomSID() (*windows.SID, error) {
|
||||
guid, err := windows.GenerateGUID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rids := *((*[4]uint32)(unsafe.Pointer(&guid)))
|
||||
|
||||
var pSID *windows.SID
|
||||
if err := windows.AllocateAndInitializeSid(&_SECURITY_RESOURCE_MANAGER_AUTHORITY, 4, rids[0], rids[1], rids[2], rids[3], 0, 0, 0, 0, &pSID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer windows.FreeSid(pSID)
|
||||
|
||||
// Make a copy that lives on the Go heap
|
||||
return pSID.Copy()
|
||||
}
|
||||
|
||||
func getExistingFileSD(name string) (*windows.SECURITY_DESCRIPTOR, error) {
|
||||
const infoFlags = windows.DACL_SECURITY_INFORMATION
|
||||
return windows.GetNamedSecurityInfo(name, windows.SE_FILE_OBJECT, infoFlags)
|
||||
}
|
||||
|
||||
func getExistingFileDACL(name string) (*windows.ACL, error) {
|
||||
sd, err := getExistingFileSD(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dacl, _, err := sd.DACL()
|
||||
return dacl, err
|
||||
}
|
||||
|
||||
func addDenyACEForRandomSID(dacl *windows.ACL) (*windows.ACL, error) {
|
||||
randomSID, err := makeRandomSID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
randomSIDTrustee := windows.TRUSTEE{nil, windows.NO_MULTIPLE_TRUSTEE,
|
||||
windows.TRUSTEE_IS_SID, windows.TRUSTEE_IS_UNKNOWN,
|
||||
windows.TrusteeValueFromSID(randomSID)}
|
||||
|
||||
entries := []windows.EXPLICIT_ACCESS{
|
||||
{
|
||||
windows.GENERIC_ALL,
|
||||
windows.DENY_ACCESS,
|
||||
windows.NO_INHERITANCE,
|
||||
randomSIDTrustee,
|
||||
},
|
||||
}
|
||||
|
||||
return windows.ACLFromEntries(entries, dacl)
|
||||
}
|
||||
|
||||
func setExistingFileDACL(name string, dacl *windows.ACL) error {
|
||||
return windows.SetNamedSecurityInfo(name, windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil)
|
||||
}
|
||||
|
||||
// makeOrigFileWithCustomDACL creates a new, temporary file with a custom
|
||||
// DACL that we can check for later. It returns the name of the temporary
|
||||
// file and the security descriptor for the file in SDDL format.
|
||||
func makeOrigFileWithCustomDACL() (name, sddl string, err error) {
|
||||
f, err := os.CreateTemp("", "foo*.tmp")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
name = f.Name()
|
||||
if err := f.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
f = nil
|
||||
defer func() {
|
||||
if err != nil {
|
||||
os.Remove(name)
|
||||
}
|
||||
}()
|
||||
|
||||
dacl, err := getExistingFileDACL(name)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Add a harmless, deny-only ACE for a random SID that isn't used for anything
|
||||
// (but that we can check for later).
|
||||
dacl, err = addDenyACEForRandomSID(dacl)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := setExistingFileDACL(name, dacl); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
sd, err := getExistingFileSD(name)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return name, sd.String(), nil
|
||||
}
|
||||
|
||||
func TestPreserveSecurityInfo(t *testing.T) {
|
||||
// Make a test file with a custom ACL.
|
||||
origFileName, want, err := makeOrigFileWithCustomDACL()
|
||||
if err != nil {
|
||||
t.Fatalf("makeOrigFileWithCustomDACL returned %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
os.Remove(origFileName)
|
||||
})
|
||||
|
||||
if err := WriteFile(origFileName, []byte{}, 0); err != nil {
|
||||
t.Fatalf("WriteFile returned %v", err)
|
||||
}
|
||||
|
||||
// We expect origFileName's security descriptor to be unchanged despite
|
||||
// the WriteFile call.
|
||||
sd, err := getExistingFileSD(origFileName)
|
||||
if err != nil {
|
||||
t.Fatalf("getExistingFileSD(%q) returned %v", origFileName, err)
|
||||
}
|
||||
|
||||
if got := sd.String(); got != want {
|
||||
t.Errorf("security descriptor comparison failed: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package atomicfile
|
||||
|
||||
//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go mksyscall.go
|
||||
|
||||
//sys replaceFileW(replaced *uint16, replacement *uint16, backup *uint16, flags uint32, exclude unsafe.Pointer, reserved unsafe.Pointer) (err error) [int32(failretval)==0] = kernel32.ReplaceFileW
|
||||
@@ -1,52 +0,0 @@
|
||||
// Code generated by 'go generate'; DO NOT EDIT.
|
||||
|
||||
package atomicfile
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var _ unsafe.Pointer
|
||||
|
||||
// Do the interface allocations only once for common
|
||||
// Errno values.
|
||||
const (
|
||||
errnoERROR_IO_PENDING = 997
|
||||
)
|
||||
|
||||
var (
|
||||
errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING)
|
||||
errERROR_EINVAL error = syscall.EINVAL
|
||||
)
|
||||
|
||||
// errnoErr returns common boxed Errno values, to prevent
|
||||
// allocations at runtime.
|
||||
func errnoErr(e syscall.Errno) error {
|
||||
switch e {
|
||||
case 0:
|
||||
return errERROR_EINVAL
|
||||
case errnoERROR_IO_PENDING:
|
||||
return errERROR_IO_PENDING
|
||||
}
|
||||
// TODO: add more here, after collecting data on the common
|
||||
// error values see on Windows. (perhaps when running
|
||||
// all.bat?)
|
||||
return e
|
||||
}
|
||||
|
||||
var (
|
||||
modkernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
|
||||
procReplaceFileW = modkernel32.NewProc("ReplaceFileW")
|
||||
)
|
||||
|
||||
func replaceFileW(replaced *uint16, replacement *uint16, backup *uint16, flags uint32, exclude unsafe.Pointer, reserved unsafe.Pointer) (err error) {
|
||||
r1, _, e1 := syscall.Syscall6(procReplaceFileW.Addr(), 6, uintptr(unsafe.Pointer(replaced)), uintptr(unsafe.Pointer(replacement)), uintptr(unsafe.Pointer(backup)), uintptr(flags), uintptr(exclude), uintptr(reserved))
|
||||
if int32(r1) == 0 {
|
||||
err = errnoErr(e1)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -37,7 +37,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,ts_omit_completion,ts_omit_ssh,ts_omit_wakeonlan,ts_omit_capture"
|
||||
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube"
|
||||
;;
|
||||
--box)
|
||||
shift
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# This script builds Tailscale container images using
|
||||
# github.com/tailscale/mkctr.
|
||||
# By default the images will be tagged with the current version and git
|
||||
# hash of this repository as produced by ./cmd/mkversion.
|
||||
# This is the image build mechanim used to build the official Tailscale
|
||||
# container images.
|
||||
# Runs `go build` with flags configured for docker distribution. All
|
||||
# it does differently from `go build` is burn git commit and version
|
||||
# information into the binaries inside docker, so that we can track down user
|
||||
# issues.
|
||||
#
|
||||
############################################################################
|
||||
#
|
||||
# WARNING: Tailscale is not yet officially supported in container
|
||||
# environments, such as Docker and Kubernetes. Though it should work, we
|
||||
# don't regularly test it, and we know there are some feature limitations.
|
||||
#
|
||||
# See current bugs tagged "containers":
|
||||
# https://github.com/tailscale/tailscale/labels/containers
|
||||
#
|
||||
############################################################################
|
||||
|
||||
set -eu
|
||||
|
||||
# Use the "go" binary from the "tool" directory (which is github.com/tailscale/go)
|
||||
export PATH="$PWD"/tool:"$PATH"
|
||||
export PATH=$PWD/tool:$PATH
|
||||
|
||||
eval "$(./build_dist.sh shellvars)"
|
||||
eval $(./build_dist.sh shellvars)
|
||||
|
||||
DEFAULT_TARGET="client"
|
||||
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
|
||||
DEFAULT_BASE="tailscale/alpine-base:3.18"
|
||||
# Set a few pre-defined OCI annotations. The source annotation is used by tools such as Renovate that scan the linked
|
||||
# Github repo to find release notes for any new image tags. Note that for official Tailscale images the default
|
||||
# annotations defined here will be overriden by release scripts that call this script.
|
||||
# https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
|
||||
DEFAULT_ANNOTATIONS="org.opencontainers.image.source=https://github.com/tailscale/tailscale/blob/main/build_docker.sh,org.opencontainers.image.vendor=Tailscale"
|
||||
|
||||
PUSH="${PUSH:-false}"
|
||||
TARGET="${TARGET:-${DEFAULT_TARGET}}"
|
||||
TAGS="${TAGS:-${DEFAULT_TAGS}}"
|
||||
BASE="${BASE:-${DEFAULT_BASE}}"
|
||||
PLATFORM="${PLATFORM:-}" # default to all platforms
|
||||
# OCI annotations that will be added to the image.
|
||||
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
|
||||
ANNOTATIONS="${ANNOTATIONS:-${DEFAULT_ANNOTATIONS}}"
|
||||
|
||||
case "$TARGET" in
|
||||
client)
|
||||
@@ -47,14 +48,11 @@ case "$TARGET" in
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
--annotations="${ANNOTATIONS}" \
|
||||
/usr/local/bin/containerboot
|
||||
;;
|
||||
k8s-operator)
|
||||
operator)
|
||||
DEFAULT_REPOS="tailscale/k8s-operator"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
@@ -65,33 +63,12 @@ case "$TARGET" in
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
--annotations="${ANNOTATIONS}" \
|
||||
/usr/local/bin/operator
|
||||
;;
|
||||
k8s-nameserver)
|
||||
DEFAULT_REPOS="tailscale/k8s-nameserver"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="tailscale.com/cmd/k8s-nameserver:/usr/local/bin/k8s-nameserver" \
|
||||
--ldflags=" \
|
||||
-X tailscale.com/version.longStamp=${VERSION_LONG} \
|
||||
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
--annotations="${ANNOTATIONS}" \
|
||||
/usr/local/bin/k8s-nameserver
|
||||
;;
|
||||
*)
|
||||
echo "unknown target: $TARGET"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
esac
|
||||
@@ -1,74 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest/deptest"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
sc, err := getServeConfigFromJSON([]byte("null"))
|
||||
if sc != nil {
|
||||
t.Errorf("want nil for null")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("reading null: %v", err)
|
||||
}
|
||||
|
||||
sc, err = getServeConfigFromJSON([]byte(`{"TCP":{}}`))
|
||||
if err != nil {
|
||||
t.Errorf("reading object: %v", err)
|
||||
} else if sc == nil {
|
||||
t.Errorf("want non-nil for object")
|
||||
} else if sc.TCP == nil {
|
||||
t.Errorf("want non-nil TCP for object")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoIsPeerNotFound(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
lc := &Client{
|
||||
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var std net.Dialer
|
||||
return std.DialContext(ctx, network, ts.Listener.Addr().(*net.TCPAddr).String())
|
||||
},
|
||||
}
|
||||
var k key.NodePublic
|
||||
if err := k.UnmarshalText([]byte("nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := lc.WhoIsNodeKey(context.Background(), k)
|
||||
if err != ErrPeerNotFound {
|
||||
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
|
||||
}
|
||||
res, err = lc.WhoIs(context.Background(), "1.2.3.4:5678")
|
||||
if err != ErrPeerNotFound {
|
||||
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
BadDeps: map[string]string{
|
||||
// Make sure we don't again accidentally bring in a dependency on
|
||||
// drive or its transitive dependencies
|
||||
"testing": "do not use testing package in production code",
|
||||
"tailscale.com/drive/driveimpl": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build cgo || !darwin
|
||||
|
||||
package systray
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
"github.com/fogleman/gg"
|
||||
)
|
||||
|
||||
// tsLogo represents the Tailscale logo displayed as the systray icon.
|
||||
type tsLogo struct {
|
||||
// dots represents the state of the 3x3 dot grid in the logo.
|
||||
// A 0 represents a gray dot, any other value is a white dot.
|
||||
dots [9]byte
|
||||
|
||||
// dotMask returns an image mask to be used when rendering the logo dots.
|
||||
dotMask func(dc *gg.Context, borderUnits int, radius int) *image.Alpha
|
||||
|
||||
// overlay is called after the dots are rendered to draw an additional overlay.
|
||||
overlay func(dc *gg.Context, borderUnits int, radius int)
|
||||
}
|
||||
|
||||
var (
|
||||
// disconnected is all gray dots
|
||||
disconnected = tsLogo{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
}}
|
||||
|
||||
// connected is the normal Tailscale logo
|
||||
connected = tsLogo{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 1,
|
||||
0, 1, 0,
|
||||
}}
|
||||
|
||||
// loading is a special tsLogo value that is not meant to be rendered directly,
|
||||
// but indicates that the loading animation should be shown.
|
||||
loading = tsLogo{dots: [9]byte{'l', 'o', 'a', 'd', 'i', 'n', 'g'}}
|
||||
|
||||
// loadingIcons are shown in sequence as an animated loading icon.
|
||||
loadingLogos = []tsLogo{
|
||||
{dots: [9]byte{
|
||||
0, 1, 1,
|
||||
1, 0, 1,
|
||||
0, 0, 1,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 1, 1,
|
||||
0, 0, 1,
|
||||
0, 1, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 1, 1,
|
||||
0, 0, 0,
|
||||
0, 0, 1,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 1,
|
||||
0, 1, 0,
|
||||
0, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 1, 0,
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
0, 0, 1,
|
||||
0, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 1,
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
1, 0, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
0, 0, 0,
|
||||
1, 1, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 0, 0,
|
||||
1, 1, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 0,
|
||||
0, 1, 0,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 0,
|
||||
0, 1, 1,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 1,
|
||||
0, 0, 1,
|
||||
}},
|
||||
{dots: [9]byte{
|
||||
0, 1, 0,
|
||||
0, 1, 1,
|
||||
1, 0, 1,
|
||||
}},
|
||||
}
|
||||
|
||||
// exitNodeOnline is the Tailscale logo with an additional arrow overlay in the corner.
|
||||
exitNodeOnline = tsLogo{
|
||||
dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 1,
|
||||
0, 1, 0,
|
||||
},
|
||||
// draw an arrow mask in the bottom right corner with a reasonably thick line width.
|
||||
dotMask: func(dc *gg.Context, borderUnits int, radius int) *image.Alpha {
|
||||
bu, r := float64(borderUnits), float64(radius)
|
||||
|
||||
x1 := r * (bu + 3.5)
|
||||
y := r * (bu + 7)
|
||||
x2 := x1 + (r * 5)
|
||||
|
||||
mc := gg.NewContext(dc.Width(), dc.Height())
|
||||
mc.DrawLine(x1, y, x2, y) // arrow center line
|
||||
mc.DrawLine(x2-(1.5*r), y-(1.5*r), x2, y) // top of arrow tip
|
||||
mc.DrawLine(x2-(1.5*r), y+(1.5*r), x2, y) // bottom of arrow tip
|
||||
mc.SetLineWidth(r * 3)
|
||||
mc.Stroke()
|
||||
return mc.AsMask()
|
||||
},
|
||||
// draw an arrow in the bottom right corner over the masked area.
|
||||
overlay: func(dc *gg.Context, borderUnits int, radius int) {
|
||||
bu, r := float64(borderUnits), float64(radius)
|
||||
|
||||
x1 := r * (bu + 3.5)
|
||||
y := r * (bu + 7)
|
||||
x2 := x1 + (r * 5)
|
||||
|
||||
dc.DrawLine(x1, y, x2, y) // arrow center line
|
||||
dc.DrawLine(x2-(1.5*r), y-(1.5*r), x2, y) // top of arrow tip
|
||||
dc.DrawLine(x2-(1.5*r), y+(1.5*r), x2, y) // bottom of arrow tip
|
||||
dc.SetColor(fg)
|
||||
dc.SetLineWidth(r)
|
||||
dc.Stroke()
|
||||
},
|
||||
}
|
||||
|
||||
// exitNodeOffline is the Tailscale logo with a red "x" in the corner.
|
||||
exitNodeOffline = tsLogo{
|
||||
dots: [9]byte{
|
||||
0, 0, 0,
|
||||
1, 1, 1,
|
||||
0, 1, 0,
|
||||
},
|
||||
// Draw a square that hides the four dots in the bottom right corner,
|
||||
dotMask: func(dc *gg.Context, borderUnits int, radius int) *image.Alpha {
|
||||
bu, r := float64(borderUnits), float64(radius)
|
||||
x := r * (bu + 3)
|
||||
|
||||
mc := gg.NewContext(dc.Width(), dc.Height())
|
||||
mc.DrawRectangle(x, x, r*6, r*6)
|
||||
mc.Fill()
|
||||
return mc.AsMask()
|
||||
},
|
||||
// draw a red "x" over the bottom right corner.
|
||||
overlay: func(dc *gg.Context, borderUnits int, radius int) {
|
||||
bu, r := float64(borderUnits), float64(radius)
|
||||
|
||||
x1 := r * (bu + 4)
|
||||
x2 := x1 + (r * 3.5)
|
||||
dc.DrawLine(x1, x1, x2, x2) // top-left to bottom-right stroke
|
||||
dc.DrawLine(x1, x2, x2, x1) // bottom-left to top-right stroke
|
||||
dc.SetColor(red)
|
||||
dc.SetLineWidth(r)
|
||||
dc.Stroke()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
bg = color.NRGBA{0, 0, 0, 255}
|
||||
fg = color.NRGBA{255, 255, 255, 255}
|
||||
gray = color.NRGBA{255, 255, 255, 102}
|
||||
red = color.NRGBA{229, 111, 74, 255}
|
||||
)
|
||||
|
||||
// render returns a PNG image of the logo.
|
||||
func (logo tsLogo) render() *bytes.Buffer {
|
||||
const borderUnits = 1
|
||||
return logo.renderWithBorder(borderUnits)
|
||||
}
|
||||
|
||||
// renderWithBorder returns a PNG image of the logo with the specified border width.
|
||||
// One border unit is equal to the radius of a tailscale logo dot.
|
||||
func (logo tsLogo) renderWithBorder(borderUnits int) *bytes.Buffer {
|
||||
const radius = 25
|
||||
dim := radius * (8 + borderUnits*2)
|
||||
|
||||
dc := gg.NewContext(dim, dim)
|
||||
dc.DrawRectangle(0, 0, float64(dim), float64(dim))
|
||||
dc.SetColor(bg)
|
||||
dc.Fill()
|
||||
|
||||
if logo.dotMask != nil {
|
||||
mask := logo.dotMask(dc, borderUnits, radius)
|
||||
dc.SetMask(mask)
|
||||
dc.InvertMask()
|
||||
}
|
||||
|
||||
for y := 0; y < 3; y++ {
|
||||
for x := 0; x < 3; x++ {
|
||||
px := (borderUnits + 1 + 3*x) * radius
|
||||
py := (borderUnits + 1 + 3*y) * radius
|
||||
col := fg
|
||||
if logo.dots[y*3+x] == 0 {
|
||||
col = gray
|
||||
}
|
||||
dc.DrawCircle(float64(px), float64(py), radius)
|
||||
dc.SetColor(col)
|
||||
dc.Fill()
|
||||
}
|
||||
}
|
||||
|
||||
if logo.overlay != nil {
|
||||
dc.ResetClip()
|
||||
logo.overlay(dc, borderUnits, radius)
|
||||
}
|
||||
|
||||
b := bytes.NewBuffer(nil)
|
||||
png.Encode(b, dc.Image())
|
||||
return b
|
||||
}
|
||||
|
||||
// setAppIcon renders logo and sets it as the systray icon.
|
||||
func setAppIcon(icon tsLogo) {
|
||||
if icon.dots == loading.dots {
|
||||
startLoadingAnimation()
|
||||
} else {
|
||||
stopLoadingAnimation()
|
||||
systray.SetIcon(icon.render().Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
loadingMu sync.Mutex // protects loadingCancel
|
||||
|
||||
// loadingCancel stops the loading animation in the systray icon.
|
||||
// This is nil if the animation is not currently active.
|
||||
loadingCancel func()
|
||||
)
|
||||
|
||||
// startLoadingAnimation starts the animated loading icon in the system tray.
|
||||
// The animation continues until [stopLoadingAnimation] is called.
|
||||
// If the loading animation is already active, this func does nothing.
|
||||
func startLoadingAnimation() {
|
||||
loadingMu.Lock()
|
||||
defer loadingMu.Unlock()
|
||||
|
||||
if loadingCancel != nil {
|
||||
// loading icon already displayed
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, loadingCancel = context.WithCancel(ctx)
|
||||
|
||||
go func() {
|
||||
t := time.NewTicker(500 * time.Millisecond)
|
||||
var i int
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
systray.SetIcon(loadingLogos[i].render().Bytes())
|
||||
i++
|
||||
if i >= len(loadingLogos) {
|
||||
i = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// stopLoadingAnimation stops the animated loading icon in the system tray.
|
||||
// If the loading animation is not currently active, this func does nothing.
|
||||
func stopLoadingAnimation() {
|
||||
loadingMu.Lock()
|
||||
defer loadingMu.Unlock()
|
||||
|
||||
if loadingCancel != nil {
|
||||
loadingCancel()
|
||||
loadingCancel = nil
|
||||
}
|
||||
}
|
||||
@@ -1,712 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build cgo || !darwin
|
||||
|
||||
// Package systray provides a minimal Tailscale systray application.
|
||||
package systray
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
"github.com/atotto/clipboard"
|
||||
dbus "github.com/godbus/dbus/v5"
|
||||
"github.com/toqueteos/webbrowser"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/slicesx"
|
||||
"tailscale.com/util/stringsx"
|
||||
)
|
||||
|
||||
var (
|
||||
// newMenuDelay is the amount of time to sleep after creating a new menu,
|
||||
// but before adding items to it. This works around a bug in some dbus implementations.
|
||||
newMenuDelay time.Duration
|
||||
|
||||
// if true, treat all mullvad exit node countries as single-city.
|
||||
// Instead of rendering a submenu with cities, just select the highest-priority peer.
|
||||
hideMullvadCities bool
|
||||
)
|
||||
|
||||
// Run starts the systray menu and blocks until the menu exits.
|
||||
func (menu *Menu) Run() {
|
||||
menu.updateState()
|
||||
|
||||
// exit cleanly on SIGINT and SIGTERM
|
||||
go func() {
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
select {
|
||||
case <-interrupt:
|
||||
menu.onExit()
|
||||
case <-menu.bgCtx.Done():
|
||||
}
|
||||
}()
|
||||
go menu.lc.IncrementCounter(menu.bgCtx, "systray_start", 1)
|
||||
|
||||
systray.Run(menu.onReady, menu.onExit)
|
||||
}
|
||||
|
||||
// Menu represents the systray menu, its items, and the current Tailscale state.
|
||||
type Menu struct {
|
||||
mu sync.Mutex // protects the entire Menu
|
||||
|
||||
lc local.Client
|
||||
status *ipnstate.Status
|
||||
curProfile ipn.LoginProfile
|
||||
allProfiles []ipn.LoginProfile
|
||||
|
||||
bgCtx context.Context // ctx for background tasks not involving menu item clicks
|
||||
bgCancel context.CancelFunc
|
||||
|
||||
// Top-level menu items
|
||||
connect *systray.MenuItem
|
||||
disconnect *systray.MenuItem
|
||||
self *systray.MenuItem
|
||||
exitNodes *systray.MenuItem
|
||||
more *systray.MenuItem
|
||||
quit *systray.MenuItem
|
||||
|
||||
rebuildCh chan struct{} // triggers a menu rebuild
|
||||
accountsCh chan ipn.ProfileID
|
||||
exitNodeCh chan tailcfg.StableNodeID // ID of selected exit node
|
||||
|
||||
eventCancel context.CancelFunc // cancel eventLoop
|
||||
|
||||
notificationIcon *os.File // icon used for desktop notifications
|
||||
}
|
||||
|
||||
func (menu *Menu) init() {
|
||||
if menu.bgCtx != nil {
|
||||
// already initialized
|
||||
return
|
||||
}
|
||||
|
||||
menu.rebuildCh = make(chan struct{}, 1)
|
||||
menu.accountsCh = make(chan ipn.ProfileID)
|
||||
menu.exitNodeCh = make(chan tailcfg.StableNodeID)
|
||||
|
||||
// dbus wants a file path for notification icons, so copy to a temp file.
|
||||
menu.notificationIcon, _ = os.CreateTemp("", "tailscale-systray.png")
|
||||
io.Copy(menu.notificationIcon, connected.renderWithBorder(3))
|
||||
|
||||
menu.bgCtx, menu.bgCancel = context.WithCancel(context.Background())
|
||||
go menu.watchIPNBus()
|
||||
}
|
||||
|
||||
func init() {
|
||||
if runtime.GOOS != "linux" {
|
||||
// so far, these tweaks are only needed on Linux
|
||||
return
|
||||
}
|
||||
|
||||
desktop := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP"))
|
||||
switch desktop {
|
||||
case "gnome":
|
||||
// GNOME expands submenus downward in the main menu, rather than flyouts to the side.
|
||||
// Either as a result of that or another limitation, there seems to be a maximum depth of submenus.
|
||||
// Mullvad countries that have a city submenu are not being rendered, and so can't be selected.
|
||||
// Handle this by simply treating all mullvad countries as single-city and select the best peer.
|
||||
hideMullvadCities = true
|
||||
case "kde":
|
||||
// KDE doesn't need a delay, and actually won't render submenus
|
||||
// if we delay for more than about 400µs.
|
||||
newMenuDelay = 0
|
||||
default:
|
||||
// Add a slight delay to ensure the menu is created before adding items.
|
||||
//
|
||||
// Systray implementations that use libdbusmenu sometimes process messages out of order,
|
||||
// resulting in errors such as:
|
||||
// (waybar:153009): LIBDBUSMENU-GTK-WARNING **: 18:07:11.551: Children but no menu, someone's been naughty with their 'children-display' property: 'submenu'
|
||||
//
|
||||
// See also: https://github.com/fyne-io/systray/issues/12
|
||||
newMenuDelay = 10 * time.Millisecond
|
||||
}
|
||||
}
|
||||
|
||||
// onReady is called by the systray package when the menu is ready to be built.
|
||||
func (menu *Menu) onReady() {
|
||||
log.Printf("starting")
|
||||
setAppIcon(disconnected)
|
||||
menu.rebuild()
|
||||
}
|
||||
|
||||
// updateState updates the Menu state from the Tailscale local client.
|
||||
func (menu *Menu) updateState() {
|
||||
menu.mu.Lock()
|
||||
defer menu.mu.Unlock()
|
||||
menu.init()
|
||||
|
||||
var err error
|
||||
menu.status, err = menu.lc.Status(menu.bgCtx)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
menu.curProfile, menu.allProfiles, err = menu.lc.ProfileStatus(menu.bgCtx)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
}
|
||||
|
||||
// rebuild the systray menu based on the current Tailscale state.
|
||||
//
|
||||
// We currently rebuild the entire menu because it is not easy to update the existing menu.
|
||||
// You cannot iterate over the items in a menu, nor can you remove some items like separators.
|
||||
// So for now we rebuild the whole thing, and can optimize this later if needed.
|
||||
func (menu *Menu) rebuild() {
|
||||
menu.mu.Lock()
|
||||
defer menu.mu.Unlock()
|
||||
menu.init()
|
||||
|
||||
if menu.eventCancel != nil {
|
||||
menu.eventCancel()
|
||||
}
|
||||
ctx := context.Background()
|
||||
ctx, menu.eventCancel = context.WithCancel(ctx)
|
||||
|
||||
systray.ResetMenu()
|
||||
|
||||
menu.connect = systray.AddMenuItem("Connect", "")
|
||||
menu.disconnect = systray.AddMenuItem("Disconnect", "")
|
||||
menu.disconnect.Hide()
|
||||
systray.AddSeparator()
|
||||
|
||||
// delay to prevent race setting icon on first start
|
||||
time.Sleep(newMenuDelay)
|
||||
|
||||
// Set systray menu icon and title.
|
||||
// Also adjust connect/disconnect menu items if needed.
|
||||
var backendState string
|
||||
if menu.status != nil {
|
||||
backendState = menu.status.BackendState
|
||||
}
|
||||
switch backendState {
|
||||
case ipn.Running.String():
|
||||
if menu.status.ExitNodeStatus != nil && !menu.status.ExitNodeStatus.ID.IsZero() {
|
||||
if menu.status.ExitNodeStatus.Online {
|
||||
setTooltip("Using exit node")
|
||||
setAppIcon(exitNodeOnline)
|
||||
} else {
|
||||
setTooltip("Exit node offline")
|
||||
setAppIcon(exitNodeOffline)
|
||||
}
|
||||
} else {
|
||||
setTooltip(fmt.Sprintf("Connected to %s", menu.status.CurrentTailnet.Name))
|
||||
setAppIcon(connected)
|
||||
}
|
||||
menu.connect.SetTitle("Connected")
|
||||
menu.connect.Disable()
|
||||
menu.disconnect.Show()
|
||||
menu.disconnect.Enable()
|
||||
case ipn.Starting.String():
|
||||
setTooltip("Connecting")
|
||||
setAppIcon(loading)
|
||||
default:
|
||||
setTooltip("Disconnected")
|
||||
setAppIcon(disconnected)
|
||||
}
|
||||
|
||||
account := "Account"
|
||||
if pt := profileTitle(menu.curProfile); pt != "" {
|
||||
account = pt
|
||||
}
|
||||
accounts := systray.AddMenuItem(account, "")
|
||||
setRemoteIcon(accounts, menu.curProfile.UserProfile.ProfilePicURL)
|
||||
time.Sleep(newMenuDelay)
|
||||
for _, profile := range menu.allProfiles {
|
||||
title := profileTitle(profile)
|
||||
var item *systray.MenuItem
|
||||
if profile.ID == menu.curProfile.ID {
|
||||
item = accounts.AddSubMenuItemCheckbox(title, "", true)
|
||||
} else {
|
||||
item = accounts.AddSubMenuItem(title, "")
|
||||
}
|
||||
setRemoteIcon(item, profile.UserProfile.ProfilePicURL)
|
||||
onClick(ctx, item, func(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case menu.accountsCh <- profile.ID:
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if menu.status != nil && menu.status.Self != nil && len(menu.status.Self.TailscaleIPs) > 0 {
|
||||
title := fmt.Sprintf("This Device: %s (%s)", menu.status.Self.HostName, menu.status.Self.TailscaleIPs[0])
|
||||
menu.self = systray.AddMenuItem(title, "")
|
||||
} else {
|
||||
menu.self = systray.AddMenuItem("This Device: not connected", "")
|
||||
menu.self.Disable()
|
||||
}
|
||||
systray.AddSeparator()
|
||||
|
||||
menu.rebuildExitNodeMenu(ctx)
|
||||
|
||||
if menu.status != nil {
|
||||
menu.more = systray.AddMenuItem("More settings", "")
|
||||
onClick(ctx, menu.more, func(_ context.Context) {
|
||||
webbrowser.Open("http://100.100.100.100/")
|
||||
})
|
||||
}
|
||||
|
||||
menu.quit = systray.AddMenuItem("Quit", "Quit the app")
|
||||
menu.quit.Enable()
|
||||
|
||||
go menu.eventLoop(ctx)
|
||||
}
|
||||
|
||||
// profileTitle returns the title string for a profile menu item.
|
||||
func profileTitle(profile ipn.LoginProfile) string {
|
||||
title := profile.Name
|
||||
if profile.NetworkProfile.DomainName != "" {
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||
// windows and mac don't support multi-line menu
|
||||
title += " (" + profile.NetworkProfile.DomainName + ")"
|
||||
} else {
|
||||
title += "\n" + profile.NetworkProfile.DomainName
|
||||
}
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
var (
|
||||
cacheMu sync.Mutex
|
||||
httpCache = map[string][]byte{} // URL => response body
|
||||
)
|
||||
|
||||
// setRemoteIcon sets the icon for menu to the specified remote image.
|
||||
// Remote images are fetched as needed and cached.
|
||||
func setRemoteIcon(menu *systray.MenuItem, urlStr string) {
|
||||
if menu == nil || urlStr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cacheMu.Lock()
|
||||
b, ok := httpCache[urlStr]
|
||||
if !ok {
|
||||
resp, err := http.Get(urlStr)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
b, _ = io.ReadAll(resp.Body)
|
||||
httpCache[urlStr] = b
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
cacheMu.Unlock()
|
||||
|
||||
if len(b) > 0 {
|
||||
menu.SetIcon(b)
|
||||
}
|
||||
}
|
||||
|
||||
// setTooltip sets the tooltip text for the systray icon.
|
||||
func setTooltip(text string) {
|
||||
if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
|
||||
systray.SetTooltip(text)
|
||||
} else {
|
||||
// on Linux, SetTitle actually sets the tooltip
|
||||
systray.SetTitle(text)
|
||||
}
|
||||
}
|
||||
|
||||
// eventLoop is the main event loop for handling click events on menu items
|
||||
// and responding to Tailscale state changes.
|
||||
// This method does not return until ctx.Done is closed.
|
||||
func (menu *Menu) eventLoop(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-menu.rebuildCh:
|
||||
menu.updateState()
|
||||
menu.rebuild()
|
||||
case <-menu.connect.ClickedCh:
|
||||
_, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
WantRunning: true,
|
||||
},
|
||||
WantRunningSet: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("error connecting: %v", err)
|
||||
}
|
||||
|
||||
case <-menu.disconnect.ClickedCh:
|
||||
_, err := menu.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
WantRunning: false,
|
||||
},
|
||||
WantRunningSet: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("error disconnecting: %v", err)
|
||||
}
|
||||
|
||||
case <-menu.self.ClickedCh:
|
||||
menu.copyTailscaleIP(menu.status.Self)
|
||||
|
||||
case id := <-menu.accountsCh:
|
||||
if err := menu.lc.SwitchProfile(ctx, id); err != nil {
|
||||
log.Printf("error switching to profile ID %v: %v", id, err)
|
||||
}
|
||||
|
||||
case exitNode := <-menu.exitNodeCh:
|
||||
if exitNode.IsZero() {
|
||||
log.Print("disable exit node")
|
||||
if err := menu.lc.SetUseExitNode(ctx, false); err != nil {
|
||||
log.Printf("error disabling exit node: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("enable exit node: %v", exitNode)
|
||||
mp := &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
ExitNodeID: exitNode,
|
||||
},
|
||||
ExitNodeIDSet: true,
|
||||
}
|
||||
if _, err := menu.lc.EditPrefs(ctx, mp); err != nil {
|
||||
log.Printf("error setting exit node: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
case <-menu.quit.ClickedCh:
|
||||
systray.Quit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// onClick registers a click handler for a menu item.
|
||||
func onClick(ctx context.Context, item *systray.MenuItem, fn func(ctx context.Context)) {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-item.ClickedCh:
|
||||
fn(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// watchIPNBus subscribes to the tailscale event bus and sends state updates to chState.
|
||||
// This method does not return.
|
||||
func (menu *Menu) watchIPNBus() {
|
||||
for {
|
||||
if err := menu.watchIPNBusInner(); err != nil {
|
||||
log.Println(err)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// If the context got canceled, we will never be able to
|
||||
// reconnect to IPN bus, so exit the process.
|
||||
log.Fatalf("watchIPNBus: %v", err)
|
||||
}
|
||||
}
|
||||
// If our watch connection breaks, wait a bit before reconnecting. No
|
||||
// reason to spam the logs if e.g. tailscaled is restarting or goes
|
||||
// down.
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (menu *Menu) watchIPNBusInner() error {
|
||||
watcher, err := menu.lc.WatchIPNBus(menu.bgCtx, ipn.NotifyNoPrivateKeys)
|
||||
if err != nil {
|
||||
return fmt.Errorf("watching ipn bus: %w", err)
|
||||
}
|
||||
defer watcher.Close()
|
||||
for {
|
||||
select {
|
||||
case <-menu.bgCtx.Done():
|
||||
return nil
|
||||
default:
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ipnbus error: %w", err)
|
||||
}
|
||||
var rebuild bool
|
||||
if n.State != nil {
|
||||
log.Printf("new state: %v", n.State)
|
||||
rebuild = true
|
||||
}
|
||||
if n.Prefs != nil {
|
||||
rebuild = true
|
||||
}
|
||||
if rebuild {
|
||||
menu.rebuildCh <- struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// copyTailscaleIP copies the first Tailscale IP of the given device to the clipboard
|
||||
// and sends a notification with the copied value.
|
||||
func (menu *Menu) copyTailscaleIP(device *ipnstate.PeerStatus) {
|
||||
if device == nil || len(device.TailscaleIPs) == 0 {
|
||||
return
|
||||
}
|
||||
name := strings.Split(device.DNSName, ".")[0]
|
||||
ip := device.TailscaleIPs[0].String()
|
||||
err := clipboard.WriteAll(ip)
|
||||
if err != nil {
|
||||
log.Printf("clipboard error: %v", err)
|
||||
}
|
||||
|
||||
menu.sendNotification(fmt.Sprintf("Copied Address for %v", name), ip)
|
||||
}
|
||||
|
||||
// sendNotification sends a desktop notification with the given title and content.
|
||||
func (menu *Menu) sendNotification(title, content string) {
|
||||
conn, err := dbus.SessionBus()
|
||||
if err != nil {
|
||||
log.Printf("dbus: %v", err)
|
||||
return
|
||||
}
|
||||
timeout := 3 * time.Second
|
||||
obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
|
||||
call := obj.Call("org.freedesktop.Notifications.Notify", 0, "Tailscale", uint32(0),
|
||||
menu.notificationIcon.Name(), title, content, []string{}, map[string]dbus.Variant{}, int32(timeout.Milliseconds()))
|
||||
if call.Err != nil {
|
||||
log.Printf("dbus: %v", call.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func (menu *Menu) rebuildExitNodeMenu(ctx context.Context) {
|
||||
if menu.status == nil {
|
||||
return
|
||||
}
|
||||
|
||||
status := menu.status
|
||||
menu.exitNodes = systray.AddMenuItem("Exit Nodes", "")
|
||||
time.Sleep(newMenuDelay)
|
||||
|
||||
// register a click handler for a menu item to set nodeID as the exit node.
|
||||
setExitNodeOnClick := func(item *systray.MenuItem, nodeID tailcfg.StableNodeID) {
|
||||
onClick(ctx, item, func(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case menu.exitNodeCh <- nodeID:
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
noExitNodeMenu := menu.exitNodes.AddSubMenuItemCheckbox("None", "", status.ExitNodeStatus == nil)
|
||||
setExitNodeOnClick(noExitNodeMenu, "")
|
||||
|
||||
// Show recommended exit node if available.
|
||||
if status.Self.CapMap.Contains(tailcfg.NodeAttrSuggestExitNodeUI) {
|
||||
sugg, err := menu.lc.SuggestExitNode(ctx)
|
||||
if err == nil {
|
||||
title := "Recommended: "
|
||||
if loc := sugg.Location; loc.Valid() && loc.Country() != "" {
|
||||
flag := countryFlag(loc.CountryCode())
|
||||
title += fmt.Sprintf("%s %s: %s", flag, loc.Country(), loc.City())
|
||||
} else {
|
||||
title += strings.Split(sugg.Name, ".")[0]
|
||||
}
|
||||
menu.exitNodes.AddSeparator()
|
||||
rm := menu.exitNodes.AddSubMenuItemCheckbox(title, "", false)
|
||||
setExitNodeOnClick(rm, sugg.ID)
|
||||
if status.ExitNodeStatus != nil && sugg.ID == status.ExitNodeStatus.ID {
|
||||
rm.Check()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add tailnet exit nodes if present.
|
||||
var tailnetExitNodes []*ipnstate.PeerStatus
|
||||
for _, ps := range status.Peer {
|
||||
if ps.ExitNodeOption && ps.Location == nil {
|
||||
tailnetExitNodes = append(tailnetExitNodes, ps)
|
||||
}
|
||||
}
|
||||
if len(tailnetExitNodes) > 0 {
|
||||
menu.exitNodes.AddSeparator()
|
||||
menu.exitNodes.AddSubMenuItem("Tailnet Exit Nodes", "").Disable()
|
||||
for _, ps := range status.Peer {
|
||||
if !ps.ExitNodeOption || ps.Location != nil {
|
||||
continue
|
||||
}
|
||||
name := strings.Split(ps.DNSName, ".")[0]
|
||||
if !ps.Online {
|
||||
name += " (offline)"
|
||||
}
|
||||
sm := menu.exitNodes.AddSubMenuItemCheckbox(name, "", false)
|
||||
if !ps.Online {
|
||||
sm.Disable()
|
||||
}
|
||||
if status.ExitNodeStatus != nil && ps.ID == status.ExitNodeStatus.ID {
|
||||
sm.Check()
|
||||
}
|
||||
setExitNodeOnClick(sm, ps.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Add mullvad exit nodes if present.
|
||||
var mullvadExitNodes mullvadPeers
|
||||
if status.Self.CapMap.Contains("mullvad") {
|
||||
mullvadExitNodes = newMullvadPeers(status)
|
||||
}
|
||||
if len(mullvadExitNodes.countries) > 0 {
|
||||
menu.exitNodes.AddSeparator()
|
||||
menu.exitNodes.AddSubMenuItem("Location-based Exit Nodes", "").Disable()
|
||||
mullvadMenu := menu.exitNodes.AddSubMenuItemCheckbox("Mullvad VPN", "", false)
|
||||
|
||||
for _, country := range mullvadExitNodes.sortedCountries() {
|
||||
flag := countryFlag(country.code)
|
||||
countryMenu := mullvadMenu.AddSubMenuItemCheckbox(flag+" "+country.name, "", false)
|
||||
|
||||
// single-city country, no submenu
|
||||
if len(country.cities) == 1 || hideMullvadCities {
|
||||
setExitNodeOnClick(countryMenu, country.best.ID)
|
||||
if status.ExitNodeStatus != nil {
|
||||
for _, city := range country.cities {
|
||||
for _, ps := range city.peers {
|
||||
if status.ExitNodeStatus.ID == ps.ID {
|
||||
mullvadMenu.Check()
|
||||
countryMenu.Check()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// multi-city country, build submenu with "best available" option and cities.
|
||||
time.Sleep(newMenuDelay)
|
||||
bm := countryMenu.AddSubMenuItemCheckbox("Best Available", "", false)
|
||||
setExitNodeOnClick(bm, country.best.ID)
|
||||
countryMenu.AddSeparator()
|
||||
|
||||
for _, city := range country.sortedCities() {
|
||||
cityMenu := countryMenu.AddSubMenuItemCheckbox(city.name, "", false)
|
||||
setExitNodeOnClick(cityMenu, city.best.ID)
|
||||
if status.ExitNodeStatus != nil {
|
||||
for _, ps := range city.peers {
|
||||
if status.ExitNodeStatus.ID == ps.ID {
|
||||
mullvadMenu.Check()
|
||||
countryMenu.Check()
|
||||
cityMenu.Check()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: "Allow Local Network Access" and "Run Exit Node" menu items
|
||||
}
|
||||
|
||||
// mullvadPeers contains all mullvad peer nodes, sorted by country and city.
|
||||
type mullvadPeers struct {
|
||||
countries map[string]*mvCountry // country code (uppercase) => country
|
||||
}
|
||||
|
||||
// sortedCountries returns countries containing mullvad nodes, sorted by name.
|
||||
func (mp mullvadPeers) sortedCountries() []*mvCountry {
|
||||
countries := slicesx.MapValues(mp.countries)
|
||||
slices.SortFunc(countries, func(a, b *mvCountry) int {
|
||||
return stringsx.CompareFold(a.name, b.name)
|
||||
})
|
||||
return countries
|
||||
}
|
||||
|
||||
type mvCountry struct {
|
||||
code string
|
||||
name string
|
||||
best *ipnstate.PeerStatus // highest priority peer in the country
|
||||
cities map[string]*mvCity // city code => city
|
||||
}
|
||||
|
||||
// sortedCities returns cities containing mullvad nodes, sorted by name.
|
||||
func (mc *mvCountry) sortedCities() []*mvCity {
|
||||
cities := slicesx.MapValues(mc.cities)
|
||||
slices.SortFunc(cities, func(a, b *mvCity) int {
|
||||
return stringsx.CompareFold(a.name, b.name)
|
||||
})
|
||||
return cities
|
||||
}
|
||||
|
||||
// countryFlag takes a 2-character ASCII string and returns the corresponding emoji flag.
|
||||
// It returns the empty string on error.
|
||||
func countryFlag(code string) string {
|
||||
if len(code) != 2 {
|
||||
return ""
|
||||
}
|
||||
runes := make([]rune, 0, 2)
|
||||
for i := range 2 {
|
||||
b := code[i] | 32 // lowercase
|
||||
if b < 'a' || b > 'z' {
|
||||
return ""
|
||||
}
|
||||
// https://en.wikipedia.org/wiki/Regional_indicator_symbol
|
||||
runes = append(runes, 0x1F1E6+rune(b-'a'))
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
type mvCity struct {
|
||||
name string
|
||||
best *ipnstate.PeerStatus // highest priority peer in the city
|
||||
peers []*ipnstate.PeerStatus
|
||||
}
|
||||
|
||||
func newMullvadPeers(status *ipnstate.Status) mullvadPeers {
|
||||
countries := make(map[string]*mvCountry)
|
||||
for _, ps := range status.Peer {
|
||||
if !ps.ExitNodeOption || ps.Location == nil {
|
||||
continue
|
||||
}
|
||||
loc := ps.Location
|
||||
country, ok := countries[loc.CountryCode]
|
||||
if !ok {
|
||||
country = &mvCountry{
|
||||
code: loc.CountryCode,
|
||||
name: loc.Country,
|
||||
cities: make(map[string]*mvCity),
|
||||
}
|
||||
countries[loc.CountryCode] = country
|
||||
}
|
||||
city, ok := countries[loc.CountryCode].cities[loc.CityCode]
|
||||
if !ok {
|
||||
city = &mvCity{
|
||||
name: loc.City,
|
||||
}
|
||||
countries[loc.CountryCode].cities[loc.CityCode] = city
|
||||
}
|
||||
city.peers = append(city.peers, ps)
|
||||
if city.best == nil || ps.Location.Priority > city.best.Location.Priority {
|
||||
city.best = ps
|
||||
}
|
||||
if country.best == nil || ps.Location.Priority > country.best.Location.Priority {
|
||||
country.best = ps
|
||||
}
|
||||
}
|
||||
return mullvadPeers{countries}
|
||||
}
|
||||
|
||||
// onExit is called by the systray package when the menu is exiting.
|
||||
func (menu *Menu) onExit() {
|
||||
log.Printf("exiting")
|
||||
if menu.bgCancel != nil {
|
||||
menu.bgCancel()
|
||||
}
|
||||
if menu.eventCancel != nil {
|
||||
menu.eventCancel()
|
||||
}
|
||||
|
||||
os.Remove(menu.notificationIcon.Name())
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
// Only one of Src/Dst or Users/Ports may be specified.
|
||||
type ACLRow struct {
|
||||
Action string `json:"action,omitempty"` // valid values: "accept"
|
||||
Proto string `json:"proto,omitempty"` // protocol
|
||||
Users []string `json:"users,omitempty"` // old name for src
|
||||
Ports []string `json:"ports,omitempty"` // old name for dst
|
||||
Src []string `json:"src,omitempty"`
|
||||
@@ -32,23 +31,12 @@ type ACLRow struct {
|
||||
type ACLTest struct {
|
||||
Src string `json:"src,omitempty"` // source
|
||||
User string `json:"user,omitempty"` // old name for source
|
||||
Proto string `json:"proto,omitempty"` // protocol
|
||||
Accept []string `json:"accept,omitempty"` // expected destination ip:port that user can access
|
||||
Deny []string `json:"deny,omitempty"` // expected destination ip:port that user cannot access
|
||||
|
||||
Allow []string `json:"allow,omitempty"` // old name for accept
|
||||
}
|
||||
|
||||
// NodeAttrGrant defines additional string attributes that apply to specific devices.
|
||||
type NodeAttrGrant struct {
|
||||
// Target specifies which nodes the attributes apply to. The nodes can be a
|
||||
// tag (tag:server), user (alice@example.com), group (group:kids), or *.
|
||||
Target []string `json:"target,omitempty"`
|
||||
|
||||
// Attr are the attributes to set on Target(s).
|
||||
Attr []string `json:"attr,omitempty"`
|
||||
}
|
||||
|
||||
// ACLDetails contains all the details for an ACL.
|
||||
type ACLDetails struct {
|
||||
Tests []ACLTest `json:"tests,omitempty"`
|
||||
@@ -56,7 +44,6 @@ type ACLDetails struct {
|
||||
Groups map[string][]string `json:"groups,omitempty"`
|
||||
TagOwners map[string][]string `json:"tagowners,omitempty"`
|
||||
Hosts map[string]string `json:"hosts,omitempty"`
|
||||
NodeAttrs []NodeAttrGrant `json:"nodeAttrs,omitempty"`
|
||||
}
|
||||
|
||||
// ACL contains an ACLDetails and metadata.
|
||||
@@ -163,12 +150,7 @@ 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 is the source ("src") value of the ACL test that failed.
|
||||
// The name "user" is a legacy holdover from the original naming and
|
||||
// is kept for compatibility but it may also contain any value
|
||||
// that's valid in a ACL test "src" field.
|
||||
User string `json:"user,omitempty"`
|
||||
|
||||
User string `json:"user,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
@@ -288,17 +270,6 @@ type UserRuleMatch struct {
|
||||
Users []string `json:"users"`
|
||||
Ports []string `json:"ports"`
|
||||
LineNumber int `json:"lineNumber"`
|
||||
// Via is the list of targets through which Users can access Ports.
|
||||
// See https://tailscale.com/kb/1378/via for more information.
|
||||
Via []string `json:"via,omitempty"`
|
||||
|
||||
// Postures is a list of posture policies that are
|
||||
// associated with this match. The rules can be looked
|
||||
// up in the ACLPreviewResponse parent struct.
|
||||
// The source of the list is from srcPosture on
|
||||
// an ACL or Grant rule:
|
||||
// https://tailscale.com/kb/1288/device-posture#posture-conditions
|
||||
Postures []string `json:"postures"`
|
||||
}
|
||||
|
||||
// ACLPreviewResponse is the response type of previewACLPostRequest
|
||||
@@ -306,12 +277,6 @@ type ACLPreviewResponse struct {
|
||||
Matches []UserRuleMatch `json:"matches"` // ACL rules that match the specified user or ipport.
|
||||
Type string `json:"type"` // The request type: currently only "user" or "ipport".
|
||||
PreviewFor string `json:"previewFor"` // A specific user or ipport.
|
||||
|
||||
// Postures is a map of postures and associated rules that apply
|
||||
// to this preview.
|
||||
// For more details about the posture mapping, see:
|
||||
// https://tailscale.com/kb/1288/device-posture#postures
|
||||
Postures map[string][]string `json:"postures,omitempty"`
|
||||
}
|
||||
|
||||
// ACLPreview is the response type of PreviewACLForUser, PreviewACLForIPPort, PreviewACLHuJSONForUser, and PreviewACLHuJSONForIPPort
|
||||
@@ -319,12 +284,6 @@ type ACLPreview struct {
|
||||
Matches []UserRuleMatch `json:"matches"`
|
||||
User string `json:"user,omitempty"` // Filled if response of PreviewACLForUser or PreviewACLHuJSONForUser
|
||||
IPPort string `json:"ipport,omitempty"` // Filled if response of PreviewACLForIPPort or PreviewACLHuJSONForIPPort
|
||||
|
||||
// Postures is a map of postures and associated rules that apply
|
||||
// to this preview.
|
||||
// For more details about the posture mapping, see:
|
||||
// https://tailscale.com/kb/1288/device-posture#postures
|
||||
Postures map[string][]string `json:"postures,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) {
|
||||
@@ -382,9 +341,8 @@ func (c *Client) PreviewACLForUser(ctx context.Context, acl ACL, user string) (r
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -411,9 +369,8 @@ func (c *Client) PreviewACLForIPPort(ctx context.Context, acl ACL, ipport netip.
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -437,9 +394,8 @@ func (c *Client) PreviewACLHuJSONForUser(ctx context.Context, acl ACLHuJSON, use
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -463,9 +419,8 @@ func (c *Client) PreviewACLHuJSONForIPPort(ctx context.Context, acl ACLHuJSON, i
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,32 +4,11 @@
|
||||
// Package apitype contains types for the Tailscale LocalAPI and control plane API.
|
||||
package apitype
|
||||
|
||||
import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/util/ctxkey"
|
||||
)
|
||||
import "tailscale.com/tailcfg"
|
||||
|
||||
// LocalAPIHost is the Host header value used by the LocalAPI.
|
||||
const LocalAPIHost = "local-tailscaled.sock"
|
||||
|
||||
// RequestReasonHeader is the header used to pass justification for a LocalAPI request,
|
||||
// such as when a user wants to perform an action they don't have permission for,
|
||||
// and a policy allows it with justification. As of 2025-01-29, it is only used to
|
||||
// allow a user to disconnect Tailscale when the "always-on" mode is enabled.
|
||||
//
|
||||
// The header value is base64-encoded using the standard encoding defined in RFC 4648.
|
||||
//
|
||||
// See tailscale/corp#26146.
|
||||
const RequestReasonHeader = "X-Tailscale-Reason"
|
||||
|
||||
// RequestReasonKey is the context key used to pass the request reason
|
||||
// when making a LocalAPI request via [local.Client].
|
||||
// It's value is a raw string. An empty string means no reason was provided.
|
||||
//
|
||||
// See tailscale/corp#26146.
|
||||
var RequestReasonKey = ctxkey.New(RequestReasonHeader, "")
|
||||
|
||||
// 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 {
|
||||
@@ -70,27 +49,3 @@ type ReloadConfigResponse struct {
|
||||
Reloaded bool // whether the config was reloaded
|
||||
Err string // any error message
|
||||
}
|
||||
|
||||
// ExitNodeSuggestionResponse is the response to a LocalAPI suggest-exit-node GET request.
|
||||
// It returns the StableNodeID, name, and location of a suggested exit node for the client making the request.
|
||||
type ExitNodeSuggestionResponse struct {
|
||||
ID tailcfg.StableNodeID
|
||||
Name string
|
||||
Location tailcfg.LocationView `json:",omitempty"`
|
||||
}
|
||||
|
||||
// DNSOSConfig mimics dns.OSConfig without forcing us to import the entire dns package
|
||||
// into the CLI.
|
||||
type DNSOSConfig struct {
|
||||
Nameservers []string
|
||||
SearchDomains []string
|
||||
MatchDomains []string
|
||||
}
|
||||
|
||||
// DNSQueryResponse is the response to a DNS query request sent via LocalAPI.
|
||||
type DNSQueryResponse struct {
|
||||
// Bytes is the raw DNS response bytes.
|
||||
Bytes []byte
|
||||
// Resolvers is the list of resolvers that the forwarder deemed able to resolve the query.
|
||||
Resolvers []*dnstype.Resolver
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
@@ -40,7 +39,6 @@ type Device struct {
|
||||
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
|
||||
Addresses []string `json:"addresses"`
|
||||
DeviceID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
@@ -215,9 +213,6 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("RESP: %di, path: %s", resp.StatusCode, path)
|
||||
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -11,14 +11,13 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var lc local.Client
|
||||
s := &http.Server{
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: lc.GetCertificate,
|
||||
GetCertificate: tailscale.GetCertificate,
|
||||
},
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "<h1>Hello from Tailscale!</h1> It works.")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,106 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
// ErrPeerNotFound is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
var ErrPeerNotFound = local.ErrPeerNotFound
|
||||
|
||||
// LocalClient is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
type LocalClient = local.Client
|
||||
|
||||
// IPNBusWatcher is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
type IPNBusWatcher = local.IPNBusWatcher
|
||||
|
||||
// BugReportOpts is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
type BugReportOpts = local.BugReportOpts
|
||||
|
||||
// DebugPortMapOpts is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
type DebugPortmapOpts = local.DebugPortmapOpts
|
||||
|
||||
// PingOpts is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
type PingOpts = local.PingOpts
|
||||
|
||||
// GetCertificate is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return local.GetCertificate(hi)
|
||||
}
|
||||
|
||||
// SetVersionMismatchHandler is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
|
||||
local.SetVersionMismatchHandler(f)
|
||||
}
|
||||
|
||||
// IsAccessDeniedError is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func IsAccessDeniedError(err error) bool {
|
||||
return local.IsAccessDeniedError(err)
|
||||
}
|
||||
|
||||
// IsPreconditionsFailedError is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func IsPreconditionsFailedError(err error) bool {
|
||||
return local.IsPreconditionsFailedError(err)
|
||||
}
|
||||
|
||||
// WhoIs is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
return local.WhoIs(ctx, remoteAddr)
|
||||
}
|
||||
|
||||
// Status is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return local.Status(ctx)
|
||||
}
|
||||
|
||||
// StatusWithoutPeers is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return local.StatusWithoutPeers(ctx)
|
||||
}
|
||||
|
||||
// CertPair is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
return local.CertPair(ctx, domain)
|
||||
}
|
||||
|
||||
// ExpandSNIName is an alias for tailscale.com/client/local.
|
||||
//
|
||||
// Deprecated: import tailscale.com/client/local instead.
|
||||
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||||
return local.ExpandSNIName(ctx, name)
|
||||
}
|
||||
27
client/tailscale/localclient_test.go
Normal file
27
client/tailscale/localclient_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
sc, err := getServeConfigFromJSON([]byte("null"))
|
||||
if sc != nil {
|
||||
t.Errorf("want nil for null")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("reading null: %v", err)
|
||||
}
|
||||
|
||||
sc, err = getServeConfigFromJSON([]byte(`{"TCP":{}}`))
|
||||
if err != nil {
|
||||
t.Errorf("reading object: %v", err)
|
||||
} else if sc == nil {
|
||||
t.Errorf("want non-nil for object")
|
||||
} else if sc.TCP == nil {
|
||||
t.Errorf("want non-nil TCP for object")
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !go1.23
|
||||
//go:build !go1.21
|
||||
|
||||
package tailscale
|
||||
|
||||
func init() {
|
||||
you_need_Go_1_23_to_compile_Tailscale()
|
||||
you_need_Go_1_21_to_compile_Tailscale()
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
// Package tailscale contains a Go client for the Tailscale control plane API.
|
||||
// Package tailscale contains Go clients for the Tailscale LocalAPI and
|
||||
// Tailscale control plane API.
|
||||
//
|
||||
// This package is only intended for internal and transitional use.
|
||||
//
|
||||
// Deprecated: the official control plane client is available at
|
||||
// tailscale.com/client/tailscale/v2.
|
||||
// Warning: this package is in development and makes no API compatibility
|
||||
// promises as of 2022-04-29. It is subject to change at any time.
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
@@ -20,7 +19,10 @@ import (
|
||||
)
|
||||
|
||||
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
|
||||
// for now. This package is being replaced by tailscale.com/client/tailscale/v2.
|
||||
// for now. It was added 2022-04-29 when it was moved to this git repo
|
||||
// and will be removed when the public API has settled.
|
||||
//
|
||||
// TODO(bradfitz): remove this after the we're happy with the public API.
|
||||
var I_Acknowledge_This_API_Is_Unstable = false
|
||||
|
||||
// TODO: use url.PathEscape() for deviceID and tailnets when constructing requests.
|
||||
@@ -34,8 +36,6 @@ const maxReadSize = 10 << 20
|
||||
//
|
||||
// Use NewClient to instantiate one. Exported fields should be set before
|
||||
// the client is used and not changed thereafter.
|
||||
//
|
||||
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
|
||||
type Client struct {
|
||||
// tailnet is the globally unique identifier for a Tailscale network, such
|
||||
// as "example.com" or "user@gmail.com".
|
||||
@@ -51,9 +51,6 @@ type Client struct {
|
||||
// HTTPClient optionally specifies an alternate HTTP client to use.
|
||||
// If nil, http.DefaultClient is used.
|
||||
HTTPClient *http.Client
|
||||
|
||||
// UserAgent optionally specifies an alternate User-Agent header
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
@@ -98,13 +95,10 @@ func (c *Client) setAuth(r *http.Request) {
|
||||
// If httpClient is nil, then http.DefaultClient is used.
|
||||
// "api.tailscale.com" is set as the BaseURL for the returned client
|
||||
// and can be changed manually by the user.
|
||||
//
|
||||
// Deprecated: use tailscale.com/client/tailscale/v2 instead.
|
||||
func NewClient(tailnet string, auth AuthMethod) *Client {
|
||||
return &Client{
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
UserAgent: "tailscale-client-oss",
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,16 +110,17 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
if c.UserAgent != "" {
|
||||
req.Header.Set("User-Agent", c.UserAgent)
|
||||
}
|
||||
return c.httpClient().Do(req)
|
||||
}
|
||||
|
||||
// sendRequest add the authentication key to the request and sends it. It
|
||||
// receives the response and reads up to 10MB of it.
|
||||
func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
|
||||
resp, err := c.Do(req)
|
||||
if !I_Acknowledge_This_API_Is_Unstable {
|
||||
return nil, nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
|
||||
@@ -8,10 +8,8 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -223,7 +221,7 @@ func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) err
|
||||
|
||||
func (s *Server) newSessionID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
for range 5 {
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -234,106 +232,3 @@ func (s *Server) newSessionID() (string, error) {
|
||||
}
|
||||
return "", errors.New("too many collisions generating new session; please refresh page")
|
||||
}
|
||||
|
||||
// peerCapabilities holds information about what a source
|
||||
// peer is allowed to edit via the web UI.
|
||||
//
|
||||
// map value is true if the peer can edit the given feature.
|
||||
// Only capFeatures included in validCaps will be included.
|
||||
type peerCapabilities map[capFeature]bool
|
||||
|
||||
// canEdit is true if the peerCapabilities grant edit access
|
||||
// to the given feature.
|
||||
func (p peerCapabilities) canEdit(feature capFeature) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
if p[capFeatureAll] {
|
||||
return true
|
||||
}
|
||||
return p[feature]
|
||||
}
|
||||
|
||||
// isEmpty is true if p is either nil or has no capabilities
|
||||
// with value true.
|
||||
func (p peerCapabilities) isEmpty() bool {
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
for _, v := range p {
|
||||
if v == true {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type capFeature string
|
||||
|
||||
const (
|
||||
// The following values should not be edited.
|
||||
// New caps can be added, but existing ones should not be changed,
|
||||
// as these exact values are used by users in tailnet policy files.
|
||||
//
|
||||
// IMPORTANT: When adding a new cap, also update validCaps slice below.
|
||||
|
||||
capFeatureAll capFeature = "*" // grants peer management of all features
|
||||
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
|
||||
capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management
|
||||
capFeatureExitNodes capFeature = "exitnodes" // grants peer ability to advertise-as and use exit nodes
|
||||
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
|
||||
)
|
||||
|
||||
// validCaps contains the list of valid capabilities used in the web client.
|
||||
// Any capabilities included in a peer's grants that do not fall into this
|
||||
// list will be ignored.
|
||||
var validCaps []capFeature = []capFeature{
|
||||
capFeatureAll,
|
||||
capFeatureSSH,
|
||||
capFeatureSubnets,
|
||||
capFeatureExitNodes,
|
||||
capFeatureAccount,
|
||||
}
|
||||
|
||||
type capRule struct {
|
||||
CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
|
||||
}
|
||||
|
||||
// toPeerCapabilities parses out the web ui capabilities from the
|
||||
// given whois response.
|
||||
func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) {
|
||||
if whois == nil || status == nil {
|
||||
return peerCapabilities{}, nil
|
||||
}
|
||||
if whois.Node.IsTagged() {
|
||||
// We don't allow management *from* tagged nodes, so ignore caps.
|
||||
// The web client auth flow relies on having a true user identity
|
||||
// that can be verified through login.
|
||||
return peerCapabilities{}, nil
|
||||
}
|
||||
|
||||
if !status.Self.IsTagged() {
|
||||
// User owned nodes are only ever manageable by the owner.
|
||||
if status.Self.UserID != whois.UserProfile.ID {
|
||||
return peerCapabilities{}, nil
|
||||
} else {
|
||||
return peerCapabilities{capFeatureAll: true}, nil // owner can edit all features
|
||||
}
|
||||
}
|
||||
|
||||
// For tagged nodes, we actually look at the granted capabilities.
|
||||
caps := peerCapabilities{}
|
||||
rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal capability: %v", err)
|
||||
}
|
||||
for _, c := range rules {
|
||||
for _, f := range c.CanEdit {
|
||||
cap := capFeature(strings.ToLower(f))
|
||||
if slices.Contains(validCaps, cap) {
|
||||
caps[cap] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
"version": "0.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "18.20.4",
|
||||
"node": "18.16.1",
|
||||
"yarn": "1.22.19"
|
||||
},
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
@@ -20,28 +19,23 @@
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.16.1",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"eslint": "^8.23.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-curly-quotes": "^1.0.4",
|
||||
"jsdom": "^23.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.7",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"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": "^1.3.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
"@typescript-eslint/parser": "^6.2.1"
|
||||
"vitest": "^0.32.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
@@ -56,11 +50,9 @@
|
||||
"react-app"
|
||||
],
|
||||
"plugins": [
|
||||
"curly-quotes",
|
||||
"react-hooks"
|
||||
],
|
||||
"rules": {
|
||||
"curly-quotes/no-straight-quotes": "warn",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "error"
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import * as Primitive from "@radix-ui/react-popover"
|
||||
import cx from "classnames"
|
||||
import React, { useCallback } from "react"
|
||||
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
|
||||
import Copy from "src/assets/icons/copy.svg?react"
|
||||
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||
import { ReactComponent as Copy } from "src/assets/icons/copy.svg"
|
||||
import NiceIP from "src/components/nice-ip"
|
||||
import useToaster from "src/hooks/toaster"
|
||||
import Button from "src/ui/button"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
|
||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||
import LoginToggle from "src/components/login-toggle"
|
||||
import DeviceDetailsView from "src/components/views/device-details-view"
|
||||
import DisconnectedView from "src/components/views/disconnected-view"
|
||||
@@ -11,8 +11,8 @@ import LoginView from "src/components/views/login-view"
|
||||
import SSHView from "src/components/views/ssh-view"
|
||||
import SubnetRouterView from "src/components/views/subnet-router-view"
|
||||
import { UpdatingView } from "src/components/views/updating-view"
|
||||
import useAuth, { AuthResponse, canEdit } from "src/hooks/auth"
|
||||
import { Feature, NodeData, featureDescription } from "src/types"
|
||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||
import { Feature, featureDescription, NodeData } from "src/types"
|
||||
import Card from "src/ui/card"
|
||||
import EmptyState from "src/ui/empty-state"
|
||||
import LoadingDots from "src/ui/loading-dots"
|
||||
@@ -56,19 +56,16 @@ function WebClient({
|
||||
<Header node={node} auth={auth} newSession={newSession} />
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<HomeView node={node} auth={auth} />
|
||||
<HomeView readonly={!auth.canManageNode} node={node} />
|
||||
</Route>
|
||||
<Route path="/details">
|
||||
<DeviceDetailsView node={node} auth={auth} />
|
||||
<DeviceDetailsView readonly={!auth.canManageNode} node={node} />
|
||||
</Route>
|
||||
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
|
||||
<SubnetRouterView
|
||||
readonly={!canEdit("subnets", auth)}
|
||||
node={node}
|
||||
/>
|
||||
<SubnetRouterView readonly={!auth.canManageNode} node={node} />
|
||||
</FeatureRoute>
|
||||
<FeatureRoute path="/ssh" feature="ssh" node={node}>
|
||||
<SSHView readonly={!canEdit("ssh", auth)} node={node} />
|
||||
<SSHView readonly={!auth.canManageNode} node={node} />
|
||||
</FeatureRoute>
|
||||
{/* <Route path="/serve">Share local content</Route> */}
|
||||
<FeatureRoute path="/update" feature="auto-update" node={node}>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useAPI } from "src/api"
|
||||
import Check from "src/assets/icons/check.svg?react"
|
||||
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
|
||||
import { ReactComponent as Check } from "src/assets/icons/check.svg"
|
||||
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||
import useExitNodes, {
|
||||
noExitNode,
|
||||
runAsExitNode,
|
||||
@@ -180,7 +180,7 @@ export default function ExitNodeSelector({
|
||||
)}
|
||||
{pending && (
|
||||
<p className="text-white p-3">
|
||||
Pending approval to run as exit node. This device won’t be usable as
|
||||
Pending approval to run as exit node. This device won't be usable as
|
||||
an exit node until then.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
|
||||
import Eye from "src/assets/icons/eye.svg?react"
|
||||
import User from "src/assets/icons/user.svg?react"
|
||||
import { AuthResponse, hasAnyEditCapabilities } from "src/hooks/auth"
|
||||
import { useTSWebConnected } from "src/hooks/ts-web-connected"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||
import { ReactComponent as Eye } from "src/assets/icons/eye.svg"
|
||||
import { ReactComponent as User } from "src/assets/icons/user.svg"
|
||||
import { AuthResponse, AuthType } from "src/hooks/auth"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Popover from "src/ui/popover"
|
||||
import ProfilePic from "src/ui/profile-pic"
|
||||
import { assertNever, isHTTPS } from "src/utils/util"
|
||||
|
||||
export default function LoginToggle({
|
||||
node,
|
||||
@@ -24,29 +22,12 @@ export default function LoginToggle({
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
const { tsWebConnected, checkTSWebConnection } = useTSWebConnected(
|
||||
auth.serverMode,
|
||||
node.IPv4
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
className="p-3 bg-white rounded-lg shadow flex flex-col max-w-[317px]"
|
||||
className="p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]"
|
||||
content={
|
||||
auth.serverMode === "readonly" ? (
|
||||
<ReadonlyModeContent auth={auth} />
|
||||
) : auth.serverMode === "login" ? (
|
||||
<LoginModeContent
|
||||
auth={auth}
|
||||
node={node}
|
||||
tsWebConnected={tsWebConnected}
|
||||
checkTSWebConnection={checkTSWebConnection}
|
||||
/>
|
||||
) : auth.serverMode === "manage" ? (
|
||||
<ManageModeContent auth={auth} node={node} newSession={newSession} />
|
||||
) : (
|
||||
assertNever(auth.serverMode)
|
||||
)
|
||||
<LoginPopoverContent node={node} auth={auth} newSession={newSession} />
|
||||
}
|
||||
side="bottom"
|
||||
align="end"
|
||||
@@ -54,303 +35,201 @@ export default function LoginToggle({
|
||||
onOpenChange={setOpen}
|
||||
asChild
|
||||
>
|
||||
<div>
|
||||
{auth.authorized ? (
|
||||
<TriggerWhenManaging auth={auth} open={open} setOpen={setOpen} />
|
||||
) : (
|
||||
<TriggerWhenReading auth={auth} open={open} setOpen={setOpen} />
|
||||
)}
|
||||
</div>
|
||||
{!auth.canManageNode ? (
|
||||
<button
|
||||
className={cx(
|
||||
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
|
||||
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Eye />
|
||||
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
|
||||
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
|
||||
{auth.viewerIdentity && (
|
||||
<ProfilePic
|
||||
className="ml-2"
|
||||
size="medium"
|
||||
url={auth.viewerIdentity.profilePicUrl}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
|
||||
{
|
||||
"bg-transparent": !open,
|
||||
"bg-gray-300": open,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button onClick={() => setOpen(!open)}>
|
||||
<ProfilePic
|
||||
size="medium"
|
||||
url={auth.viewerIdentity?.profilePicUrl}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TriggerWhenManaging is displayed as the trigger for the login popover
|
||||
* when the user has an active authorized managment session.
|
||||
*/
|
||||
function TriggerWhenManaging({
|
||||
auth,
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
auth: AuthResponse
|
||||
open: boolean
|
||||
setOpen: (next: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
|
||||
{
|
||||
"bg-transparent": !open,
|
||||
"bg-gray-300": open,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button onClick={() => setOpen(!open)}>
|
||||
<ProfilePic size="medium" url={auth.viewerIdentity?.profilePicUrl} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TriggerWhenReading is displayed as the trigger for the login popover
|
||||
* when the user is currently in read mode (doesn't have an authorized
|
||||
* management session).
|
||||
*/
|
||||
function TriggerWhenReading({
|
||||
auth,
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
auth: AuthResponse
|
||||
open: boolean
|
||||
setOpen: (next: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cx(
|
||||
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
|
||||
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Eye />
|
||||
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
|
||||
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
|
||||
{auth.viewerIdentity && (
|
||||
<ProfilePic
|
||||
className="ml-2"
|
||||
size="medium"
|
||||
url={auth.viewerIdentity.profilePicUrl}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PopoverContentHeader is the header for the login popover.
|
||||
*/
|
||||
function PopoverContentHeader({ auth }: { auth: AuthResponse }) {
|
||||
return (
|
||||
<div className="text-black text-sm font-medium leading-tight mb-1">
|
||||
{auth.authorized ? "Managing" : "Viewing"}
|
||||
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PopoverContentFooter is the footer for the login popover.
|
||||
*/
|
||||
function PopoverContentFooter({ auth }: { auth: AuthResponse }) {
|
||||
return auth.viewerIdentity ? (
|
||||
<>
|
||||
<hr className="my-2" />
|
||||
<div className="flex items-center">
|
||||
<User className="flex-shrink-0" />
|
||||
<p className="text-gray-500 text-xs ml-2">
|
||||
We recognize you because you are accessing this page from{" "}
|
||||
<span className="font-medium">
|
||||
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadonlyModeContent is the body of the login popover when the web
|
||||
* client is being run in "readonly" server mode.
|
||||
*/
|
||||
function ReadonlyModeContent({ auth }: { auth: AuthResponse }) {
|
||||
return (
|
||||
<>
|
||||
<PopoverContentHeader auth={auth} />
|
||||
<p className="text-gray-500 text-xs">
|
||||
This web interface is running in read-only mode.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/s/web-client-read-only"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
<PopoverContentFooter auth={auth} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LoginModeContent is the body of the login popover when the web
|
||||
* client is being run in "login" server mode.
|
||||
*/
|
||||
function LoginModeContent({
|
||||
function LoginPopoverContent({
|
||||
node,
|
||||
auth,
|
||||
tsWebConnected,
|
||||
checkTSWebConnection,
|
||||
}: {
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
tsWebConnected: boolean
|
||||
checkTSWebConnection: () => void
|
||||
}) {
|
||||
const https = isHTTPS()
|
||||
// We can't run the ts web connection test when the webpage is loaded
|
||||
// over HTTPS. So in this case, we default to presenting a login button
|
||||
// with some helper text reminding the user to check their connection
|
||||
// themselves.
|
||||
const hasACLAccess = https || tsWebConnected
|
||||
|
||||
const hasEditCaps = useMemo(() => {
|
||||
if (!auth.viewerIdentity) {
|
||||
// If not connected to login client over tailscale, we won't know the viewer's
|
||||
// identity. So we must assume they may be able to edit something and have the
|
||||
// management client handle permissions once the user gets there.
|
||||
return true
|
||||
}
|
||||
return hasAnyEditCapabilities(auth)
|
||||
}, [auth])
|
||||
|
||||
const handleLogin = useCallback(() => {
|
||||
// Must be connected over Tailscale to log in.
|
||||
// Send user to Tailscale IP and start check mode
|
||||
const manageURL = `http://${node.IPv4}:5252/?check=now`
|
||||
if (window.self !== window.top) {
|
||||
// If we're inside an iframe, open management client in new window.
|
||||
window.open(manageURL, "_blank")
|
||||
} else {
|
||||
window.location.href = manageURL
|
||||
}
|
||||
}, [node.IPv4])
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={
|
||||
hasEditCaps && !hasACLAccess ? checkTSWebConnection : undefined
|
||||
}
|
||||
>
|
||||
<PopoverContentHeader auth={auth} />
|
||||
{!hasACLAccess || !hasEditCaps ? (
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
{!hasEditCaps ? (
|
||||
// ACLs allow access, but user isn't allowed to edit any features,
|
||||
// restricted to readonly. No point in sending them over to the
|
||||
// tailscaleIP:5252 address.
|
||||
<>
|
||||
You don’t have permission to make changes to this device, but
|
||||
you can view most of its details.
|
||||
</>
|
||||
) : !node.ACLAllowsAnyIncomingTraffic ? (
|
||||
// Tailnet ACLs don't allow access to anyone.
|
||||
<>
|
||||
The current tailnet policy file does not allow connecting to
|
||||
this device.
|
||||
</>
|
||||
) : (
|
||||
// ACLs don't allow access to this user specifically.
|
||||
<>
|
||||
Cannot access this device’s Tailscale IP. Make sure you are
|
||||
connected to your tailnet, and that your policy file allows
|
||||
access.
|
||||
</>
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://tailscale.com/s/web-client-access"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
// User can connect to Tailcale IP; sign in when ready.
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
You can see most of this device’s details. To make changes, you need
|
||||
to sign in.
|
||||
</p>
|
||||
{https && (
|
||||
// we don't know if the user can connect over TS, so
|
||||
// provide extra tips in case they have trouble.
|
||||
<p className="text-gray-500 text-xs font-semibold pt-2">
|
||||
Make sure you are connected to your tailnet, and that your policy
|
||||
file allows access.
|
||||
</p>
|
||||
)}
|
||||
<SignInButton auth={auth} onClick={handleLogin} />
|
||||
</>
|
||||
)}
|
||||
<PopoverContentFooter auth={auth} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ManageModeContent is the body of the login popover when the web
|
||||
* client is being run in "manage" server mode.
|
||||
*/
|
||||
function ManageModeContent({
|
||||
auth,
|
||||
newSession,
|
||||
}: {
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
newSession: () => void
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const handleLogin = useCallback(() => {
|
||||
if (window.self !== window.top) {
|
||||
// If we're inside an iframe, start session in new window.
|
||||
let url = new URL(window.location.href)
|
||||
url.searchParams.set("check", "now")
|
||||
window.open(url, "_blank")
|
||||
} else {
|
||||
newSession()
|
||||
}
|
||||
}, [newSession])
|
||||
/**
|
||||
* canConnectOverTS indicates whether the current viewer
|
||||
* is able to hit the node's web client that's being served
|
||||
* at http://${node.IP}:5252. If false, this means that the
|
||||
* viewer must connect to the correct tailnet before being
|
||||
* able to sign in.
|
||||
*/
|
||||
const [canConnectOverTS, setCanConnectOverTS] = useState<boolean>(false)
|
||||
const [isRunningCheck, setIsRunningCheck] = useState<boolean>(false)
|
||||
|
||||
const hasAnyPermissions = useMemo(() => hasAnyEditCapabilities(auth), [auth])
|
||||
const checkTSConnection = useCallback(() => {
|
||||
if (auth.viewerIdentity) {
|
||||
setCanConnectOverTS(true) // already connected over ts
|
||||
return
|
||||
}
|
||||
// Otherwise, test connection to the ts IP.
|
||||
if (isRunningCheck) {
|
||||
return // already checking
|
||||
}
|
||||
setIsRunningCheck(true)
|
||||
fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" })
|
||||
.then(() => {
|
||||
setCanConnectOverTS(true)
|
||||
setIsRunningCheck(false)
|
||||
})
|
||||
.catch(() => setIsRunningCheck(false))
|
||||
}, [auth.viewerIdentity, isRunningCheck, node.IPv4])
|
||||
|
||||
/**
|
||||
* Checking connection for first time on page load.
|
||||
*
|
||||
* While not connected, we check again whenever the mouse
|
||||
* enters the popover component, to pick up on the user
|
||||
* leaving to turn on Tailscale then returning to the view.
|
||||
* See `onMouseEnter` on the div below.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => checkTSConnection(), [])
|
||||
|
||||
const handleSignInClick = useCallback(() => {
|
||||
if (auth.viewerIdentity && auth.serverMode === "manage") {
|
||||
if (window.self !== window.top) {
|
||||
// if we're inside an iframe, start session in new window
|
||||
let url = new URL(window.location.href)
|
||||
url.searchParams.set("check", "now")
|
||||
window.open(url, "_blank")
|
||||
} else {
|
||||
newSession()
|
||||
}
|
||||
} else {
|
||||
// Must be connected over Tailscale to log in.
|
||||
// Send user to Tailscale IP and start check mode
|
||||
const manageURL = `http://${node.IPv4}:5252/?check=now`
|
||||
if (window.self !== window.top) {
|
||||
// if we're inside an iframe, open management client in new window
|
||||
window.open(manageURL, "_blank")
|
||||
} else {
|
||||
window.location.href = manageURL
|
||||
}
|
||||
}
|
||||
}, [auth.viewerIdentity, auth.serverMode, newSession, node.IPv4])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverContentHeader auth={auth} />
|
||||
{!auth.authorized &&
|
||||
(hasAnyPermissions ? (
|
||||
// User is connected over Tailscale, but needs to complete check mode.
|
||||
<>
|
||||
<div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>
|
||||
<div className="text-black text-sm font-medium leading-tight mb-1">
|
||||
{!auth.canManageNode ? "Viewing" : "Managing"}
|
||||
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
|
||||
</div>
|
||||
{!auth.canManageNode && (
|
||||
<>
|
||||
{!auth.viewerIdentity ? (
|
||||
// User is not connected over Tailscale.
|
||||
// These states are only possible on the login client.
|
||||
<>
|
||||
{!canConnectOverTS ? (
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
{!node.ACLAllowsAnyIncomingTraffic ? (
|
||||
// Tailnet ACLs don't allow access.
|
||||
<>
|
||||
The current tailnet policy file does not allow
|
||||
connecting to this device.
|
||||
</>
|
||||
) : (
|
||||
// ACLs allow access, but user can't connect.
|
||||
<>
|
||||
Cannot access this device's Tailscale IP. Make sure you
|
||||
are connected to your tailnet, and that your policy file
|
||||
allows access.
|
||||
</>
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://tailscale.com/s/web-client-connection"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
// User can connect to Tailcale IP; sign in when ready.
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
You can see most of this device's details. To make changes,
|
||||
you need to sign in.
|
||||
</p>
|
||||
<SignInButton auth={auth} onClick={handleSignInClick} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : auth.authNeeded === AuthType.tailscale ? (
|
||||
// User is connected over Tailscale, but needs to complete check mode.
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
To make changes, sign in to confirm your identity. This extra
|
||||
step helps us keep your device secure.
|
||||
</p>
|
||||
<SignInButton auth={auth} onClick={handleSignInClick} />
|
||||
</>
|
||||
) : (
|
||||
// User is connected over tailscale, but doesn't have permission to manage.
|
||||
<p className="text-gray-500 text-xs">
|
||||
To make changes, sign in to confirm your identity. This extra step
|
||||
helps us keep your device secure.
|
||||
You don’t have permission to make changes to this device, but you
|
||||
can view most of its details.
|
||||
</p>
|
||||
<SignInButton auth={auth} onClick={handleLogin} />
|
||||
</>
|
||||
) : (
|
||||
// User is connected over tailscale, but doesn't have permission to manage.
|
||||
<p className="text-gray-500 text-xs">
|
||||
You don’t have permission to make changes to this device, but you
|
||||
can view most of its details.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/s/web-client-access"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
))}
|
||||
<PopoverContentFooter auth={auth} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{auth.viewerIdentity && (
|
||||
<>
|
||||
<hr className="my-2" />
|
||||
<div className="flex items-center">
|
||||
<User className="flex-shrink-0" />
|
||||
<p className="text-gray-500 text-xs ml-2">
|
||||
We recognize you because you are accessing this page from{" "}
|
||||
<span className="font-medium">
|
||||
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export function ChangelogText({ version }: { version?: string }) {
|
||||
<a href="https://tailscale.com/changelog/" className="link">
|
||||
release notes
|
||||
</a>{" "}
|
||||
to find out what’s new!
|
||||
to find out what's new!
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import ACLTag from "src/components/acl-tag"
|
||||
import * as Control from "src/components/control-components"
|
||||
import NiceIP from "src/components/nice-ip"
|
||||
import { UpdateAvailableNotification } from "src/components/update-available"
|
||||
import { AuthResponse, canEdit } from "src/hooks/auth"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Card from "src/ui/card"
|
||||
@@ -17,11 +16,11 @@ import QuickCopy from "src/ui/quick-copy"
|
||||
import { useLocation } from "wouter"
|
||||
|
||||
export default function DeviceDetailsView({
|
||||
readonly,
|
||||
node,
|
||||
auth,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -38,11 +37,11 @@ export default function DeviceDetailsView({
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{canEdit("account", auth) && <DisconnectDialog />}
|
||||
{!readonly && <DisconnectDialog />}
|
||||
</div>
|
||||
</Card>
|
||||
{node.Features["auto-update"] &&
|
||||
canEdit("account", auth) &&
|
||||
!readonly &&
|
||||
node.ClientVersion &&
|
||||
!node.ClientVersion.RunningLatest && (
|
||||
<UpdateAvailableNotification details={node.ClientVersion} />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
|
||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||
|
||||
/**
|
||||
* DisconnectedView is rendered after node logout.
|
||||
|
||||
@@ -4,22 +4,21 @@
|
||||
import cx from "classnames"
|
||||
import React, { useMemo } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import ArrowRight from "src/assets/icons/arrow-right.svg?react"
|
||||
import Machine from "src/assets/icons/machine.svg?react"
|
||||
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
|
||||
import { ReactComponent as Machine } from "src/assets/icons/machine.svg"
|
||||
import AddressCard from "src/components/address-copy-card"
|
||||
import ExitNodeSelector from "src/components/exit-node-selector"
|
||||
import { AuthResponse, canEdit } from "src/hooks/auth"
|
||||
import { NodeData } from "src/types"
|
||||
import Card from "src/ui/card"
|
||||
import { pluralize } from "src/utils/util"
|
||||
import { Link, useLocation } from "wouter"
|
||||
|
||||
export default function HomeView({
|
||||
readonly,
|
||||
node,
|
||||
auth,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
}) {
|
||||
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
|
||||
() => [
|
||||
@@ -64,11 +63,7 @@ export default function HomeView({
|
||||
</div>
|
||||
{(node.Features["advertise-exit-node"] ||
|
||||
node.Features["use-exit-node"]) && (
|
||||
<ExitNodeSelector
|
||||
className="mb-5"
|
||||
node={node}
|
||||
disabled={!canEdit("exitnodes", auth)}
|
||||
/>
|
||||
<ExitNodeSelector className="mb-5" node={node} disabled={readonly} />
|
||||
)}
|
||||
<Link
|
||||
className="link font-medium"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import React, { useState } from "react"
|
||||
import { useAPI } from "src/api"
|
||||
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
|
||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Collapsible from "src/ui/collapsible"
|
||||
import Input from "src/ui/input"
|
||||
|
||||
/**
|
||||
* LoginView is rendered when the client is not authenticated
|
||||
@@ -13,6 +15,8 @@ import Button from "src/ui/button"
|
||||
*/
|
||||
export default function LoginView({ data }: { data: NodeData }) {
|
||||
const api = useAPI()
|
||||
const [controlURL, setControlURL] = useState<string>("")
|
||||
const [authKey, setAuthKey] = useState<string>("")
|
||||
|
||||
return (
|
||||
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
@@ -37,7 +41,7 @@ export default function LoginView({ data }: { data: NodeData }) {
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700">
|
||||
Your device’s key has expired. Reauthenticate this device by
|
||||
Your device's key has expired. Reauthenticate this device by
|
||||
logging in again, or{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1028/key-expiry"
|
||||
@@ -84,6 +88,8 @@ export default function LoginView({ data }: { data: NodeData }) {
|
||||
action: "up",
|
||||
data: {
|
||||
Reauthenticate: true,
|
||||
ControlURL: controlURL,
|
||||
AuthKey: authKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -92,6 +98,34 @@ export default function LoginView({ data }: { data: NodeData }) {
|
||||
>
|
||||
Log In
|
||||
</Button>
|
||||
<Collapsible trigger="Advanced options">
|
||||
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
Connect with a pre-authenticated key.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1085/auth-keys/"
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
<Input
|
||||
className="mt-2"
|
||||
value={authKey}
|
||||
onChange={(e) => setAuthKey(e.target.value)}
|
||||
placeholder="tskey-auth-XXX"
|
||||
/>
|
||||
<h4 className="font-medium mt-3 mb-1">Server URL</h4>
|
||||
<p className="text-sm text-gray-500">Base URL of control server.</p>
|
||||
<Input
|
||||
className="mt-2"
|
||||
value={controlURL}
|
||||
onChange={(e) => setControlURL(e.target.value)}
|
||||
placeholder="https://login.tailscale.com/"
|
||||
/>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import { useAPI } from "src/api"
|
||||
import CheckCircle from "src/assets/icons/check-circle.svg?react"
|
||||
import Clock from "src/assets/icons/clock.svg?react"
|
||||
import Plus from "src/assets/icons/plus.svg?react"
|
||||
import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg"
|
||||
import { ReactComponent as Clock } from "src/assets/icons/clock.svg"
|
||||
import { ReactComponent as Plus } from "src/assets/icons/plus.svg"
|
||||
import * as Control from "src/components/control-components"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import CheckCircleIcon from "src/assets/icons/check-circle.svg?react"
|
||||
import XCircleIcon from "src/assets/icons/x-circle.svg?react"
|
||||
import { ReactComponent as CheckCircleIcon } from "src/assets/icons/check-circle.svg"
|
||||
import { ReactComponent as XCircleIcon } from "src/assets/icons/x-circle.svg"
|
||||
import { ChangelogText } from "src/components/update-available"
|
||||
import { UpdateState, useInstallUpdate } from "src/hooks/self-update"
|
||||
import { VersionInfo } from "src/types"
|
||||
@@ -35,7 +35,7 @@ export function UpdatingView({
|
||||
<Spinner size="sm" className="text-gray-400" />
|
||||
<h1 className="text-2xl m-3">Update in progress</h1>
|
||||
<p className="text-gray-400">
|
||||
The update shouldn’t take more than a couple of minutes. Once it’s
|
||||
The update shouldn't take more than a couple of minutes. Once it's
|
||||
completed, you will be asked to log in again.
|
||||
</p>
|
||||
</>
|
||||
|
||||
@@ -4,50 +4,25 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch, setSynoToken } from "src/api"
|
||||
|
||||
export enum AuthType {
|
||||
synology = "synology",
|
||||
tailscale = "tailscale",
|
||||
}
|
||||
|
||||
export type AuthResponse = {
|
||||
serverMode: AuthServerMode
|
||||
authorized: boolean
|
||||
authNeeded?: AuthType
|
||||
canManageNode: boolean
|
||||
serverMode: "login" | "manage"
|
||||
viewerIdentity?: {
|
||||
loginName: string
|
||||
nodeName: string
|
||||
nodeIP: string
|
||||
profilePicUrl?: string
|
||||
capabilities: { [key in PeerCapability]: boolean }
|
||||
}
|
||||
needsSynoAuth?: boolean
|
||||
}
|
||||
|
||||
export type AuthServerMode = "login" | "readonly" | "manage"
|
||||
|
||||
export type PeerCapability = "*" | "ssh" | "subnets" | "exitnodes" | "account"
|
||||
|
||||
/**
|
||||
* canEdit reports whether the given auth response specifies that the viewer
|
||||
* has the ability to edit the given capability.
|
||||
*/
|
||||
export function canEdit(cap: PeerCapability, auth: AuthResponse): boolean {
|
||||
if (!auth.authorized || !auth.viewerIdentity) {
|
||||
return false
|
||||
}
|
||||
if (auth.viewerIdentity.capabilities["*"] === true) {
|
||||
return true // can edit all features
|
||||
}
|
||||
return auth.viewerIdentity.capabilities[cap] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* hasAnyEditCapabilities reports whether the given auth response specifies
|
||||
* that the viewer has at least one edit capability. If this is true, the
|
||||
* user is able to go through the auth flow to authenticate a management
|
||||
* session.
|
||||
*/
|
||||
export function hasAnyEditCapabilities(auth: AuthResponse): boolean {
|
||||
return Object.values(auth.viewerIdentity?.capabilities || {}).includes(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* useAuth reports and refreshes Tailscale auth status for the web client.
|
||||
*/
|
||||
// useAuth reports and refreshes Tailscale auth status
|
||||
// for the web client.
|
||||
export default function useAuth() {
|
||||
const [data, setData] = useState<AuthResponse>()
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
@@ -58,16 +33,18 @@ export default function useAuth() {
|
||||
return apiFetch<AuthResponse>("/auth", "GET")
|
||||
.then((d) => {
|
||||
setData(d)
|
||||
if (d.needsSynoAuth) {
|
||||
fetch("/webman/login.cgi")
|
||||
.then((r) => r.json())
|
||||
.then((a) => {
|
||||
setSynoToken(a.SynoToken)
|
||||
setRanSynoAuth(true)
|
||||
setLoading(false)
|
||||
})
|
||||
} else {
|
||||
setLoading(false)
|
||||
switch (d.authNeeded) {
|
||||
case AuthType.synology:
|
||||
fetch("/webman/login.cgi")
|
||||
.then((r) => r.json())
|
||||
.then((a) => {
|
||||
setSynoToken(a.SynoToken)
|
||||
setRanSynoAuth(true)
|
||||
setLoading(false)
|
||||
})
|
||||
break
|
||||
default:
|
||||
setLoading(false)
|
||||
}
|
||||
return d
|
||||
})
|
||||
@@ -95,13 +72,8 @@ export default function useAuth() {
|
||||
|
||||
useEffect(() => {
|
||||
loadAuth().then((d) => {
|
||||
if (!d) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!d.authorized &&
|
||||
hasAnyEditCapabilities(d) &&
|
||||
// Start auth flow immediately if browser has requested it.
|
||||
!d?.canManageNode &&
|
||||
new URLSearchParams(window.location.search).get("check") === "now"
|
||||
) {
|
||||
newSession()
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { isHTTPS } from "src/utils/util"
|
||||
import { AuthServerMode } from "./auth"
|
||||
|
||||
/**
|
||||
* useTSWebConnected hook is used to check whether the browser is able to
|
||||
* connect to the web client served at http://${nodeIPv4}:5252
|
||||
*/
|
||||
export function useTSWebConnected(mode: AuthServerMode, nodeIPv4: string) {
|
||||
const [tsWebConnected, setTSWebConnected] = useState<boolean>(
|
||||
mode === "manage" // browser already on the web client
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
|
||||
const checkTSWebConnection = useCallback(() => {
|
||||
if (mode === "manage") {
|
||||
// Already connected to the web client.
|
||||
setTSWebConnected(true)
|
||||
return
|
||||
}
|
||||
if (isHTTPS()) {
|
||||
// When page is loaded over HTTPS, the connectivity check will always
|
||||
// fail with a mixed-content error. In this case don't bother doing
|
||||
// the check.
|
||||
return
|
||||
}
|
||||
if (isLoading) {
|
||||
return // already checking
|
||||
}
|
||||
setIsLoading(true)
|
||||
fetch(`http://${nodeIPv4}:5252/ok`, { mode: "no-cors" })
|
||||
.then(() => {
|
||||
setTSWebConnected(true)
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch(() => setIsLoading(false))
|
||||
}, [isLoading, mode, nodeIPv4])
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => checkTSWebConnection(), []) // checking connection for first time on page load
|
||||
|
||||
return { tsWebConnected, checkTSWebConnection, isLoading }
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import * as Primitive from "@radix-ui/react-collapsible"
|
||||
import React, { useState } from "react"
|
||||
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
|
||||
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||
|
||||
type CollapsibleProps = {
|
||||
trigger?: string
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import cx from "classnames"
|
||||
import React, { Component, ComponentProps, FormEvent } from "react"
|
||||
import X from "src/assets/icons/x.svg?react"
|
||||
import { ReactComponent as X } from "src/assets/icons/x.svg"
|
||||
import Button from "src/ui/button"
|
||||
import PortalContainerContext from "src/ui/portal-container-context"
|
||||
import { isObject } from "src/utils/util"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { forwardRef, InputHTMLAttributes } from "react"
|
||||
import Search from "src/assets/icons/search.svg?react"
|
||||
import { ReactComponent as Search } from "src/assets/icons/search.svg"
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
|
||||
@@ -10,7 +10,7 @@ import React, {
|
||||
useState,
|
||||
} from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import X from "src/assets/icons/x.svg?react"
|
||||
import { ReactComponent as X } from "src/assets/icons/x.svg"
|
||||
import { noop } from "src/utils/util"
|
||||
import { create } from "zustand"
|
||||
import { shallow } from "zustand/shallow"
|
||||
|
||||
@@ -49,10 +49,3 @@ export function isPromise<T = unknown>(val: unknown): val is Promise<T> {
|
||||
}
|
||||
return typeof val === "object" && "then" in val
|
||||
}
|
||||
|
||||
/**
|
||||
* isHTTPS reports whether the current page is loaded over HTTPS.
|
||||
*/
|
||||
export function isHTTPS() {
|
||||
return window.location.protocol === "https:"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import plugin from "tailwindcss/plugin"
|
||||
import styles from "./styles.json"
|
||||
const plugin = require("tailwindcss/plugin")
|
||||
const styles = require("./styles.json")
|
||||
|
||||
const config = {
|
||||
module.exports = {
|
||||
theme: {
|
||||
screens: {
|
||||
sm: "420px",
|
||||
@@ -96,22 +96,20 @@ const config = {
|
||||
plugins: [
|
||||
plugin(function ({ addVariant }) {
|
||||
addVariant("state-open", [
|
||||
"&[data-state=“open”]",
|
||||
"[data-state=“open”] &",
|
||||
'&[data-state="open"]',
|
||||
'[data-state="open"] &',
|
||||
])
|
||||
addVariant("state-closed", [
|
||||
"&[data-state=“closed”]",
|
||||
"[data-state=“closed”] &",
|
||||
'&[data-state="closed"]',
|
||||
'[data-state="closed"] &',
|
||||
])
|
||||
addVariant("state-delayed-open", [
|
||||
"&[data-state=“delayed-open”]",
|
||||
"[data-state=“delayed-open”] &",
|
||||
'&[data-state="delayed-open"]',
|
||||
'[data-state="delayed-open"] &',
|
||||
])
|
||||
addVariant("state-active", ["&[data-state=“active”]"])
|
||||
addVariant("state-inactive", ["&[data-state=“inactive”]"])
|
||||
addVariant("state-active", ['&[data-state="active"]'])
|
||||
addVariant("state-inactive", ['&[data-state="inactive"]'])
|
||||
}),
|
||||
],
|
||||
content: ["./src/**/*.html", "./src/**/*.{ts,tsx}", "./index.html"],
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"module": "ES2020",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/// <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"
|
||||
|
||||
@@ -23,6 +24,11 @@ export default defineConfig({
|
||||
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",
|
||||
|
||||
@@ -17,16 +17,16 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/envknob/featureknob"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
@@ -35,7 +35,6 @@ import (
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
@@ -50,7 +49,7 @@ type Server struct {
|
||||
mode ServerMode
|
||||
|
||||
logf logger.Logf
|
||||
lc *local.Client
|
||||
lc *tailscale.LocalClient
|
||||
timeNow func() time.Time
|
||||
|
||||
// devMode indicates that the server run with frontend assets
|
||||
@@ -89,31 +88,28 @@ type Server struct {
|
||||
type ServerMode string
|
||||
|
||||
const (
|
||||
// LoginServerMode serves a read-only login client for logging a
|
||||
// node into a tailnet, and viewing a read-only interface of the
|
||||
// LoginServerMode serves a readonly login client for logging a
|
||||
// node into a tailnet, and viewing a readonly interface of the
|
||||
// node's current Tailscale settings.
|
||||
//
|
||||
// In this mode, API calls are authenticated via platform auth.
|
||||
LoginServerMode ServerMode = "login"
|
||||
|
||||
// ReadOnlyServerMode is identical to LoginServerMode,
|
||||
// but does not present a login button to switch to manage mode,
|
||||
// even if the management client is running and reachable.
|
||||
//
|
||||
// This is designed for platforms where the device is configured by other means,
|
||||
// such as Home Assistant's declarative YAML configuration.
|
||||
ReadOnlyServerMode ServerMode = "readonly"
|
||||
|
||||
// ManageServerMode serves a management client for editing tailscale
|
||||
// settings of a node.
|
||||
//
|
||||
// This mode restricts the app to only being assessible over Tailscale,
|
||||
// and API calls are authenticated via browser sessions associated with
|
||||
// the source's Tailscale identity. If the source browser does not have
|
||||
// a valid session, a read-only version of the app is displayed.
|
||||
// a valid session, a readonly version of the app is displayed.
|
||||
ManageServerMode ServerMode = "manage"
|
||||
)
|
||||
|
||||
var (
|
||||
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
||||
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
|
||||
)
|
||||
|
||||
// ServerOpts contains options for constructing a new Server.
|
||||
type ServerOpts struct {
|
||||
// Mode specifies the mode of web client being constructed.
|
||||
@@ -125,9 +121,9 @@ type ServerOpts struct {
|
||||
// PathPrefix is the URL prefix added to requests by CGI or reverse proxy.
|
||||
PathPrefix string
|
||||
|
||||
// LocalClient is the local.Client to use for this web server.
|
||||
// LocalClient is the tailscale.LocalClient to use for this web server.
|
||||
// If nil, a new one will be created.
|
||||
LocalClient *local.Client
|
||||
LocalClient *tailscale.LocalClient
|
||||
|
||||
// TimeNow optionally provides a time function.
|
||||
// time.Now is used as default.
|
||||
@@ -158,7 +154,7 @@ type ServerOpts struct {
|
||||
// and not the lifespan of the web server.
|
||||
func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
switch opts.Mode {
|
||||
case LoginServerMode, ReadOnlyServerMode, ManageServerMode:
|
||||
case LoginServerMode, ManageServerMode:
|
||||
// valid types
|
||||
case "":
|
||||
return nil, fmt.Errorf("must specify a Mode")
|
||||
@@ -166,7 +162,7 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
return nil, fmt.Errorf("invalid Mode provided")
|
||||
}
|
||||
if opts.LocalClient == nil {
|
||||
opts.LocalClient = &local.Client{}
|
||||
opts.LocalClient = &tailscale.LocalClient{}
|
||||
}
|
||||
s = &Server{
|
||||
mode: opts.Mode,
|
||||
@@ -211,25 +207,11 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
// The client is secured by limiting the interface it listens on,
|
||||
// or by authenticating requests before they reach the web client.
|
||||
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
|
||||
|
||||
// signal to the CSRF middleware that the request is being served over
|
||||
// plaintext HTTP to skip TLS-only header checks.
|
||||
withSetPlaintext := func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = csrf.PlaintextHTTPRequest(r)
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
switch s.mode {
|
||||
case LoginServerMode:
|
||||
s.apiHandler = csrfProtect(withSetPlaintext(http.HandlerFunc(s.serveLoginAPI)))
|
||||
if s.mode == LoginServerMode {
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
||||
metric = "web_login_client_initialization"
|
||||
case ReadOnlyServerMode:
|
||||
s.apiHandler = csrfProtect(withSetPlaintext(http.HandlerFunc(s.serveLoginAPI)))
|
||||
metric = "web_readonly_client_initialization"
|
||||
case ManageServerMode:
|
||||
s.apiHandler = csrfProtect(withSetPlaintext(http.HandlerFunc(s.serveAPI)))
|
||||
} else {
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
|
||||
metric = "web_client_initialization"
|
||||
}
|
||||
|
||||
@@ -289,12 +271,6 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if r.URL.Path == "/metrics" {
|
||||
r.URL.Path = "/api/local/v0/usermetrics"
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
switch {
|
||||
case r.URL.Path == "/api/auth" && r.Method == httpm.GET:
|
||||
@@ -457,198 +433,27 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
type apiHandler[data any] struct {
|
||||
s *Server
|
||||
w http.ResponseWriter
|
||||
r *http.Request
|
||||
type authType string
|
||||
|
||||
// permissionCheck allows for defining whether a requesting peer's
|
||||
// capabilities grant them access to make the given data update.
|
||||
// If permissionCheck reports false, the request fails as unauthorized.
|
||||
permissionCheck func(data data, peer peerCapabilities) bool
|
||||
}
|
||||
|
||||
// newHandler constructs a new api handler which restricts the given request
|
||||
// to the specified permission check. If the permission check fails for
|
||||
// the peer associated with the request, an unauthorized error is returned
|
||||
// to the client.
|
||||
func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
|
||||
return &apiHandler[data]{
|
||||
s: s,
|
||||
w: w,
|
||||
r: r,
|
||||
permissionCheck: permissionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
// alwaysAllowed can be passed as the permissionCheck argument to newHandler
|
||||
// for requests that are always allowed to complete regardless of a peer's
|
||||
// capabilities.
|
||||
func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
|
||||
|
||||
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
|
||||
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
|
||||
// WhoIs when originally checking for a session from authorizeRequest.
|
||||
// Would be nice if we could pipe those through to here so we don't end
|
||||
// up having to re-call them to grab the peer capabilities.
|
||||
status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
peer, err := toPeerCapabilities(status, whois)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
|
||||
|
||||
// handle runs the given handler if the source peer satisfies the
|
||||
// constraints for running this request.
|
||||
//
|
||||
// handle is expected for use when `data` type is empty, or set to
|
||||
// `noBodyData` in practice. For requests that expect JSON body data
|
||||
// to be attached, use handleJSON instead.
|
||||
func (a *apiHandler[data]) handle(h http.HandlerFunc) {
|
||||
peer, err := a.getPeer()
|
||||
if err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var body data // not used
|
||||
if !a.permissionCheck(body, peer) {
|
||||
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h(a.w, a.r)
|
||||
}
|
||||
|
||||
// handleJSON manages decoding the request's body JSON and passing
|
||||
// it on to the provided function if the source peer satisfies the
|
||||
// constraints for running this request.
|
||||
func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
|
||||
defer a.r.Body.Close()
|
||||
var body data
|
||||
if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
peer, err := a.getPeer()
|
||||
if err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !a.permissionCheck(body, peer) {
|
||||
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h(a.r.Context(), body); err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == httpm.PATCH {
|
||||
// Enforce that PATCH requests are always application/json.
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case path == "/data" && r.Method == httpm.GET:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveGetNodeData)
|
||||
return
|
||||
case path == "/exit-nodes" && r.Method == httpm.GET:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveGetExitNodes)
|
||||
return
|
||||
case path == "/routes" && r.Method == httpm.POST:
|
||||
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
|
||||
if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
|
||||
return false
|
||||
} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
newHandler[postRoutesRequest](s, w, r, peerAllowed).
|
||||
handleJSON(s.servePostRoutes)
|
||||
return
|
||||
case path == "/device-details-click" && r.Method == httpm.POST:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveDeviceDetailsClick)
|
||||
return
|
||||
case path == "/local/v0/logout" && r.Method == httpm.POST:
|
||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
||||
return peer.canEdit(capFeatureAccount)
|
||||
}
|
||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
|
||||
peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
|
||||
if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
newHandler[maskedPrefs](s, w, r, peerAllowed).
|
||||
handleJSON(s.serveUpdatePrefs)
|
||||
return
|
||||
case path == "/local/v0/update/check" && r.Method == httpm.GET:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/update/check" && r.Method == httpm.POST:
|
||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
||||
return peer.canEdit(capFeatureAccount)
|
||||
}
|
||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
}
|
||||
var (
|
||||
synoAuth authType = "synology" // user needs a SynoToken for subsequent API calls
|
||||
tailscaleAuth authType = "tailscale" // user needs to complete Tailscale check mode
|
||||
)
|
||||
|
||||
type authResponse struct {
|
||||
ServerMode ServerMode `json:"serverMode"`
|
||||
Authorized bool `json:"authorized"` // has an authorized management session
|
||||
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
|
||||
CanManageNode bool `json:"canManageNode"`
|
||||
ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
|
||||
NeedsSynoAuth bool `json:"needsSynoAuth,omitempty"`
|
||||
ServerMode ServerMode `json:"serverMode"`
|
||||
}
|
||||
|
||||
// viewerIdentity is the Tailscale identity of the source node
|
||||
// connected to this web client.
|
||||
type viewerIdentity struct {
|
||||
LoginName string `json:"loginName"`
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeIP string `json:"nodeIP"`
|
||||
ProfilePicURL string `json:"profilePicUrl,omitempty"`
|
||||
Capabilities peerCapabilities `json:"capabilities"` // features peer is allowed to edit
|
||||
LoginName string `json:"loginName"`
|
||||
NodeName string `json:"nodeName"`
|
||||
NodeIP string `json:"nodeIP"`
|
||||
ProfilePicURL string `json:"profilePicUrl,omitempty"`
|
||||
}
|
||||
|
||||
// serverAPIAuth handles requests to the /api/auth endpoint
|
||||
@@ -657,20 +462,12 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var resp authResponse
|
||||
resp.ServerMode = s.mode
|
||||
session, whois, status, sErr := s.getSession(r)
|
||||
var caps peerCapabilities
|
||||
|
||||
if whois != nil {
|
||||
var err error
|
||||
caps, err = toPeerCapabilities(status, whois)
|
||||
if err != nil {
|
||||
http.Error(w, sErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resp.ViewerIdentity = &viewerIdentity{
|
||||
LoginName: whois.UserProfile.LoginName,
|
||||
NodeName: whois.Node.Name,
|
||||
ProfilePicURL: whois.UserProfile.ProfilePicURL,
|
||||
Capabilities: caps,
|
||||
}
|
||||
if addrs := whois.Node.Addresses; len(addrs) > 0 {
|
||||
resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()
|
||||
@@ -679,7 +476,7 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// First verify platform auth.
|
||||
// If platform auth is needed, this should happen first.
|
||||
if s.mode == LoginServerMode || s.mode == ReadOnlyServerMode {
|
||||
if s.mode == LoginServerMode {
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
authorized, err := authorizeSynology(r)
|
||||
@@ -688,7 +485,7 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !authorized {
|
||||
resp.NeedsSynoAuth = true
|
||||
resp.AuthNeeded = synoAuth
|
||||
writeJSON(w, resp)
|
||||
return
|
||||
}
|
||||
@@ -704,17 +501,21 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch {
|
||||
case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
|
||||
// Restricted to the readonly view, no auth action to take.
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
|
||||
resp.Authorized = false // restricted to the read-only view
|
||||
resp.AuthNeeded = ""
|
||||
case sErr != nil && errors.Is(sErr, errNotOwner):
|
||||
// Restricted to the readonly view, no auth action to take.
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1)
|
||||
resp.Authorized = false // restricted to the read-only view
|
||||
resp.AuthNeeded = ""
|
||||
case sErr != nil && errors.Is(sErr, errTaggedLocalSource):
|
||||
// Restricted to the readonly view, no auth action to take.
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1)
|
||||
resp.Authorized = false // restricted to the read-only view
|
||||
resp.AuthNeeded = ""
|
||||
case sErr != nil && errors.Is(sErr, errTaggedRemoteSource):
|
||||
// Restricted to the readonly view, no auth action to take.
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1)
|
||||
resp.Authorized = false // restricted to the read-only view
|
||||
resp.AuthNeeded = ""
|
||||
case sErr != nil && !errors.Is(sErr, errNoSession):
|
||||
// Any other error.
|
||||
http.Error(w, sErr.Error(), http.StatusInternalServerError)
|
||||
@@ -725,26 +526,16 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_managing_remote", 1)
|
||||
}
|
||||
// User has a valid session. They're now authorized to edit if they
|
||||
// have any edit capabilities. In practice, they won't be sent through
|
||||
// the auth flow if they don't have edit caps, but their ACL granted
|
||||
// permissions may change at any time. The frontend views and backend
|
||||
// endpoints are always restricted to their current capabilities in
|
||||
// addition to a valid session.
|
||||
//
|
||||
// But, we also check the caps here for a better user experience on
|
||||
// the frontend login toggle, which uses resp.Authorized to display
|
||||
// "viewing" vs "managing" copy. If they don't have caps, we want to
|
||||
// display "viewing" even if they have a valid session.
|
||||
resp.Authorized = !caps.isEmpty()
|
||||
resp.CanManageNode = true
|
||||
resp.AuthNeeded = ""
|
||||
default:
|
||||
// whois being nil implies local as the request did not come over Tailscale
|
||||
if whois == nil || (whois.Node.StableID == status.Self.ID) {
|
||||
// whois being nil implies local as the request did not come over Tailscale.
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote", 1)
|
||||
}
|
||||
resp.Authorized = false // not yet authorized
|
||||
resp.AuthNeeded = tailscaleAuth
|
||||
}
|
||||
|
||||
writeJSON(w, resp)
|
||||
@@ -808,14 +599,40 @@ func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case path == "/data" && r.Method == httpm.GET:
|
||||
s.serveGetNodeData(w, r)
|
||||
return
|
||||
case path == "/exit-nodes" && r.Method == httpm.GET:
|
||||
s.serveGetExitNodes(w, r)
|
||||
return
|
||||
case path == "/routes" && r.Method == httpm.POST:
|
||||
s.servePostRoutes(w, r)
|
||||
return
|
||||
case path == "/device-details-click" && r.Method == httpm.POST:
|
||||
s.serveDeviceDetailsClick(w, r)
|
||||
return
|
||||
case strings.HasPrefix(path, "/local/"):
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
}
|
||||
|
||||
type nodeData struct {
|
||||
ID tailcfg.StableNodeID
|
||||
Status string
|
||||
DeviceName string
|
||||
TailnetName string // TLS cert name
|
||||
DomainName string
|
||||
IPv4 netip.Addr
|
||||
IPv6 netip.Addr
|
||||
IPv4 string
|
||||
IPv6 string
|
||||
OS string
|
||||
IPNVersion string
|
||||
|
||||
@@ -874,14 +691,10 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
filterRules, _ := s.lc.DebugPacketFilterRules(r.Context())
|
||||
ipv4, ipv6 := s.selfNodeAddresses(r, st)
|
||||
|
||||
data := &nodeData{
|
||||
ID: st.Self.ID,
|
||||
Status: st.BackendState,
|
||||
DeviceName: strings.Split(st.Self.DNSName, ".")[0],
|
||||
IPv4: ipv4,
|
||||
IPv6: ipv6,
|
||||
OS: st.Self.OS,
|
||||
IPNVersion: strings.Split(st.Version, "-")[0],
|
||||
Profile: st.User[st.Self.UserID],
|
||||
@@ -901,6 +714,10 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
ACLAllowsAnyIncomingTraffic: s.aclsAllowAccess(filterRules),
|
||||
}
|
||||
|
||||
ipv4, ipv6 := s.selfNodeAddresses(r, st)
|
||||
data.IPv4 = ipv4.String()
|
||||
data.IPv6 = ipv6.String()
|
||||
|
||||
if hostinfo.GetEnvType() == hostinfo.HomeAssistantAddOn && data.URLPrefix == "" {
|
||||
// X-Ingress-Path is the path prefix in use for Home Assistant
|
||||
// https://developers.home-assistant.io/docs/add-ons/presentation#ingress
|
||||
@@ -933,10 +750,10 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
return p == route
|
||||
})
|
||||
}
|
||||
data.AdvertisingExitNodeApproved = routeApproved(tsaddr.AllIPv4()) || routeApproved(tsaddr.AllIPv6())
|
||||
data.AdvertisingExitNodeApproved = routeApproved(exitNodeRouteV4) || routeApproved(exitNodeRouteV6)
|
||||
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if tsaddr.IsExitRoute(r) {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
data.AdvertisingExitNode = true
|
||||
} else {
|
||||
data.AdvertisedRoutes = append(data.AdvertisedRoutes, subnetRoute{
|
||||
@@ -971,16 +788,37 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func availableFeatures() map[string]bool {
|
||||
env := hostinfo.GetEnvType()
|
||||
features := map[string]bool{
|
||||
"advertise-exit-node": true, // available on all platforms
|
||||
"advertise-routes": true, // available on all platforms
|
||||
"use-exit-node": featureknob.CanUseExitNode() == nil,
|
||||
"ssh": featureknob.CanRunTailscaleSSH() == nil,
|
||||
"use-exit-node": canUseExitNode(env) == nil,
|
||||
"ssh": envknob.CanRunTailscaleSSH() == nil,
|
||||
"auto-update": version.IsUnstableBuild() && clientupdate.CanAutoUpdate(),
|
||||
}
|
||||
if env == hostinfo.HomeAssistantAddOn {
|
||||
// Setting SSH on Home Assistant causes trouble on startup
|
||||
// (since the flag is not being passed to `tailscale up`).
|
||||
// Although Tailscale SSH does work here,
|
||||
// it's not terribly useful since it's running in a separate container.
|
||||
features["ssh"] = false
|
||||
}
|
||||
return features
|
||||
}
|
||||
|
||||
func canUseExitNode(env hostinfo.EnvType) error {
|
||||
switch dist := distro.Get(); dist {
|
||||
case distro.Synology, // see https://github.com/tailscale/tailscale/issues/1995
|
||||
distro.QNAP,
|
||||
distro.Unraid:
|
||||
return fmt.Errorf("Tailscale exit nodes cannot be used on %s.", dist)
|
||||
}
|
||||
if env == hostinfo.HomeAssistantAddOn {
|
||||
return errors.New("Tailscale exit nodes cannot be used on Home Assistant.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// aclsAllowAccess returns whether tailnet ACLs (as expressed in the provided filter rules)
|
||||
// permit any devices to access the local web client.
|
||||
// This does not currently check whether a specific device can connect, just any device.
|
||||
@@ -1023,23 +861,6 @@ func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, exitNodes)
|
||||
}
|
||||
|
||||
// maskedPrefs is the subset of ipn.MaskedPrefs that are
|
||||
// allowed to be editable via the web UI.
|
||||
type maskedPrefs struct {
|
||||
RunSSHSet bool
|
||||
RunSSH bool
|
||||
}
|
||||
|
||||
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
|
||||
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
RunSSHSet: prefs.RunSSHSet,
|
||||
Prefs: ipn.Prefs{
|
||||
RunSSH: prefs.RunSSH,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type postRoutesRequest struct {
|
||||
SetExitNode bool // when set, UseExitNode and AdvertiseExitNode values are applied
|
||||
SetRoutes bool // when set, AdvertiseRoutes value is applied
|
||||
@@ -1048,15 +869,23 @@ type postRoutesRequest struct {
|
||||
AdvertiseRoutes []string
|
||||
}
|
||||
|
||||
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
|
||||
prefs, err := s.lc.GetPrefs(ctx)
|
||||
func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
var data postRoutesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
prefs, err := s.lc.GetPrefs(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var currNonExitRoutes []string
|
||||
var currAdvertisingExitNode bool
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if tsaddr.IsExitRoute(r) {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
currAdvertisingExitNode = true
|
||||
continue
|
||||
}
|
||||
@@ -1074,11 +903,18 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
|
||||
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
||||
routes, err := netutil.CalcAdvertiseRoutes(routesStr, data.AdvertiseExitNode)
|
||||
if err != nil {
|
||||
return err
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !data.UseExitNode.IsZero() && tsaddr.ContainsExitRoutes(views.SliceOf(routes)) {
|
||||
return errors.New("cannot use and advertise exit node at same time")
|
||||
hasExitNodeRoute := func(all []netip.Prefix) bool {
|
||||
return slices.Contains(all, exitNodeRouteV4) ||
|
||||
slices.Contains(all, exitNodeRouteV6)
|
||||
}
|
||||
|
||||
if !data.UseExitNode.IsZero() && hasExitNodeRoute(routes) {
|
||||
http.Error(w, "cannot use and advertise exit node at same time", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Make prefs update.
|
||||
@@ -1090,8 +926,12 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
|
||||
AdvertiseRoutes: routes,
|
||||
},
|
||||
}
|
||||
_, err = s.lc.EditPrefs(ctx, p)
|
||||
return err
|
||||
if _, err := s.lc.EditPrefs(r.Context(), p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// tailscaleUp starts the daemon with the provided options.
|
||||
@@ -1136,15 +976,7 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tails
|
||||
if !isRunning {
|
||||
ipnOptions := ipn.Options{AuthKey: opt.AuthKey}
|
||||
if opt.ControlURL != "" {
|
||||
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
ControlURL: opt.ControlURL,
|
||||
},
|
||||
ControlURLSet: true,
|
||||
})
|
||||
if err != nil {
|
||||
s.logf("edit prefs: %v", err)
|
||||
}
|
||||
ipnOptions.UpdatePrefs = &ipn.Prefs{ControlURL: opt.ControlURL}
|
||||
}
|
||||
if err := s.lc.Start(ctx, ipnOptions); err != nil {
|
||||
s.logf("start: %v", err)
|
||||
@@ -1238,12 +1070,26 @@ func (s *Server) serveDeviceDetailsClick(w http.ResponseWriter, r *http.Request)
|
||||
//
|
||||
// The web API request path is expected to exactly match a localapi path,
|
||||
// with prefix /api/local/ rather than /localapi/.
|
||||
//
|
||||
// If the localapi path is not included in localapiAllowlist,
|
||||
// the request is rejected.
|
||||
func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/local")
|
||||
if r.URL.Path == path { // missing prefix
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Method == httpm.PATCH {
|
||||
// enforce that PATCH requests are always application/json
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !slices.Contains(localapiAllowlist, path) {
|
||||
http.Error(w, fmt.Sprintf("%s not allowed from localapi proxy", path), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
|
||||
@@ -1268,6 +1114,21 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// localapiAllowlist is an allowlist of localapi endpoints the
|
||||
// web client is allowed to proxy to the client's localapi.
|
||||
//
|
||||
// Rather than exposing all localapi endpoints over the proxy,
|
||||
// this limits to just the ones actually used from the web
|
||||
// client frontend.
|
||||
var localapiAllowlist = []string{
|
||||
"/v0/logout",
|
||||
"/v0/prefs",
|
||||
"/v0/update/check",
|
||||
"/v0/update/install",
|
||||
"/v0/update/progress",
|
||||
"/v0/upload-client-metrics",
|
||||
}
|
||||
|
||||
// csrfKey returns a key that can be used for CSRF protection.
|
||||
// If an error occurs during key creation, the error is logged and the active process terminated.
|
||||
// If the server is running in CGI mode, the key is cached to disk and reused between requests.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -20,7 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
@@ -87,172 +86,75 @@ func TestQnapAuthnURL(t *testing.T) {
|
||||
|
||||
// TestServeAPI tests the web client api's handling of
|
||||
// 1. invalid endpoint errors
|
||||
// 2. permissioning of api endpoints based on node capabilities
|
||||
// 2. localapi proxy allowlist
|
||||
func TestServeAPI(t *testing.T) {
|
||||
selfTags := views.SliceOf([]string{"tag:server"})
|
||||
self := &ipnstate.PeerStatus{ID: "self", Tags: &selfTags}
|
||||
prefs := &ipn.Prefs{}
|
||||
|
||||
remoteUser := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
remoteIPWithAllCapabilities := "100.100.100.101"
|
||||
remoteIPWithNoCapabilities := "100.100.100.102"
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t,
|
||||
map[string]*apitype.WhoIsResponse{
|
||||
remoteIPWithAllCapabilities: {
|
||||
Node: &tailcfg.Node{StableID: "node1"},
|
||||
UserProfile: remoteUser,
|
||||
CapMap: tailcfg.PeerCapMap{tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{"{\"canEdit\":[\"*\"]}"}},
|
||||
},
|
||||
remoteIPWithNoCapabilities: {
|
||||
Node: &tailcfg.Node{StableID: "node2"},
|
||||
UserProfile: remoteUser,
|
||||
},
|
||||
},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
func() *ipn.Prefs { return prefs },
|
||||
nil,
|
||||
)
|
||||
// Serve dummy localapi. Just returns "success".
|
||||
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "success")
|
||||
})}
|
||||
defer localapi.Close()
|
||||
|
||||
go localapi.Serve(lal)
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &local.Client{Dial: lal.Dial},
|
||||
timeNow: time.Now,
|
||||
}
|
||||
|
||||
type requestTest struct {
|
||||
remoteIP string
|
||||
wantResponse string
|
||||
wantStatus int
|
||||
}
|
||||
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
|
||||
|
||||
tests := []struct {
|
||||
reqPath string
|
||||
name string
|
||||
reqMethod string
|
||||
reqPath string
|
||||
reqContentType string
|
||||
reqBody string
|
||||
tests []requestTest
|
||||
wantResp string
|
||||
wantStatus int
|
||||
}{{
|
||||
reqPath: "/not-an-endpoint",
|
||||
reqMethod: httpm.POST,
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantResponse: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}},
|
||||
name: "invalid_endpoint",
|
||||
reqMethod: httpm.POST,
|
||||
reqPath: "/not-an-endpoint",
|
||||
wantResp: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}, {
|
||||
reqPath: "/local/v0/not-an-endpoint",
|
||||
reqMethod: httpm.POST,
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantResponse: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}},
|
||||
name: "not_in_localapi_allowlist",
|
||||
reqMethod: httpm.POST,
|
||||
reqPath: "/local/v0/not-allowlisted",
|
||||
wantResp: "/v0/not-allowlisted not allowed from localapi proxy",
|
||||
wantStatus: http.StatusForbidden,
|
||||
}, {
|
||||
reqPath: "/local/v0/logout",
|
||||
reqMethod: httpm.POST,
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "not allowed", // requesting node has insufficient permissions
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantResponse: "success", // requesting node has sufficient permissions
|
||||
wantStatus: http.StatusOK,
|
||||
}},
|
||||
name: "in_localapi_allowlist",
|
||||
reqMethod: httpm.POST,
|
||||
reqPath: "/local/v0/logout",
|
||||
wantResp: "success", // Successfully allowed to hit localapi.
|
||||
wantStatus: http.StatusOK,
|
||||
}, {
|
||||
reqPath: "/exit-nodes",
|
||||
reqMethod: httpm.GET,
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "null",
|
||||
wantStatus: http.StatusOK, // allowed, no additional capabilities required
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantResponse: "null",
|
||||
wantStatus: http.StatusOK,
|
||||
}},
|
||||
}, {
|
||||
reqPath: "/routes",
|
||||
reqMethod: httpm.POST,
|
||||
reqBody: "{\"setExitNode\":true}",
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "not allowed",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantStatus: http.StatusOK,
|
||||
}},
|
||||
}, {
|
||||
reqPath: "/local/v0/prefs",
|
||||
name: "patch_bad_contenttype",
|
||||
reqMethod: httpm.PATCH,
|
||||
reqBody: "{\"runSSHSet\":true}",
|
||||
reqContentType: "application/json",
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "not allowed",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantStatus: http.StatusOK,
|
||||
}},
|
||||
}, {
|
||||
reqPath: "/local/v0/prefs",
|
||||
reqMethod: httpm.PATCH,
|
||||
reqContentType: "multipart/form-data",
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "invalid request",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantResponse: "invalid request",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
}},
|
||||
wantResp: "invalid request",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
for _, req := range tt.tests {
|
||||
t.Run(req.remoteIP+"_requesting_"+tt.reqPath, func(t *testing.T) {
|
||||
var reqBody io.Reader
|
||||
if tt.reqBody != "" {
|
||||
reqBody = bytes.NewBuffer([]byte(tt.reqBody))
|
||||
}
|
||||
r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, reqBody)
|
||||
r.RemoteAddr = req.remoteIP
|
||||
if tt.reqContentType != "" {
|
||||
r.Header.Add("Content-Type", tt.reqContentType)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, nil)
|
||||
if tt.reqContentType != "" {
|
||||
r.Header.Add("Content-Type", tt.reqContentType)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.serveAPI(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
if gotStatus := res.StatusCode; req.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%v, got=%v", req.wantStatus, gotStatus)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
|
||||
if req.wantResponse != gotResp {
|
||||
t.Errorf("wrong response; want=%q, got=%q", req.wantResponse, gotResp)
|
||||
}
|
||||
})
|
||||
}
|
||||
s.serveAPI(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
|
||||
if tt.wantResp != gotResp {
|
||||
t.Errorf("wrong response; want=%q, got=%q", tt.wantResp, gotResp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +190,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
|
||||
s := &Server{
|
||||
timeNow: time.Now,
|
||||
lc: &local.Client{Dial: lal.Dial},
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
}
|
||||
|
||||
// Add some browser sessions to cache state.
|
||||
@@ -457,7 +359,7 @@ func TestAuthorizeRequest(t *testing.T) {
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &local.Client{Dial: lal.Dial},
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: time.Now,
|
||||
}
|
||||
validCookie := "ts-cookie"
|
||||
@@ -548,7 +450,6 @@ func TestServeAuth(t *testing.T) {
|
||||
NodeName: remoteNode.Node.Name,
|
||||
NodeIP: remoteIP,
|
||||
ProfilePicURL: user.ProfilePicURL,
|
||||
Capabilities: peerCapabilities{capFeatureAll: true},
|
||||
}
|
||||
|
||||
testControlURL := &defaultControlURL
|
||||
@@ -572,7 +473,7 @@ func TestServeAuth(t *testing.T) {
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &local.Client{Dial: lal.Dial},
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: func() time.Time { return timeNow },
|
||||
newAuthURL: mockNewAuthURL,
|
||||
waitAuthURL: mockWaitAuthURL,
|
||||
@@ -622,7 +523,7 @@ func TestServeAuth(t *testing.T) {
|
||||
name: "no-session",
|
||||
path: "/api/auth",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantNewCookie: false,
|
||||
wantSession: nil,
|
||||
},
|
||||
@@ -647,7 +548,7 @@ func TestServeAuth(t *testing.T) {
|
||||
path: "/api/auth",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
@@ -695,7 +596,7 @@ func TestServeAuth(t *testing.T) {
|
||||
path: "/api/auth",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{Authorized: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
@@ -914,7 +815,7 @@ func TestServeAPIAuthMetricLogging(t *testing.T) {
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &local.Client{Dial: lal.Dial},
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: func() time.Time { return timeNow },
|
||||
newAuthURL: mockNewAuthURL,
|
||||
waitAuthURL: mockWaitAuthURL,
|
||||
@@ -1126,7 +1027,7 @@ func TestRequireTailscaleIP(t *testing.T) {
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &local.Client{Dial: lal.Dial},
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: time.Now,
|
||||
logf: t.Logf,
|
||||
}
|
||||
@@ -1196,217 +1097,6 @@ func TestRequireTailscaleIP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerCapabilities(t *testing.T) {
|
||||
userOwnedStatus := &ipnstate.Status{Self: &ipnstate.PeerStatus{UserID: tailcfg.UserID(1)}}
|
||||
tags := views.SliceOf[string]([]string{"tag:server"})
|
||||
tagOwnedStatus := &ipnstate.Status{Self: &ipnstate.PeerStatus{Tags: &tags}}
|
||||
|
||||
// Testing web.toPeerCapabilities
|
||||
toPeerCapsTests := []struct {
|
||||
name string
|
||||
status *ipnstate.Status
|
||||
whois *apitype.WhoIsResponse
|
||||
wantCaps peerCapabilities
|
||||
}{
|
||||
{
|
||||
name: "empty-whois",
|
||||
status: userOwnedStatus,
|
||||
whois: nil,
|
||||
wantCaps: peerCapabilities{},
|
||||
},
|
||||
{
|
||||
name: "user-owned-node-non-owner-caps-ignored",
|
||||
status: userOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(2)},
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{},
|
||||
},
|
||||
{
|
||||
name: "user-owned-node-owner-caps-ignored",
|
||||
status: userOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)},
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{capFeatureAll: true}, // should just have wildcard
|
||||
},
|
||||
{
|
||||
name: "tag-owned-no-webui-caps",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{},
|
||||
},
|
||||
{
|
||||
name: "tag-owned-one-webui-cap",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnets: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tag-owned-multiple-webui-cap",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
|
||||
"{\"canEdit\":[\"subnets\",\"exitnodes\",\"*\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnets: true,
|
||||
capFeatureExitNodes: true,
|
||||
capFeatureAll: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tag-owned-case-insensitive-caps",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"SSH\",\"sUBnets\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnets: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tag-owned-random-canEdit-contents-get-dropped",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"unknown-feature\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{},
|
||||
},
|
||||
{
|
||||
name: "tag-owned-no-canEdit-section",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canDoSomething\":[\"*\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{},
|
||||
},
|
||||
{
|
||||
name: "tagged-source-caps-ignored",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1), Tags: tags.AsSlice()},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{},
|
||||
},
|
||||
}
|
||||
for _, tt := range toPeerCapsTests {
|
||||
t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) {
|
||||
got, err := toPeerCapabilities(tt.status, tt.whois)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
if diff := cmp.Diff(got, tt.wantCaps); diff != "" {
|
||||
t.Errorf("wrong caps; (-got+want):%v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Testing web.peerCapabilities.canEdit
|
||||
canEditTests := []struct {
|
||||
name string
|
||||
caps peerCapabilities
|
||||
wantCanEdit map[capFeature]bool
|
||||
}{
|
||||
{
|
||||
name: "empty-caps",
|
||||
caps: nil,
|
||||
wantCanEdit: map[capFeature]bool{
|
||||
capFeatureAll: false,
|
||||
capFeatureSSH: false,
|
||||
capFeatureSubnets: false,
|
||||
capFeatureExitNodes: false,
|
||||
capFeatureAccount: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "some-caps",
|
||||
caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true},
|
||||
wantCanEdit: map[capFeature]bool{
|
||||
capFeatureAll: false,
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnets: false,
|
||||
capFeatureExitNodes: false,
|
||||
capFeatureAccount: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard-in-caps",
|
||||
caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true},
|
||||
wantCanEdit: map[capFeature]bool{
|
||||
capFeatureAll: true,
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnets: true,
|
||||
capFeatureExitNodes: true,
|
||||
capFeatureAccount: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range canEditTests {
|
||||
t.Run("canEdit-"+tt.name, func(t *testing.T) {
|
||||
for f, want := range tt.wantCanEdit {
|
||||
if got := tt.caps.canEdit(f); got != want {
|
||||
t.Errorf("wrong canEdit(%s); got=%v, want=%v", f, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
defaultControlURL = "https://controlplane.tailscale.com"
|
||||
testAuthPath = "/a/12345"
|
||||
@@ -1453,9 +1143,6 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
|
||||
metricCapture(metricNames[0].Name)
|
||||
writeJSON(w, struct{}{})
|
||||
return
|
||||
case "/localapi/v0/logout":
|
||||
fmt.Fprintf(w, "success")
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
|
||||
}
|
||||
|
||||
1572
client/web/yarn.lock
1572
client/web/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -27,26 +27,21 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/hostinfo"
|
||||
"github.com/google/uuid"
|
||||
"tailscale.com/clientupdate/distsign"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/cmpver"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
const (
|
||||
CurrentTrack = ""
|
||||
StableTrack = "stable"
|
||||
UnstableTrack = "unstable"
|
||||
)
|
||||
|
||||
var CurrentTrack = func() string {
|
||||
if version.IsUnstableBuild() {
|
||||
return UnstableTrack
|
||||
} else {
|
||||
return StableTrack
|
||||
}
|
||||
}()
|
||||
|
||||
func versionToTrack(v string) (string, error) {
|
||||
_, rest, ok := strings.Cut(v, ".")
|
||||
if !ok {
|
||||
@@ -111,7 +106,7 @@ func (args Arguments) validate() error {
|
||||
return fmt.Errorf("only one of Version(%q) or Track(%q) can be set", args.Version, args.Track)
|
||||
}
|
||||
switch args.Track {
|
||||
case StableTrack, UnstableTrack, "":
|
||||
case StableTrack, UnstableTrack, CurrentTrack:
|
||||
// All valid values.
|
||||
default:
|
||||
return fmt.Errorf("unsupported track %q", args.Track)
|
||||
@@ -124,17 +119,11 @@ type Updater struct {
|
||||
// Update is a platform-specific method that updates the installation. May be
|
||||
// nil (not all platforms support updates from within Tailscale).
|
||||
Update func() error
|
||||
|
||||
// currentVersion is the short form of the current client version as
|
||||
// returned by version.Short(), typically "x.y.z". Used for tests to
|
||||
// override the actual current version.
|
||||
currentVersion string
|
||||
}
|
||||
|
||||
func NewUpdater(args Arguments) (*Updater, error) {
|
||||
up := Updater{
|
||||
Arguments: args,
|
||||
currentVersion: version.Short(),
|
||||
Arguments: args,
|
||||
}
|
||||
if up.Stdout == nil {
|
||||
up.Stdout = os.Stdout
|
||||
@@ -150,15 +139,18 @@ func NewUpdater(args Arguments) (*Updater, error) {
|
||||
if args.ForAutoUpdate && !canAutoUpdate {
|
||||
return nil, errors.ErrUnsupported
|
||||
}
|
||||
if up.Track == "" {
|
||||
if up.Version != "" {
|
||||
if up.Track == CurrentTrack {
|
||||
switch {
|
||||
case up.Version != "":
|
||||
var err error
|
||||
up.Track, err = versionToTrack(args.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
up.Track = CurrentTrack
|
||||
case version.IsUnstableBuild():
|
||||
up.Track = UnstableTrack
|
||||
default:
|
||||
up.Track = StableTrack
|
||||
}
|
||||
}
|
||||
if up.Arguments.PkgsAddr == "" {
|
||||
@@ -170,21 +162,11 @@ func NewUpdater(args Arguments) (*Updater, error) {
|
||||
type updateFunction func() error
|
||||
|
||||
func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
|
||||
hi := hostinfo.New()
|
||||
// We don't know how to update custom tsnet binaries, it's up to the user.
|
||||
if hi.Package == "tsnet" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return up.updateWindows, true
|
||||
case "linux":
|
||||
switch distro.Get() {
|
||||
case distro.NixOS:
|
||||
// NixOS packages are immutable and managed with a system-wide
|
||||
// configuration.
|
||||
return up.updateNixos, false
|
||||
case distro.Synology:
|
||||
// Synology updates use our own pkgs.tailscale.com instead of the
|
||||
// Synology Package Center. We should eventually get to a regular
|
||||
@@ -252,11 +234,6 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
|
||||
// CanAutoUpdate reports whether auto-updating via the clientupdate package
|
||||
// is supported for the current os/distro.
|
||||
func CanAutoUpdate() bool {
|
||||
if version.IsMacSysExt() {
|
||||
// Macsys uses Sparkle for auto-updates, which doesn't have an update
|
||||
// function in this package.
|
||||
return true
|
||||
}
|
||||
_, canAutoUpdate := (&Updater{}).getUpdateFunction()
|
||||
return canAutoUpdate
|
||||
}
|
||||
@@ -278,16 +255,13 @@ func Update(args Arguments) error {
|
||||
}
|
||||
|
||||
func (up *Updater) confirm(ver string) bool {
|
||||
// Only check version when we're not switching tracks.
|
||||
if up.Track == "" || up.Track == CurrentTrack {
|
||||
switch c := cmpver.Compare(up.currentVersion, ver); {
|
||||
case c == 0:
|
||||
up.Logf("already running %v version %v; no update needed", up.Track, ver)
|
||||
return false
|
||||
case c > 0:
|
||||
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.Track, up.currentVersion, ver)
|
||||
return false
|
||||
}
|
||||
switch cmpver.Compare(version.Short(), ver) {
|
||||
case 0:
|
||||
up.Logf("already running %v version %v; no update needed", up.Track, ver)
|
||||
return false
|
||||
case 1:
|
||||
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.Track, version.Short(), ver)
|
||||
return false
|
||||
}
|
||||
if up.Confirm != nil {
|
||||
return up.Confirm(ver)
|
||||
@@ -458,7 +432,7 @@ func (up *Updater) updateDebLike() error {
|
||||
return fmt.Errorf("apt-get update failed: %w; output:\n%s", err, out)
|
||||
}
|
||||
|
||||
for range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
out, err := exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver).CombinedOutput()
|
||||
if err != nil {
|
||||
if !bytes.Contains(out, []byte(`dpkg was interrupted`)) {
|
||||
@@ -548,13 +522,6 @@ func (up *Updater) updateArchLike() error {
|
||||
you can use "pacman --sync --refresh --sysupgrade" or "pacman -Syu" to upgrade the system, including Tailscale.`)
|
||||
}
|
||||
|
||||
func (up *Updater) updateNixos() error {
|
||||
// NixOS package updates are managed on a system level and not individually.
|
||||
// Direct users to update their nix channel or nixpkgs flake input to
|
||||
// receive the latest version.
|
||||
return errors.New(`individual package updates are not supported on NixOS installations. Update your system channel or flake inputs to get the latest Tailscale version from nixpkgs.`)
|
||||
}
|
||||
|
||||
const yumRepoConfigFile = "/etc/yum.repos.d/tailscale.repo"
|
||||
|
||||
// updateFedoraLike updates tailscale on any distros in the Fedora family,
|
||||
@@ -673,9 +640,6 @@ func (up *Updater) updateAlpineLike() (err error) {
|
||||
return fmt.Errorf(`failed to parse latest version from "apk info tailscale": %w`, err)
|
||||
}
|
||||
if !up.confirm(ver) {
|
||||
if err := checkOutdatedAlpineRepo(up.Logf, ver, up.Track); err != nil {
|
||||
up.Logf("failed to check whether Alpine release is outdated: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -690,7 +654,6 @@ func (up *Updater) updateAlpineLike() (err error) {
|
||||
|
||||
func parseAlpinePackageVersion(out []byte) (string, error) {
|
||||
s := bufio.NewScanner(bytes.NewReader(out))
|
||||
var maxVer string
|
||||
for s.Scan() {
|
||||
// The line should look like this:
|
||||
// tailscale-1.44.2-r0 description:
|
||||
@@ -702,48 +665,11 @@ func parseAlpinePackageVersion(out []byte) (string, error) {
|
||||
if len(parts) < 3 {
|
||||
return "", fmt.Errorf("malformed info line: %q", line)
|
||||
}
|
||||
ver := parts[1]
|
||||
if cmpver.Compare(ver, maxVer) > 0 {
|
||||
maxVer = ver
|
||||
}
|
||||
}
|
||||
if maxVer != "" {
|
||||
return maxVer, nil
|
||||
return parts[1], nil
|
||||
}
|
||||
return "", errors.New("tailscale version not found in output")
|
||||
}
|
||||
|
||||
var apkRepoVersionRE = regexp.MustCompile(`v[0-9]+\.[0-9]+`)
|
||||
|
||||
func checkOutdatedAlpineRepo(logf logger.Logf, apkVer, track string) error {
|
||||
latest, err := LatestTailscaleVersion(track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if latest == apkVer {
|
||||
// Actually on latest release.
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open("/etc/apk/repositories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
// Read the first repo line. Typically, there are multiple repos that all
|
||||
// contain the same version in the path, like:
|
||||
// https://dl-cdn.alpinelinux.org/alpine/v3.20/main
|
||||
// https://dl-cdn.alpinelinux.org/alpine/v3.20/community
|
||||
s := bufio.NewScanner(f)
|
||||
if !s.Scan() {
|
||||
return s.Err()
|
||||
}
|
||||
alpineVer := apkRepoVersionRE.FindString(s.Text())
|
||||
if alpineVer != "" {
|
||||
logf("The latest Tailscale release for Linux is %q, but your apk repository only provides %q.\nYour Alpine version is %q, you may need to upgrade the system to get the latest Tailscale version: https://wiki.alpinelinux.org/wiki/Upgrading_Alpine", latest, apkVer, alpineVer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (up *Updater) updateMacSys() error {
|
||||
return errors.New("NOTREACHED: On MacSys builds, `tailscale update` is handled in Swift to launch the GUI updater")
|
||||
}
|
||||
@@ -760,6 +686,164 @@ func (up *Updater) updateMacAppStore() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
// winMSIEnv is the environment variable that, if set, is the MSI file for
|
||||
// the update command to install. It's passed like this so we can stop the
|
||||
// tailscale.exe process from running before the msiexec process runs and
|
||||
// tries to overwrite ourselves.
|
||||
winMSIEnv = "TS_UPDATE_WIN_MSI"
|
||||
// winExePathEnv is the environment variable that is set along with
|
||||
// winMSIEnv and carries the full path of the calling tailscale.exe binary.
|
||||
// It is used to re-launch the GUI process (tailscale-ipn.exe) after
|
||||
// install is complete.
|
||||
winExePathEnv = "TS_UPDATE_WIN_EXE_PATH"
|
||||
)
|
||||
|
||||
var (
|
||||
verifyAuthenticode func(string) error // set non-nil only on Windows
|
||||
markTempFileFunc func(string) error // set non-nil only on Windows
|
||||
)
|
||||
|
||||
func (up *Updater) updateWindows() error {
|
||||
if msi := os.Getenv(winMSIEnv); msi != "" {
|
||||
// stdout/stderr from this part of the install could be lost since the
|
||||
// parent tailscaled is replaced. Create a temp log file to have some
|
||||
// output to debug with in case update fails.
|
||||
close, err := up.switchOutputToFile()
|
||||
if err != nil {
|
||||
up.Logf("failed to create log file for installation: %v; proceeding with existing outputs", err)
|
||||
} else {
|
||||
defer close.Close()
|
||||
}
|
||||
|
||||
up.Logf("installing %v ...", msi)
|
||||
if err := up.installMSI(msi); err != nil {
|
||||
up.Logf("MSI install failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
up.Logf("success.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !winutil.IsCurrentProcessElevated() {
|
||||
return errors.New(`update must be run as Administrator
|
||||
|
||||
you can run the command prompt as Administrator one of these ways:
|
||||
* right-click cmd.exe, select 'Run as administrator'
|
||||
* press Windows+x, then press a
|
||||
* press Windows+r, type in "cmd", then press Ctrl+Shift+Enter`)
|
||||
}
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
if !up.confirm(ver) {
|
||||
return nil
|
||||
}
|
||||
|
||||
tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
|
||||
msiDir := filepath.Join(tsDir, "MSICache")
|
||||
if fi, err := os.Stat(tsDir); err != nil {
|
||||
return fmt.Errorf("expected %s to exist, got stat error: %w", tsDir, err)
|
||||
} else if !fi.IsDir() {
|
||||
return fmt.Errorf("expected %s to be a directory; got %v", tsDir, fi.Mode())
|
||||
}
|
||||
if err := os.MkdirAll(msiDir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
up.cleanupOldDownloads(filepath.Join(msiDir, "*.msi"))
|
||||
pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s-%s.msi", up.Track, ver, arch)
|
||||
msiTarget := filepath.Join(msiDir, path.Base(pkgsPath))
|
||||
if err := up.downloadURLToFile(pkgsPath, msiTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
up.Logf("verifying MSI authenticode...")
|
||||
if err := verifyAuthenticode(msiTarget); err != nil {
|
||||
return fmt.Errorf("authenticode verification of %s failed: %w", msiTarget, err)
|
||||
}
|
||||
up.Logf("authenticode verification succeeded")
|
||||
|
||||
up.Logf("making tailscale.exe copy to switch to...")
|
||||
up.cleanupOldDownloads(filepath.Join(os.TempDir(), "tailscale-updater-*.exe"))
|
||||
selfOrig, selfCopy, err := makeSelfCopy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(selfCopy)
|
||||
up.Logf("running tailscale.exe copy for final install...")
|
||||
|
||||
cmd := exec.Command(selfCopy, "update")
|
||||
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winExePathEnv+"="+selfOrig)
|
||||
cmd.Stdout = up.Stderr
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Once it's started, exit ourselves, so the binary is free
|
||||
// to be replaced.
|
||||
os.Exit(0)
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
func (up *Updater) switchOutputToFile() (io.Closer, error) {
|
||||
var logFilePath string
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
logFilePath = filepath.Join(os.TempDir(), "tailscale-updater.log")
|
||||
} else {
|
||||
logFilePath = strings.TrimSuffix(exePath, ".exe") + ".log"
|
||||
}
|
||||
|
||||
up.Logf("writing update output to %q", logFilePath)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
up.Logf = func(m string, args ...any) {
|
||||
fmt.Fprintf(logFile, m+"\n", args...)
|
||||
}
|
||||
up.Stdout = logFile
|
||||
up.Stderr = logFile
|
||||
return logFile, nil
|
||||
}
|
||||
|
||||
func (up *Updater) installMSI(msi string) error {
|
||||
var err error
|
||||
for tries := 0; tries < 2; tries++ {
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
|
||||
cmd.Dir = filepath.Dir(msi)
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
up.Logf("Install attempt failed: %v", err)
|
||||
uninstallVersion := 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 = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
up.Logf("msiexec uninstall: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// cleanupOldDownloads removes all files matching glob (see filepath.Glob).
|
||||
// Only regular files are removed, so the glob must match specific files and
|
||||
// not directories.
|
||||
@@ -784,6 +868,53 @@ func (up *Updater) cleanupOldDownloads(glob string) {
|
||||
}
|
||||
}
|
||||
|
||||
func msiUUIDForVersion(ver string) string {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
track, err := versionToTrack(ver)
|
||||
if err != nil {
|
||||
track = UnstableTrack
|
||||
}
|
||||
msiURL := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", track, ver, arch)
|
||||
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
|
||||
}
|
||||
|
||||
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
f, err := os.Open(selfExe)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer f.Close()
|
||||
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if f := markTempFileFunc; f != nil {
|
||||
if err := f(f2.Name()); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
if _, err := io.Copy(f2, f); err != nil {
|
||||
f2.Close()
|
||||
return "", "", err
|
||||
}
|
||||
return selfExe, f2.Name(), f2.Close()
|
||||
}
|
||||
|
||||
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
|
||||
c, err := distsign.NewClient(up.Logf, up.PkgsAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Download(context.Background(), pathSrc, fileDst)
|
||||
}
|
||||
|
||||
func (up *Updater) updateFreeBSD() (err error) {
|
||||
if up.Version != "" {
|
||||
return errors.New("installing a specific version on FreeBSD is not supported")
|
||||
@@ -868,20 +999,6 @@ func (up *Updater) updateLinuxBinary() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func restartSystemdUnit(ctx context.Context) error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
// Likely not a systemd-managed distro.
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("systemctl daemon-reload failed: %w\noutput: %s", err, out)
|
||||
}
|
||||
if out, err := exec.Command("systemctl", "restart", "tailscaled.service").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("systemctl restart failed: %w\noutput: %s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (up *Updater) downloadLinuxTarball(ver string) (string, error) {
|
||||
dlDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
@@ -1148,31 +1265,22 @@ func requestedTailscaleVersion(ver, track string) (string, error) {
|
||||
// LatestTailscaleVersion returns the latest released version for the given
|
||||
// track from pkgs.tailscale.com.
|
||||
func LatestTailscaleVersion(track string) (string, error) {
|
||||
if track == "" {
|
||||
track = CurrentTrack
|
||||
if track == CurrentTrack {
|
||||
if version.IsUnstableBuild() {
|
||||
track = UnstableTrack
|
||||
} else {
|
||||
track = StableTrack
|
||||
}
|
||||
}
|
||||
|
||||
latest, err := latestPackages(track)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ver := latest.Version
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
ver = latest.MSIsVersion
|
||||
case "darwin":
|
||||
ver = latest.MacZipsVersion
|
||||
case "linux":
|
||||
ver = latest.TarballsVersion
|
||||
if distro.Get() == distro.Synology {
|
||||
ver = latest.SPKsVersion
|
||||
}
|
||||
if latest.Version == "" {
|
||||
return "", fmt.Errorf("no latest version found for %q track", track)
|
||||
}
|
||||
|
||||
if ver == "" {
|
||||
return "", fmt.Errorf("no latest version found for OS %q on %q track", runtime.GOOS, track)
|
||||
}
|
||||
return ver, nil
|
||||
return latest.Version, nil
|
||||
}
|
||||
|
||||
type trackPackages struct {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build (linux && !android) || windows
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"tailscale.com/clientupdate/distsign"
|
||||
)
|
||||
|
||||
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
|
||||
c, err := distsign.NewClient(up.Logf, up.PkgsAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Download(context.Background(), pathSrc, fileDst)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !((linux && !android) || windows)
|
||||
|
||||
package clientupdate
|
||||
|
||||
func (up *Updater) downloadURLToFile(pathSrc, fileDst string) (ret error) {
|
||||
panic("unreachable")
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package clientupdate
|
||||
|
||||
func (up *Updater) updateWindows() error {
|
||||
panic("unreachable")
|
||||
}
|
||||
@@ -251,29 +251,6 @@ tailscale installed size:
|
||||
out: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "multiple versions",
|
||||
out: `
|
||||
tailscale-1.54.1-r0 description:
|
||||
The easiest, most secure way to use WireGuard and 2FA
|
||||
|
||||
tailscale-1.54.1-r0 webpage:
|
||||
https://tailscale.com/
|
||||
|
||||
tailscale-1.54.1-r0 installed size:
|
||||
34 MiB
|
||||
|
||||
tailscale-1.58.2-r0 description:
|
||||
The easiest, most secure way to use WireGuard and 2FA
|
||||
|
||||
tailscale-1.58.2-r0 webpage:
|
||||
https://tailscale.com/
|
||||
|
||||
tailscale-1.58.2-r0 installed size:
|
||||
35 MiB
|
||||
`,
|
||||
want: "1.58.2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -663,7 +640,7 @@ func genTarball(t *testing.T, path string, files map[string]string) {
|
||||
|
||||
func TestWriteFileOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test")
|
||||
for i := range 2 {
|
||||
for i := 0; i < 2; i++ {
|
||||
content := fmt.Sprintf("content %d", i)
|
||||
if err := writeFile(strings.NewReader(content), path, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -846,107 +823,3 @@ func TestParseUnraidPluginVersion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirm(t *testing.T) {
|
||||
curTrack := CurrentTrack
|
||||
defer func() { CurrentTrack = curTrack }()
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
fromTrack string
|
||||
toTrack string
|
||||
fromVer string
|
||||
toVer string
|
||||
confirm func(string) bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
desc: "on latest stable",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.66.0",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
desc: "stable upgrade",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.68.0",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "unstable upgrade",
|
||||
fromTrack: UnstableTrack,
|
||||
toTrack: UnstableTrack,
|
||||
fromVer: "1.67.1",
|
||||
toVer: "1.67.2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "from stable to unstable",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: UnstableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.67.1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "from unstable to stable",
|
||||
fromTrack: UnstableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.67.1",
|
||||
toVer: "1.66.0",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "confirm callback rejects",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.66.1",
|
||||
confirm: func(string) bool {
|
||||
return false
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
desc: "confirm callback allows",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.66.1",
|
||||
confirm: func(string) bool {
|
||||
return true
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "downgrade",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.1",
|
||||
toVer: "1.66.0",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
CurrentTrack = tt.fromTrack
|
||||
up := Updater{
|
||||
currentVersion: tt.fromVer,
|
||||
Arguments: Arguments{
|
||||
Track: tt.toTrack,
|
||||
Confirm: tt.confirm,
|
||||
Logf: t.Logf,
|
||||
},
|
||||
}
|
||||
|
||||
if got := up.confirm(tt.toVer); got != tt.want {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,57 +7,13 @@
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/sys/windows"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/util/winutil/authenticode"
|
||||
)
|
||||
|
||||
const (
|
||||
// winMSIEnv is the environment variable that, if set, is the MSI file for
|
||||
// the update command to install. It's passed like this so we can stop the
|
||||
// tailscale.exe process from running before the msiexec process runs and
|
||||
// tries to overwrite ourselves.
|
||||
winMSIEnv = "TS_UPDATE_WIN_MSI"
|
||||
// winExePathEnv is the environment variable that is set along with
|
||||
// winMSIEnv and carries the full path of the calling tailscale.exe binary.
|
||||
// It is used to re-launch the GUI process (tailscale-ipn.exe) after
|
||||
// install is complete.
|
||||
winExePathEnv = "TS_UPDATE_WIN_EXE_PATH"
|
||||
)
|
||||
|
||||
func makeSelfCopy() (origPathExe, tmpPathExe string, err error) {
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
f, err := os.Open(selfExe)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer f.Close()
|
||||
f2, err := os.CreateTemp("", "tailscale-updater-*.exe")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := markTempFileWindows(f2.Name()); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := io.Copy(f2, f); err != nil {
|
||||
f2.Close()
|
||||
return "", "", err
|
||||
}
|
||||
return selfExe, f2.Name(), f2.Close()
|
||||
func init() {
|
||||
markTempFileFunc = markTempFileWindows
|
||||
verifyAuthenticode = verifyTailscale
|
||||
}
|
||||
|
||||
func markTempFileWindows(name string) error {
|
||||
@@ -67,159 +23,6 @@ func markTempFileWindows(name string) error {
|
||||
|
||||
const certSubjectTailscale = "Tailscale Inc."
|
||||
|
||||
func verifyAuthenticode(path string) error {
|
||||
func verifyTailscale(path string) error {
|
||||
return authenticode.Verify(path, certSubjectTailscale)
|
||||
}
|
||||
|
||||
func (up *Updater) updateWindows() error {
|
||||
if msi := os.Getenv(winMSIEnv); msi != "" {
|
||||
// stdout/stderr from this part of the install could be lost since the
|
||||
// parent tailscaled is replaced. Create a temp log file to have some
|
||||
// output to debug with in case update fails.
|
||||
close, err := up.switchOutputToFile()
|
||||
if err != nil {
|
||||
up.Logf("failed to create log file for installation: %v; proceeding with existing outputs", err)
|
||||
} else {
|
||||
defer close.Close()
|
||||
}
|
||||
|
||||
up.Logf("installing %v ...", msi)
|
||||
if err := up.installMSI(msi); err != nil {
|
||||
up.Logf("MSI install failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
up.Logf("success.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !winutil.IsCurrentProcessElevated() {
|
||||
return errors.New(`update must be run as Administrator
|
||||
|
||||
you can run the command prompt as Administrator one of these ways:
|
||||
* right-click cmd.exe, select 'Run as administrator'
|
||||
* press Windows+x, then press a
|
||||
* press Windows+r, type in "cmd", then press Ctrl+Shift+Enter`)
|
||||
}
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
if !up.confirm(ver) {
|
||||
return nil
|
||||
}
|
||||
|
||||
tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale")
|
||||
msiDir := filepath.Join(tsDir, "MSICache")
|
||||
if fi, err := os.Stat(tsDir); err != nil {
|
||||
return fmt.Errorf("expected %s to exist, got stat error: %w", tsDir, err)
|
||||
} else if !fi.IsDir() {
|
||||
return fmt.Errorf("expected %s to be a directory; got %v", tsDir, fi.Mode())
|
||||
}
|
||||
if err := os.MkdirAll(msiDir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
up.cleanupOldDownloads(filepath.Join(msiDir, "*.msi"))
|
||||
pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s-%s.msi", up.Track, ver, arch)
|
||||
msiTarget := filepath.Join(msiDir, path.Base(pkgsPath))
|
||||
if err := up.downloadURLToFile(pkgsPath, msiTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
up.Logf("verifying MSI authenticode...")
|
||||
if err := verifyAuthenticode(msiTarget); err != nil {
|
||||
return fmt.Errorf("authenticode verification of %s failed: %w", msiTarget, err)
|
||||
}
|
||||
up.Logf("authenticode verification succeeded")
|
||||
|
||||
up.Logf("making tailscale.exe copy to switch to...")
|
||||
up.cleanupOldDownloads(filepath.Join(os.TempDir(), "tailscale-updater-*.exe"))
|
||||
selfOrig, selfCopy, err := makeSelfCopy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(selfCopy)
|
||||
up.Logf("running tailscale.exe copy for final install...")
|
||||
|
||||
cmd := exec.Command(selfCopy, "update")
|
||||
cmd.Env = append(os.Environ(), winMSIEnv+"="+msiTarget, winExePathEnv+"="+selfOrig)
|
||||
cmd.Stdout = up.Stderr
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Once it's started, exit ourselves, so the binary is free
|
||||
// to be replaced.
|
||||
os.Exit(0)
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
func (up *Updater) installMSI(msi string) error {
|
||||
var err error
|
||||
for tries := 0; tries < 2; tries++ {
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/norestart", "/qn")
|
||||
cmd.Dir = filepath.Dir(msi)
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
up.Logf("Install attempt failed: %v", err)
|
||||
uninstallVersion := up.currentVersion
|
||||
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
|
||||
uninstallVersion = v
|
||||
}
|
||||
// Assume it's a downgrade, which msiexec won't permit. Uninstall our current version first.
|
||||
up.Logf("Uninstalling current version %q for downgrade...", uninstallVersion)
|
||||
cmd = exec.Command("msiexec.exe", "/x", msiUUIDForVersion(uninstallVersion), "/norestart", "/qn")
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
err = cmd.Run()
|
||||
up.Logf("msiexec uninstall: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func msiUUIDForVersion(ver string) string {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "386" {
|
||||
arch = "x86"
|
||||
}
|
||||
track, err := versionToTrack(ver)
|
||||
if err != nil {
|
||||
track = UnstableTrack
|
||||
}
|
||||
msiURL := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%s-%s.msi", track, ver, arch)
|
||||
return "{" + strings.ToUpper(uuid.NewSHA1(uuid.NameSpaceURL, []byte(msiURL)).String()) + "}"
|
||||
}
|
||||
|
||||
func (up *Updater) switchOutputToFile() (io.Closer, error) {
|
||||
var logFilePath string
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
logFilePath = filepath.Join(os.TempDir(), "tailscale-updater.log")
|
||||
} else {
|
||||
logFilePath = strings.TrimSuffix(exePath, ".exe") + ".log"
|
||||
}
|
||||
|
||||
up.Logf("writing update output to %q", logFilePath)
|
||||
logFile, err := os.Create(logFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
up.Logf = func(m string, args ...any) {
|
||||
fmt.Fprintf(logFile, m+"\n", args...)
|
||||
}
|
||||
up.Stdout = logFile
|
||||
up.Stderr = logFile
|
||||
return logFile, nil
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@ type testServer struct {
|
||||
|
||||
func newTestServer(t *testing.T) *testServer {
|
||||
var roots []rootKeyPair
|
||||
for range 3 {
|
||||
for i := 0; i < 3; i++ {
|
||||
roots = append(roots, newRootKeyPair(t))
|
||||
}
|
||||
|
||||
|
||||
37
clientupdate/systemd_linux.go
Normal file
37
clientupdate/systemd_linux.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/coreos/go-systemd/v22/dbus"
|
||||
)
|
||||
|
||||
func restartSystemdUnit(ctx context.Context) error {
|
||||
c, err := dbus.NewWithContext(ctx)
|
||||
if err != nil {
|
||||
// Likely not a systemd-managed distro.
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.ReloadContext(ctx); err != nil {
|
||||
return fmt.Errorf("failed to reload tailsacled.service: %w", err)
|
||||
}
|
||||
ch := make(chan string, 1)
|
||||
if _, err := c.RestartUnitContext(ctx, "tailscaled.service", "replace", ch); err != nil {
|
||||
return fmt.Errorf("failed to restart tailsacled.service: %w", err)
|
||||
}
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res != "done" {
|
||||
return fmt.Errorf("systemd service restart failed with result %q", res)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
15
clientupdate/systemd_other.go
Normal file
15
clientupdate/systemd_other.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !linux
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func restartSystemdUnit(ctx context.Context) error {
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
@@ -18,12 +18,12 @@ var (
|
||||
)
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
usage: addlicense -file FILE <subcommand args...>
|
||||
`[1:])
|
||||
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprint(os.Stderr, `
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
addlicense adds a Tailscale license to the beginning of file.
|
||||
|
||||
It is intended for use with 'go generate', so it also runs a subcommand,
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// checkmetrics validates that all metrics in the tailscale client-metrics
|
||||
// are documented in a given path or URL.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tstest/integration/testcontrol"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
var (
|
||||
kbPath = flag.String("kb-path", "", "filepath to the client-metrics knowledge base")
|
||||
kbUrl = flag.String("kb-url", "", "URL to the client-metrics knowledge base page")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *kbPath == "" && *kbUrl == "" {
|
||||
log.Fatalf("either -kb-path or -kb-url must be set")
|
||||
}
|
||||
|
||||
var control testcontrol.Server
|
||||
ts := httptest.NewServer(&control)
|
||||
defer ts.Close()
|
||||
|
||||
td, err := os.MkdirTemp("", "testcontrol")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(td)
|
||||
|
||||
// tsnet is used not used as a Tailscale client, but as a way to
|
||||
// boot up Tailscale, have all the metrics registered, and then
|
||||
// verifiy that all the metrics are documented.
|
||||
tsn := &tsnet.Server{
|
||||
Dir: td,
|
||||
Store: new(mem.Store),
|
||||
UserLogf: log.Printf,
|
||||
Ephemeral: true,
|
||||
ControlURL: ts.URL,
|
||||
}
|
||||
if err := tsn.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer tsn.Close()
|
||||
|
||||
log.Printf("checking that all metrics are documented, looking for: %s", tsn.Sys().UserMetricsRegistry().MetricNames())
|
||||
|
||||
if *kbPath != "" {
|
||||
kb, err := readKB(*kbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("reading kb: %v", err)
|
||||
}
|
||||
missing := undocumentedMetrics(kb, tsn.Sys().UserMetricsRegistry().MetricNames())
|
||||
|
||||
if len(missing) > 0 {
|
||||
log.Fatalf("found undocumented metrics in %q: %v", *kbPath, missing)
|
||||
}
|
||||
}
|
||||
|
||||
if *kbUrl != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
kb, err := getKB(ctx, *kbUrl)
|
||||
if err != nil {
|
||||
log.Fatalf("getting kb: %v", err)
|
||||
}
|
||||
missing := undocumentedMetrics(kb, tsn.Sys().UserMetricsRegistry().MetricNames())
|
||||
|
||||
if len(missing) > 0 {
|
||||
log.Fatalf("found undocumented metrics in %q: %v", *kbUrl, missing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readKB(path string) (string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading file: %w", err)
|
||||
}
|
||||
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func getKB(ctx context.Context, url string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.GET, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting kb page: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading body: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func undocumentedMetrics(b string, metrics []string) []string {
|
||||
var missing []string
|
||||
for _, metric := range metrics {
|
||||
if !strings.Contains(b, metric) {
|
||||
missing = append(missing, metric)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func main() {
|
||||
it := codegen.NewImportTracker(pkg.Types)
|
||||
buf := new(bytes.Buffer)
|
||||
for _, typeName := range typeNames {
|
||||
typ, ok := namedTypes[typeName].(*types.Named)
|
||||
typ, ok := namedTypes[typeName]
|
||||
if !ok {
|
||||
log.Fatalf("could not find type %s", typeName)
|
||||
}
|
||||
@@ -78,11 +78,7 @@ func main() {
|
||||
w(" return false")
|
||||
w("}")
|
||||
}
|
||||
cloneOutput := pkg.Name + "_clone"
|
||||
if *flagBuildTags == "test" {
|
||||
cloneOutput += "_test"
|
||||
}
|
||||
cloneOutput += ".go"
|
||||
cloneOutput := pkg.Name + "_clone.go"
|
||||
if err := codegen.WritePackageFile("tailscale.com/cmd/cloner", pkg, cloneOutput, it, buf); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -95,27 +91,24 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
}
|
||||
|
||||
name := typ.Obj().Name()
|
||||
typeParams := typ.Origin().TypeParams()
|
||||
_, typeParamNames := codegen.FormatTypeParams(typeParams, it)
|
||||
nameWithParams := name + typeParamNames
|
||||
fmt.Fprintf(buf, "// Clone makes a deep copy of %s.\n", name)
|
||||
fmt.Fprintf(buf, "// The result aliases no memory with the original.\n")
|
||||
fmt.Fprintf(buf, "func (src *%s) Clone() *%s {\n", nameWithParams, nameWithParams)
|
||||
fmt.Fprintf(buf, "func (src *%s) Clone() *%s {\n", name, name)
|
||||
writef := func(format string, args ...any) {
|
||||
fmt.Fprintf(buf, "\t"+format+"\n", args...)
|
||||
}
|
||||
writef("if src == nil {")
|
||||
writef("\treturn nil")
|
||||
writef("}")
|
||||
writef("dst := new(%s)", nameWithParams)
|
||||
writef("dst := new(%s)", name)
|
||||
writef("*dst = *src")
|
||||
for i := range t.NumFields() {
|
||||
for i := 0; i < t.NumFields(); i++ {
|
||||
fname := t.Field(i).Name()
|
||||
ft := t.Field(i).Type()
|
||||
if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) {
|
||||
continue
|
||||
}
|
||||
if named, _ := codegen.NamedTypeOf(ft); named != nil {
|
||||
if named, _ := ft.(*types.Named); named != nil {
|
||||
if codegen.IsViewType(ft) {
|
||||
writef("dst.%s = src.%s", fname, fname)
|
||||
continue
|
||||
@@ -133,23 +126,16 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
|
||||
writef("for i := range dst.%s {", fname)
|
||||
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
|
||||
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname)
|
||||
if codegen.ContainsPointers(ptr.Elem()) {
|
||||
if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("\tdst.%s[i] = ptr.To((*src.%s[i]).Clone())", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
} else {
|
||||
if _, isBasic := ptr.Elem().Underlying().(*types.Basic); isBasic {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname)
|
||||
writef("\tdst.%s[i] = ptr.To(*src.%s[i])", fname, fname)
|
||||
writef("}")
|
||||
} else {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
writef("}")
|
||||
} 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 if _, isIface := ft.Elem().Underlying().(*types.Interface); isIface {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
@@ -159,19 +145,14 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("dst.%s = append(src.%s[:0:0], src.%s...)", fname, fname, fname)
|
||||
}
|
||||
case *types.Pointer:
|
||||
base := ft.Elem()
|
||||
hasPtrs := codegen.ContainsPointers(base)
|
||||
if named, _ := codegen.NamedTypeOf(base); named != nil && hasPtrs {
|
||||
if named, _ := ft.Elem().(*types.Named); named != nil && codegen.ContainsPointers(ft.Elem()) {
|
||||
writef("dst.%s = src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("if dst.%s != nil {", fname)
|
||||
if _, isIface := base.Underlying().(*types.Interface); isIface && hasPtrs {
|
||||
writef("\tdst.%s = ptr.To((*src.%s).Clone())", fname, fname)
|
||||
} else if !hasPtrs {
|
||||
writef("\tdst.%s = ptr.To(*src.%s)", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s = ptr.To(*src.%s)", fname, fname)
|
||||
if codegen.ContainsPointers(ft.Elem()) {
|
||||
writef("\t" + `panic("TODO pointers in pointers")`)
|
||||
}
|
||||
writef("}")
|
||||
@@ -191,50 +172,18 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
|
||||
writef("\tfor k, v := range src.%s {", fname)
|
||||
|
||||
switch elem := elem.Underlying().(type) {
|
||||
switch elem.(type) {
|
||||
case *types.Pointer:
|
||||
writef("\t\tif v == nil { dst.%s[k] = nil } else {", fname)
|
||||
if base := elem.Elem().Underlying(); codegen.ContainsPointers(base) {
|
||||
if _, isIface := base.(*types.Interface); isIface {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("\t\t\tdst.%s[k] = ptr.To((*v).Clone())", fname)
|
||||
} else {
|
||||
writef("\t\t\tdst.%s[k] = v.Clone()", fname)
|
||||
}
|
||||
} else {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("\t\t\tdst.%s[k] = ptr.To(*v)", fname)
|
||||
}
|
||||
writef("}")
|
||||
case *types.Interface:
|
||||
if cloneResultType := methodResultType(elem, "Clone"); cloneResultType != nil {
|
||||
if _, isPtr := cloneResultType.(*types.Pointer); isPtr {
|
||||
writef("\t\tdst.%s[k] = *(v.Clone())", fname)
|
||||
} else {
|
||||
writef("\t\tdst.%s[k] = v.Clone()", fname)
|
||||
}
|
||||
} else {
|
||||
writef(`panic("%s (%v) does not have a Clone method")`, fname, elem)
|
||||
}
|
||||
writef("\t\tdst.%s[k] = v.Clone()", fname)
|
||||
default:
|
||||
writef("\t\tdst.%s[k] = *(v.Clone())", fname)
|
||||
}
|
||||
|
||||
writef("\t}")
|
||||
writef("}")
|
||||
} else {
|
||||
it.Import("maps")
|
||||
writef("\tdst.%s = maps.Clone(src.%s)", fname, fname)
|
||||
}
|
||||
case *types.Interface:
|
||||
// If ft is an interface with a "Clone() ft" method, it can be used to clone the field.
|
||||
// This includes scenarios where ft is a constrained type parameter.
|
||||
if cloneResultType := methodResultType(ft, "Clone"); cloneResultType.Underlying() == ft {
|
||||
writef("dst.%s = src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
writef(`panic("%s (%v) does not have a compatible Clone method")`, fname, ft)
|
||||
default:
|
||||
writef(`panic("TODO: %s (%T)")`, fname, ft)
|
||||
}
|
||||
@@ -242,7 +191,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("return dst")
|
||||
fmt.Fprintf(buf, "}\n\n")
|
||||
|
||||
buf.Write(codegen.AssertStructUnchanged(t, name, typeParams, "Clone", it))
|
||||
buf.Write(codegen.AssertStructUnchanged(t, name, "Clone", it))
|
||||
}
|
||||
|
||||
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
|
||||
@@ -254,15 +203,3 @@ func hasBasicUnderlying(typ types.Type) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func methodResultType(typ types.Type, method string) types.Type {
|
||||
viewMethod := codegen.LookupMethod(typ, method)
|
||||
if viewMethod == nil {
|
||||
return nil
|
||||
}
|
||||
sig, ok := viewMethod.Type().(*types.Signature)
|
||||
if !ok || sig.Results().Len() != 1 {
|
||||
return nil
|
||||
}
|
||||
return sig.Results().At(0).Type()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer
|
||||
|
||||
// Package clonerex is an example package for the cloner tool.
|
||||
package clonerex
|
||||
|
||||
type SliceContainer struct {
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/linuxfw"
|
||||
)
|
||||
|
||||
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
|
||||
func ensureIPForwarding(root, clusterProxyTargetIP, tailnetTargetIP, tailnetTargetFQDN string, routes *string) error {
|
||||
var (
|
||||
v4Forwarding, v6Forwarding bool
|
||||
)
|
||||
if clusterProxyTargetIP != "" {
|
||||
proxyIP, err := netip.ParseAddr(clusterProxyTargetIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cluster destination IP: %v", err)
|
||||
}
|
||||
if proxyIP.Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
if tailnetTargetIP != "" {
|
||||
proxyIP, err := netip.ParseAddr(tailnetTargetIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid tailnet destination IP: %v", err)
|
||||
}
|
||||
if proxyIP.Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
// Currently we only proxy traffic to the IPv4 address of the tailnet
|
||||
// target.
|
||||
if tailnetTargetFQDN != "" {
|
||||
v4Forwarding = true
|
||||
}
|
||||
if routes != nil && *routes != "" {
|
||||
for _, route := range strings.Split(*routes, ",") {
|
||||
cidr, err := netip.ParsePrefix(route)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid subnet route: %v", err)
|
||||
}
|
||||
if cidr.Addr().Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return enableIPForwarding(v4Forwarding, v6Forwarding, root)
|
||||
}
|
||||
|
||||
func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error {
|
||||
var paths []string
|
||||
if v4Forwarding {
|
||||
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv4/ip_forward"))
|
||||
}
|
||||
if v6Forwarding {
|
||||
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv6/conf/all/forwarding"))
|
||||
}
|
||||
|
||||
// In some common configurations (e.g. default docker,
|
||||
// kubernetes), the container environment denies write access to
|
||||
// most sysctls, including IP forwarding controls. Check the
|
||||
// sysctl values before trying to change them, so that we
|
||||
// gracefully do nothing if the container's already been set up
|
||||
// properly by e.g. a k8s initContainer.
|
||||
for _, path := range paths {
|
||||
bs, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %q: %w", path, err)
|
||||
}
|
||||
if v := strings.TrimSpace(string(bs)); v != "1" {
|
||||
if err := os.WriteFile(path, []byte("1"), 0644); err != nil {
|
||||
return fmt.Errorf("enabling %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installEgressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var local netip.Addr
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
|
||||
}
|
||||
if err := nfr.DNATNonTailscaleTraffic("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
if err := nfr.EnsureSNATForDst(local, dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing egress proxy rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installTSForwardingRuleForDestination accepts a destination address and a
|
||||
// list of node's tailnet addresses, sets up rules to forward traffic for
|
||||
// destination to the tailnet IP matching the destination IP family.
|
||||
// Destination can be Pod IP of this node.
|
||||
func installTSForwardingRuleForDestination(_ context.Context, dstFilter string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var local netip.Addr
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstFilter, tsIPs)
|
||||
}
|
||||
if err := nfr.AddDNATRule(dst, local); err != nil {
|
||||
return fmt.Errorf("installing rule for forwarding traffic to tailnet IP: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installIngressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var local netip.Addr
|
||||
proxyHasIPv4Address := false
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() {
|
||||
proxyHasIPv4Address = true
|
||||
}
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if proxyHasIPv4Address && dst.Is6() {
|
||||
log.Printf("Warning: proxy backend ClusterIP is an IPv6 address and the proxy has a IPv4 tailnet address. You might need to disable IPv4 address allocation for the proxy for forwarding to work. See https://github.com/tailscale/tailscale/issues/12156")
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
|
||||
}
|
||||
if err := nfr.AddDNATRule(local, dst); err != nil {
|
||||
return fmt.Errorf("installing ingress proxy rules: %w", err)
|
||||
}
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("installing ingress proxy rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installIngressForwardingRuleForDNSTarget(_ context.Context, backendAddrs []net.IP, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
var (
|
||||
tsv4 netip.Addr
|
||||
tsv6 netip.Addr
|
||||
v4Backends []netip.Addr
|
||||
v6Backends []netip.Addr
|
||||
)
|
||||
for _, pfx := range tsIPs {
|
||||
if pfx.IsSingleIP() && pfx.Addr().Is4() {
|
||||
tsv4 = pfx.Addr()
|
||||
continue
|
||||
}
|
||||
if pfx.IsSingleIP() && pfx.Addr().Is6() {
|
||||
tsv6 = pfx.Addr()
|
||||
continue
|
||||
}
|
||||
}
|
||||
// TODO: log if more than one backend address is found and firewall is
|
||||
// in nftables mode that only the first IP will be used.
|
||||
for _, ip := range backendAddrs {
|
||||
if ip.To4() != nil {
|
||||
v4Backends = append(v4Backends, netip.AddrFrom4([4]byte(ip.To4())))
|
||||
}
|
||||
if ip.To16() != nil {
|
||||
v6Backends = append(v6Backends, netip.AddrFrom16([16]byte(ip.To16())))
|
||||
}
|
||||
}
|
||||
|
||||
// Enable IP forwarding here as opposed to at the start of containerboot
|
||||
// as the IPv4/IPv6 requirements might have changed.
|
||||
// For Kubernetes operator proxies, forwarding for both IPv4 and IPv6 is
|
||||
// enabled by an init container, so in practice enabling forwarding here
|
||||
// is only needed if this proxy has been configured by manually setting
|
||||
// TS_EXPERIMENTAL_DEST_DNS_NAME env var for a containerboot instance.
|
||||
if err := enableIPForwarding(len(v4Backends) != 0, len(v6Backends) != 0, ""); err != nil {
|
||||
log.Printf("[unexpected] failed to ensure IP forwarding: %v", err)
|
||||
}
|
||||
|
||||
updateFirewall := func(dst netip.Addr, backendTargets []netip.Addr) error {
|
||||
if err := nfr.DNATWithLoadBalancer(dst, backendTargets); err != nil {
|
||||
return fmt.Errorf("installing DNAT rules for ingress backends %+#v: %w", backendTargets, err)
|
||||
}
|
||||
// The backend might advertize MSS higher than that of the
|
||||
// tailscale interfaces. Clamp MSS of packets going out via
|
||||
// tailscale0 interface to its MTU to prevent broken connections
|
||||
// in environments where path MTU discovery is not working.
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("adding rule to clamp traffic via tailscale0: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(v4Backends) != 0 {
|
||||
if !tsv4.IsValid() {
|
||||
log.Printf("backend targets %v contain at least one IPv4 address, but this node's Tailscale IPs do not contain a valid IPv4 address: %v", backendAddrs, tsIPs)
|
||||
} else if err := updateFirewall(tsv4, v4Backends); err != nil {
|
||||
return fmt.Errorf("Installing IPv4 firewall rules: %w", err)
|
||||
}
|
||||
}
|
||||
if len(v6Backends) != 0 && !tsv6.IsValid() {
|
||||
if !tsv6.IsValid() {
|
||||
log.Printf("backend targets %v contain at least one IPv6 address, but this node's Tailscale IPs do not contain a valid IPv6 address: %v", backendAddrs, tsIPs)
|
||||
} else if !nfr.HasIPV6NAT() {
|
||||
log.Printf("backend targets %v contain at least one IPv6 address, but the chosen firewall mode does not support IPv6 NAT", backendAddrs)
|
||||
} else if err := updateFirewall(tsv6, v6Backends); err != nil {
|
||||
return fmt.Errorf("Installing IPv6 firewall rules: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"tailscale.com/kube/kubetypes"
|
||||
)
|
||||
|
||||
// healthz is a simple health check server, if enabled it returns 200 OK if
|
||||
// this tailscale node currently has at least one tailnet IP address else
|
||||
// returns 503.
|
||||
type healthz struct {
|
||||
sync.Mutex
|
||||
hasAddrs bool
|
||||
podIPv4 string
|
||||
}
|
||||
|
||||
func (h *healthz) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.Lock()
|
||||
defer h.Unlock()
|
||||
|
||||
if h.hasAddrs {
|
||||
w.Header().Add(kubetypes.PodIPv4Header, h.podIPv4)
|
||||
if _, err := w.Write([]byte("ok")); err != nil {
|
||||
http.Error(w, fmt.Sprintf("error writing status: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "node currently has no tailscale IPs", http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *healthz) update(healthy bool) {
|
||||
h.Lock()
|
||||
defer h.Unlock()
|
||||
|
||||
if h.hasAddrs != healthy {
|
||||
log.Println("Setting healthy", healthy)
|
||||
}
|
||||
h.hasAddrs = healthy
|
||||
}
|
||||
|
||||
// healthHandlers registers a simple health handler at /healthz.
|
||||
// A containerized tailscale instance is considered healthy if
|
||||
// it has at least one tailnet IP address.
|
||||
func healthHandlers(mux *http.ServeMux, podIPv4 string) *healthz {
|
||||
h := &healthz{podIPv4: podIPv4}
|
||||
mux.Handle("GET /healthz", h)
|
||||
return h
|
||||
}
|
||||
@@ -8,64 +8,46 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/kubeapi"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/logtail/backoff"
|
||||
"tailscale.com/kube"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// kubeClient is a wrapper around Tailscale's internal kube client that knows how to talk to the kube API server. We use
|
||||
// this rather than any of the upstream Kubernetes client libaries to avoid extra imports.
|
||||
type kubeClient struct {
|
||||
kubeclient.Client
|
||||
stateSecret string
|
||||
canPatch bool // whether the client has permissions to patch Kubernetes Secrets
|
||||
}
|
||||
|
||||
func newKubeClient(root string, stateSecret string) (*kubeClient, error) {
|
||||
if root != "/" {
|
||||
// If we are running in a test, we need to set the root path to the fake
|
||||
// service account directory.
|
||||
kubeclient.SetRootPathForTesting(root)
|
||||
}
|
||||
var err error
|
||||
kc, err := kubeclient.New("tailscale-container")
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error creating kube client: %w", err)
|
||||
return "", err
|
||||
}
|
||||
if (root != "/") || os.Getenv("TS_KUBERNETES_READ_API_SERVER_ADDRESS_FROM_ENV") == "true" {
|
||||
// Derive the API server address from the environment variables
|
||||
// Used to set http server in tests, or optionally enabled by flag
|
||||
kc.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
|
||||
ak, ok := s.Data["authkey"]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return &kubeClient{Client: kc, stateSecret: stateSecret}, nil
|
||||
return string(ak), nil
|
||||
}
|
||||
|
||||
// storeDeviceID writes deviceID to 'device_id' data field of the client's state Secret.
|
||||
func (kc *kubeClient) storeDeviceID(ctx context.Context, deviceID tailcfg.StableNodeID) error {
|
||||
s := &kubeapi.Secret{
|
||||
Data: map[string][]byte{
|
||||
kubetypes.KeyDeviceID: []byte(deviceID),
|
||||
},
|
||||
// storeDeviceInfo writes deviceID into the "device_id" data field of the kube
|
||||
// secret secretName.
|
||||
func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID, fqdn string, addresses []netip.Prefix) 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
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
|
||||
}
|
||||
|
||||
// storeDeviceEndpoints writes device's tailnet IPs and MagicDNS name to fields 'device_ips', 'device_fqdn' of client's
|
||||
// state Secret.
|
||||
func (kc *kubeClient) storeDeviceEndpoints(ctx context.Context, fqdn string, addresses []netip.Prefix) error {
|
||||
var ips []string
|
||||
for _, addr := range addresses {
|
||||
ips = append(ips, addr.Addr().String())
|
||||
@@ -75,39 +57,28 @@ func (kc *kubeClient) storeDeviceEndpoints(ctx context.Context, fqdn string, add
|
||||
return err
|
||||
}
|
||||
|
||||
s := &kubeapi.Secret{
|
||||
m := &kube.Secret{
|
||||
Data: map[string][]byte{
|
||||
kubetypes.KeyDeviceFQDN: []byte(fqdn),
|
||||
kubetypes.KeyDeviceIPs: deviceIPs,
|
||||
"device_id": []byte(deviceID),
|
||||
"device_fqdn": []byte(fqdn),
|
||||
"device_ips": deviceIPs,
|
||||
},
|
||||
}
|
||||
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
|
||||
}
|
||||
|
||||
// storeHTTPSEndpoint writes an HTTPS endpoint exposed by this device via 'tailscale serve' to the client's state
|
||||
// Secret. In practice this will be the same value that gets written to 'device_fqdn', but this should only be called
|
||||
// when the serve config has been successfully set up.
|
||||
func (kc *kubeClient) storeHTTPSEndpoint(ctx context.Context, ep string) error {
|
||||
s := &kubeapi.Secret{
|
||||
Data: map[string][]byte{
|
||||
kubetypes.KeyHTTPSEndpoint: []byte(ep),
|
||||
},
|
||||
}
|
||||
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
|
||||
return kc.StrategicMergePatchSecret(ctx, secretName, m, "tailscale-container")
|
||||
}
|
||||
|
||||
// deleteAuthKey deletes the 'authkey' field of the given kube
|
||||
// secret. No-op if there is no authkey in the secret.
|
||||
func (kc *kubeClient) deleteAuthKey(ctx context.Context) error {
|
||||
func deleteAuthKey(ctx context.Context, secretName string) error {
|
||||
// m is a JSON Patch data structure, see https://jsonpatch.com/ or RFC 6902.
|
||||
m := []kubeclient.JSONPatch{
|
||||
m := []kube.JSONPatch{
|
||||
{
|
||||
Op: "remove",
|
||||
Path: "/data/authkey",
|
||||
},
|
||||
}
|
||||
if err := kc.JSONPatchResource(ctx, kc.stateSecret, kubeclient.TypeSecrets, m); err != nil {
|
||||
if s, ok := err.(*kubeapi.Status); ok && s.Code == http.StatusUnprocessableEntity {
|
||||
if err := kc.JSONPatchSecret(ctx, secretName, m); err != nil {
|
||||
if s, ok := err.(*kube.Status); ok && s.Code == http.StatusUnprocessableEntity {
|
||||
// This is kubernetes-ese for "the field you asked to
|
||||
// delete already doesn't exist", aka no-op.
|
||||
return nil
|
||||
@@ -117,78 +88,22 @@ func (kc *kubeClient) deleteAuthKey(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeCapVerUID stores the current capability version of tailscale and, if provided, UID of the Pod in the tailscale
|
||||
// state Secret.
|
||||
// These two fields are used by the Kubernetes Operator to observe the current capability version of tailscaled running in this container.
|
||||
func (kc *kubeClient) storeCapVerUID(ctx context.Context, podUID string) error {
|
||||
capVerS := fmt.Sprintf("%d", tailcfg.CurrentCapabilityVersion)
|
||||
d := map[string][]byte{
|
||||
kubetypes.KeyCapVer: []byte(capVerS),
|
||||
var kc *kube.Client
|
||||
|
||||
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 podUID != "" {
|
||||
d[kubetypes.KeyPodUID] = []byte(podUID)
|
||||
var err error
|
||||
kc, err = kube.New()
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating kube client: %v", err)
|
||||
}
|
||||
s := &kubeapi.Secret{
|
||||
Data: d,
|
||||
}
|
||||
return kc.StrategicMergePatchSecret(ctx, kc.stateSecret, s, "tailscale-container")
|
||||
}
|
||||
|
||||
// waitForConsistentState waits for tailscaled to finish writing state if it
|
||||
// looks like it's started. It is designed to reduce the likelihood that
|
||||
// tailscaled gets shut down in the window between authenticating to control
|
||||
// and finishing writing state. However, it's not bullet proof because we can't
|
||||
// atomically authenticate and write state.
|
||||
func (kc *kubeClient) waitForConsistentState(ctx context.Context) error {
|
||||
var logged bool
|
||||
|
||||
bo := backoff.NewBackoff("", logger.Discard, 2*time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
secret, err := kc.GetSecret(ctx, kc.stateSecret)
|
||||
if ctx.Err() != nil || kubeclient.IsNotFoundErr(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting Secret %q: %v", kc.stateSecret, err)
|
||||
}
|
||||
|
||||
if hasConsistentState(secret.Data) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !logged {
|
||||
log.Printf("Waiting for tailscaled to finish writing state to Secret %q", kc.stateSecret)
|
||||
logged = true
|
||||
}
|
||||
bo.BackOff(ctx, errors.New("")) // Fake error to trigger actual sleep.
|
||||
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")))
|
||||
}
|
||||
}
|
||||
|
||||
// hasConsistentState returns true is there is either no state or the full set
|
||||
// of expected keys are present.
|
||||
func hasConsistentState(d map[string][]byte) bool {
|
||||
var (
|
||||
_, hasCurrent = d[string(ipn.CurrentProfileStateKey)]
|
||||
_, hasKnown = d[string(ipn.KnownProfilesStateKey)]
|
||||
_, hasMachine = d[string(ipn.MachineKeyStateKey)]
|
||||
hasProfile bool
|
||||
)
|
||||
|
||||
for k := range d {
|
||||
if strings.HasPrefix(k, "profile-") {
|
||||
if hasProfile {
|
||||
return false // We only expect one profile.
|
||||
}
|
||||
hasProfile = true
|
||||
}
|
||||
}
|
||||
|
||||
// Approximate check, we don't want to reimplement all of profileManager.
|
||||
return (hasCurrent && hasKnown && hasMachine && hasProfile) ||
|
||||
(!hasCurrent && !hasKnown && !hasMachine && !hasProfile)
|
||||
}
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/kubeapi"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
)
|
||||
|
||||
func TestSetupKube(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *settings
|
||||
wantErr bool
|
||||
wantCfg *settings
|
||||
kc *kubeClient
|
||||
}{
|
||||
{
|
||||
name: "TS_AUTHKEY set, state Secret exists",
|
||||
cfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}},
|
||||
wantCfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY set, state Secret does not exist, we have permissions to create it",
|
||||
cfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, true, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return nil, &kubeapi.Status{Code: 404}
|
||||
},
|
||||
}},
|
||||
wantCfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY set, state Secret does not exist, we do not have permissions to create it",
|
||||
cfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return nil, &kubeapi.Status{Code: 404}
|
||||
},
|
||||
}},
|
||||
wantCfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY set, we encounter a non-404 error when trying to retrieve the state Secret",
|
||||
cfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return nil, &kubeapi.Status{Code: 403}
|
||||
},
|
||||
}},
|
||||
wantCfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY set, we encounter a non-404 error when trying to check Secret permissions",
|
||||
cfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantCfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, errors.New("broken")
|
||||
},
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// Interactive login using URL in Pod logs
|
||||
name: "TS_AUTHKEY not set, state Secret does not exist, we have permissions to create it",
|
||||
cfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantCfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, true, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return nil, &kubeapi.Status{Code: 404}
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
// Interactive login using URL in Pod logs
|
||||
name: "TS_AUTHKEY not set, state Secret exists, but does not contain auth key",
|
||||
cfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantCfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return &kubeapi.Secret{}, nil
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY not set, state Secret contains auth key, we do not have RBAC to patch it",
|
||||
cfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return &kubeapi.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
|
||||
},
|
||||
}},
|
||||
wantCfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY not set, state Secret contains auth key, we have RBAC to patch it",
|
||||
cfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kubeClient{stateSecret: "foo", Client: &kubeclient.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return true, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return &kubeapi.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
|
||||
},
|
||||
}},
|
||||
wantCfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
AuthKey: "foo",
|
||||
KubernetesCanPatch: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
kc := tt.kc
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.cfg.setupKube(context.Background(), kc); (err != nil) != tt.wantErr {
|
||||
t.Errorf("settings.setupKube() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if diff := cmp.Diff(*tt.cfg, *tt.wantCfg); diff != "" {
|
||||
t.Errorf("unexpected contents of settings after running settings.setupKube()\n(-got +want):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForConsistentState(t *testing.T) {
|
||||
data := map[string][]byte{
|
||||
// Missing _current-profile.
|
||||
string(ipn.KnownProfilesStateKey): []byte(""),
|
||||
string(ipn.MachineKeyStateKey): []byte(""),
|
||||
"profile-foo": []byte(""),
|
||||
}
|
||||
kc := &kubeClient{
|
||||
Client: &kubeclient.FakeClient{
|
||||
GetSecretImpl: func(context.Context, string) (*kubeapi.Secret, error) {
|
||||
return &kubeapi.Secret{
|
||||
Data: data,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := kc.waitForConsistentState(ctx); err != context.DeadlineExceeded {
|
||||
t.Fatalf("expected DeadlineExceeded, got %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
data[string(ipn.CurrentProfileStateKey)] = []byte("")
|
||||
if err := kc.waitForConsistentState(ctx); err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,16 +25,12 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/netmap"
|
||||
@@ -51,13 +47,16 @@ func TestContainerBoot(t *testing.T) {
|
||||
defer lapi.Close()
|
||||
|
||||
kube := kubeServer{FSRoot: d}
|
||||
kube.Start(t)
|
||||
if err := kube.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer kube.Close()
|
||||
|
||||
tailscaledConf := &ipn.ConfigVAlpha{AuthKey: ptr.To("foo"), Version: "alpha0"}
|
||||
serveConf := ipn.ServeConfig{TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}}
|
||||
egressCfg := egressSvcConfig("foo", "foo.tailnetxyz.ts.net")
|
||||
egressStatus := egressSvcStatus("foo", "foo.tailnetxyz.ts.net")
|
||||
tailscaledConf := &ipn.ConfigVAlpha{AuthKey: func(s string) *string { return &s }("foo"), Version: "alpha0"}
|
||||
tailscaledConfBytes, err := json.Marshal(tailscaledConf)
|
||||
if err != nil {
|
||||
t.Fatalf("error unmarshaling tailscaled config: %v", err)
|
||||
}
|
||||
|
||||
dirs := []string{
|
||||
"var/lib",
|
||||
@@ -66,7 +65,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
"dev/net",
|
||||
"proc/sys/net/ipv4",
|
||||
"proc/sys/net/ipv6/conf/all",
|
||||
"etc/tailscaled",
|
||||
"etc",
|
||||
}
|
||||
for _, path := range dirs {
|
||||
if err := os.MkdirAll(filepath.Join(d, path), 0700); err != nil {
|
||||
@@ -81,10 +80,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
"dev/net/tun": []byte(""),
|
||||
"proc/sys/net/ipv4/ip_forward": []byte("0"),
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": []byte("0"),
|
||||
"etc/tailscaled/cap-95.hujson": mustJSON(t, tailscaledConf),
|
||||
"etc/tailscaled/serve-config.json": mustJSON(t, serveConf),
|
||||
filepath.Join("etc/tailscaled/", egressservices.KeyEgressServices): mustJSON(t, egressCfg),
|
||||
filepath.Join("etc/tailscaled/", egressservices.KeyHEPPings): []byte("4"),
|
||||
"etc/tailscaled": tailscaledConfBytes,
|
||||
}
|
||||
resetFiles := func() {
|
||||
for path, content := range files {
|
||||
@@ -105,29 +101,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
|
||||
argFile := filepath.Join(d, "args")
|
||||
runningSockPath := filepath.Join(d, "tmp/tailscaled.sock")
|
||||
var localAddrPort, healthAddrPort int
|
||||
for _, p := range []*int{&localAddrPort, &healthAddrPort} {
|
||||
ln, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open listener: %v", err)
|
||||
}
|
||||
if err := ln.Close(); err != nil {
|
||||
t.Fatalf("Failed to close listener: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
*p = port
|
||||
}
|
||||
metricsURL := func(port int) string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
|
||||
}
|
||||
healthURL := func(port int) string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d/healthz", port)
|
||||
}
|
||||
egressSvcTerminateURL := func(port int) string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d%s", port, kubetypes.EgessServicesPreshutdownEP)
|
||||
}
|
||||
|
||||
capver := fmt.Sprintf("%d", tailcfg.CurrentCapabilityVersion)
|
||||
|
||||
type phase struct {
|
||||
// If non-nil, send this IPN bus notification (and remember it as the
|
||||
@@ -137,31 +110,12 @@ func TestContainerBoot(t *testing.T) {
|
||||
|
||||
// WantCmds is the commands that containerboot should run in this phase.
|
||||
WantCmds []string
|
||||
|
||||
// WantKubeSecret is the secret keys/values that should exist in the
|
||||
// kube secret.
|
||||
WantKubeSecret map[string]string
|
||||
|
||||
// Update the kube secret with these keys/values at the beginning of the
|
||||
// phase (simulates our fake tailscaled doing it).
|
||||
UpdateKubeSecret map[string]string
|
||||
|
||||
// WantFiles files that should exist in the container and their
|
||||
// contents.
|
||||
WantFiles map[string]string
|
||||
|
||||
// WantLog is a log message we expect from containerboot.
|
||||
WantLog string
|
||||
|
||||
// If set for a phase, the test will expect containerboot to exit with
|
||||
// this error code, and the test will finish on that phase without
|
||||
// waiting for the successful startup log message.
|
||||
WantExitCode *int
|
||||
|
||||
// The signal to send to containerboot at the start of the phase.
|
||||
Signal *syscall.Signal
|
||||
|
||||
EndpointStatuses map[string]int
|
||||
}
|
||||
runningNotify := &ipn.Notify{
|
||||
State: ptr.To(ipn.Running),
|
||||
@@ -190,11 +144,6 @@ func TestContainerBoot(t *testing.T) {
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
|
||||
},
|
||||
// No metrics or health by default.
|
||||
EndpointStatuses: map[string]int{
|
||||
metricsURL(9002): -1,
|
||||
healthURL(9002): -1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
@@ -400,58 +349,12 @@ func TestContainerBoot(t *testing.T) {
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantFiles: map[string]string{
|
||||
"proc/sys/net/ipv4/ip_forward": "1",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "egress_proxy_fqdn_ipv6_target_on_ipv4_host",
|
||||
Env: map[string]string{
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_TAILNET_TARGET_FQDN": "ipv6-node.test.ts.net", // resolves to IPv6 address
|
||||
"TS_USERSPACE": "false",
|
||||
"TS_TEST_FAKE_NETFILTER_6": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantFiles: map[string]string{
|
||||
"proc/sys/net/ipv4/ip_forward": "1",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: ptr.To(ipn.Running),
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
}).View(),
|
||||
Peers: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("ipv6ID"),
|
||||
Name: "ipv6-node.test.ts.net",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
|
||||
}).View(),
|
||||
},
|
||||
},
|
||||
},
|
||||
WantLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
|
||||
WantExitCode: ptr.To(1),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authkey_once",
|
||||
Env: map[string]string{
|
||||
@@ -502,11 +405,10 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
"device_id": "myID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
"tailscale_capver": capver,
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
"device_id": "myID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -596,10 +498,9 @@ func TestContainerBoot(t *testing.T) {
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
"device_id": "myID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
"tailscale_capver": capver,
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
"device_id": "myID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -626,11 +527,10 @@ func TestContainerBoot(t *testing.T) {
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
"device_id": "myID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
"tailscale_capver": capver,
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
"device_id": "myID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -645,11 +545,10 @@ func TestContainerBoot(t *testing.T) {
|
||||
},
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "new-name.test.ts.net",
|
||||
"device_id": "newID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
"tailscale_capver": capver,
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "new-name.test.ts.net",
|
||||
"device_id": "newID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -739,278 +638,20 @@ func TestContainerBoot(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "experimental tailscaled config path",
|
||||
Name: "experimental tailscaled configfile",
|
||||
Env: map[string]string{
|
||||
"TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR": filepath.Join(d, "etc/tailscaled/"),
|
||||
"EXPERIMENTAL_TS_CONFIGFILE_PATH": filepath.Join(d, "etc/tailscaled"),
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --config=/etc/tailscaled/cap-95.hujson",
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --config=/etc/tailscaled",
|
||||
},
|
||||
}, {
|
||||
Notify: runningNotify,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "metrics_enabled",
|
||||
Env: map[string]string{
|
||||
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
|
||||
"TS_ENABLE_METRICS": "true",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
|
||||
},
|
||||
EndpointStatuses: map[string]int{
|
||||
metricsURL(localAddrPort): 200,
|
||||
healthURL(localAddrPort): -1,
|
||||
},
|
||||
}, {
|
||||
Notify: runningNotify,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "health_enabled",
|
||||
Env: map[string]string{
|
||||
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
|
||||
"TS_ENABLE_HEALTH_CHECK": "true",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
|
||||
},
|
||||
EndpointStatuses: map[string]int{
|
||||
metricsURL(localAddrPort): -1,
|
||||
healthURL(localAddrPort): 503, // Doesn't start passing until the next phase.
|
||||
},
|
||||
}, {
|
||||
Notify: runningNotify,
|
||||
EndpointStatuses: map[string]int{
|
||||
metricsURL(localAddrPort): -1,
|
||||
healthURL(localAddrPort): 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "metrics_and_health_on_same_port",
|
||||
Env: map[string]string{
|
||||
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
|
||||
"TS_ENABLE_METRICS": "true",
|
||||
"TS_ENABLE_HEALTH_CHECK": "true",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
|
||||
},
|
||||
EndpointStatuses: map[string]int{
|
||||
metricsURL(localAddrPort): 200,
|
||||
healthURL(localAddrPort): 503, // Doesn't start passing until the next phase.
|
||||
},
|
||||
}, {
|
||||
Notify: runningNotify,
|
||||
EndpointStatuses: map[string]int{
|
||||
metricsURL(localAddrPort): 200,
|
||||
healthURL(localAddrPort): 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "local_metrics_and_deprecated_health",
|
||||
Env: map[string]string{
|
||||
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
|
||||
"TS_ENABLE_METRICS": "true",
|
||||
"TS_HEALTHCHECK_ADDR_PORT": fmt.Sprintf("[::]:%d", healthAddrPort),
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
|
||||
},
|
||||
EndpointStatuses: map[string]int{
|
||||
metricsURL(localAddrPort): 200,
|
||||
healthURL(healthAddrPort): 503, // Doesn't start passing until the next phase.
|
||||
},
|
||||
}, {
|
||||
Notify: runningNotify,
|
||||
EndpointStatuses: map[string]int{
|
||||
metricsURL(localAddrPort): 200,
|
||||
healthURL(healthAddrPort): 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "serve_config_no_kube",
|
||||
Env: map[string]string{
|
||||
"TS_SERVE_CONFIG": filepath.Join(d, "etc/tailscaled/serve-config.json"),
|
||||
"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,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "serve_config_kube",
|
||||
Env: map[string]string{
|
||||
"KUBERNETES_SERVICE_HOST": kube.Host,
|
||||
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
|
||||
"TS_SERVE_CONFIG": filepath.Join(d, "etc/tailscaled/serve-config.json"),
|
||||
},
|
||||
KubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
"device_id": "myID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
"https_endpoint": "no-https",
|
||||
"tailscale_capver": capver,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "egress_svcs_config_kube",
|
||||
Env: map[string]string{
|
||||
"KUBERNETES_SERVICE_HOST": kube.Host,
|
||||
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
|
||||
"TS_EGRESS_PROXIES_CONFIG_PATH": filepath.Join(d, "etc/tailscaled"),
|
||||
"TS_LOCAL_ADDR_PORT": fmt.Sprintf("[::]:%d", localAddrPort),
|
||||
},
|
||||
KubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
EndpointStatuses: map[string]int{
|
||||
egressSvcTerminateURL(localAddrPort): 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantKubeSecret: map[string]string{
|
||||
"egress-services": mustBase64(t, egressStatus),
|
||||
"authkey": "tskey-key",
|
||||
"device_fqdn": "test-node.test.ts.net",
|
||||
"device_id": "myID",
|
||||
"device_ips": `["100.64.0.1"]`,
|
||||
"tailscale_capver": capver,
|
||||
},
|
||||
EndpointStatuses: map[string]int{
|
||||
egressSvcTerminateURL(localAddrPort): 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "egress_svcs_config_no_kube",
|
||||
Env: map[string]string{
|
||||
"TS_EGRESS_PROXIES_CONFIG_PATH": filepath.Join(d, "etc/tailscaled"),
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantLog: "TS_EGRESS_PROXIES_CONFIG_PATH is only supported for Tailscale running on Kubernetes",
|
||||
WantExitCode: ptr.To(1),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "kube_shutdown_during_state_write",
|
||||
Env: map[string]string{
|
||||
"KUBERNETES_SERVICE_HOST": kube.Host,
|
||||
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
|
||||
"TS_ENABLE_HEALTH_CHECK": "true",
|
||||
},
|
||||
KubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
// Normal startup.
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
// SIGTERM before state is finished writing, should wait for
|
||||
// consistent state before propagating SIGTERM to tailscaled.
|
||||
Signal: ptr.To(unix.SIGTERM),
|
||||
UpdateKubeSecret: map[string]string{
|
||||
"_machinekey": "foo",
|
||||
"_profiles": "foo",
|
||||
"profile-baff": "foo",
|
||||
// Missing "_current-profile" key.
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"_machinekey": "foo",
|
||||
"_profiles": "foo",
|
||||
"profile-baff": "foo",
|
||||
},
|
||||
WantLog: "Waiting for tailscaled to finish writing state to Secret \"tailscale\"",
|
||||
},
|
||||
{
|
||||
// tailscaled has finished writing state, should propagate SIGTERM.
|
||||
UpdateKubeSecret: map[string]string{
|
||||
"_current-profile": "foo",
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"_machinekey": "foo",
|
||||
"_profiles": "foo",
|
||||
"profile-baff": "foo",
|
||||
"_current-profile": "foo",
|
||||
},
|
||||
WantLog: "HTTP server at [::]:9002 closed",
|
||||
WantExitCode: ptr.To(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -1055,36 +696,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
|
||||
var wantCmds []string
|
||||
for i, p := range test.Phases {
|
||||
for k, v := range p.UpdateKubeSecret {
|
||||
kube.SetSecret(k, v)
|
||||
}
|
||||
lapi.Notify(p.Notify)
|
||||
if p.Signal != nil {
|
||||
cmd.Process.Signal(*p.Signal)
|
||||
}
|
||||
if p.WantLog != "" {
|
||||
err := tstest.WaitFor(2*time.Second, func() error {
|
||||
waitLogLine(t, time.Second, cbOut, p.WantLog)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if p.WantExitCode != nil {
|
||||
state, err := cmd.Process.Wait()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.ExitCode() != *p.WantExitCode {
|
||||
t.Fatalf("phase %d: want exit code %d, got %d", i, *p.WantExitCode, state.ExitCode())
|
||||
}
|
||||
|
||||
// Early test return, we don't expect the successful startup log message.
|
||||
return
|
||||
}
|
||||
|
||||
wantCmds = append(wantCmds, p.WantCmds...)
|
||||
waitArgs(t, 2*time.Second, d, argFile, strings.Join(wantCmds, "\n"))
|
||||
err := tstest.WaitFor(2*time.Second, func() error {
|
||||
@@ -1117,32 +729,10 @@ func TestContainerBoot(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("phase %d: %v", i, err)
|
||||
}
|
||||
|
||||
for url, want := range p.EndpointStatuses {
|
||||
err := tstest.WaitFor(2*time.Second, func() error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil && want != -1 {
|
||||
return fmt.Errorf("GET %s: %v", url, err)
|
||||
}
|
||||
if want > 0 && resp.StatusCode != want {
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("GET %s, want %d, got %d\n%s", url, want, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("phase %d: %v", i, err)
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
waitLogLine(t, 2*time.Second, cbOut, "Startup complete, waiting for shutdown signal")
|
||||
if cmd.ProcessState != nil {
|
||||
t.Fatalf("containerboot should be running but exited with exit code %d", cmd.ProcessState.ExitCode())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1298,12 +888,6 @@ func (l *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
panic(fmt.Sprintf("unsupported method %q", r.Method))
|
||||
}
|
||||
case "/localapi/v0/usermetrics":
|
||||
if r.Method != "GET" {
|
||||
panic(fmt.Sprintf("unsupported method %q", r.Method))
|
||||
}
|
||||
w.Write([]byte("fake metrics"))
|
||||
return
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported path %q", r.URL.Path))
|
||||
}
|
||||
@@ -1374,18 +958,18 @@ func (k *kubeServer) Reset() {
|
||||
k.secret = map[string]string{}
|
||||
}
|
||||
|
||||
func (k *kubeServer) Start(t *testing.T) {
|
||||
func (k *kubeServer) Start() error {
|
||||
root := filepath.Join(k.FSRoot, "var/run/secrets/kubernetes.io/serviceaccount")
|
||||
|
||||
if err := os.MkdirAll(root, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "namespace"), []byte("default"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "token"), []byte("bearer_token"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
k.srv = httptest.NewTLSServer(k)
|
||||
@@ -1394,11 +978,13 @@ func (k *kubeServer) Start(t *testing.T) {
|
||||
|
||||
var cert bytes.Buffer
|
||||
if err := pem.Encode(&cert, &pem.Block{Type: "CERTIFICATE", Bytes: k.srv.Certificate().Raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "ca.crt"), cert.Bytes(), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *kubeServer) Close() {
|
||||
@@ -1447,7 +1033,6 @@ func (k *kubeServer) serveSecret(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, fmt.Sprintf("reading request body: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
@@ -1480,32 +1065,13 @@ func (k *kubeServer) serveSecret(w http.ResponseWriter, r *http.Request) {
|
||||
panic(fmt.Sprintf("json decode failed: %v. Body:\n\n%s", err, string(bs)))
|
||||
}
|
||||
for _, op := range req {
|
||||
switch op.Op {
|
||||
case "remove":
|
||||
if !strings.HasPrefix(op.Path, "/data/") {
|
||||
panic(fmt.Sprintf("unsupported json-patch path %q", op.Path))
|
||||
}
|
||||
delete(k.secret, strings.TrimPrefix(op.Path, "/data/"))
|
||||
case "replace":
|
||||
path, ok := strings.CutPrefix(op.Path, "/data/")
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("unsupported json-patch path %q", op.Path))
|
||||
}
|
||||
req := make([]kubeclient.JSONPatch, 0)
|
||||
if err := json.Unmarshal(bs, &req); err != nil {
|
||||
panic(fmt.Sprintf("json decode failed: %v. Body:\n\n%s", err, string(bs)))
|
||||
}
|
||||
|
||||
for _, patch := range req {
|
||||
val, ok := patch.Value.(string)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("unsupported json patch value %v: cannot be converted to string", patch.Value))
|
||||
}
|
||||
k.secret[path] = val
|
||||
}
|
||||
default:
|
||||
if op.Op != "remove" {
|
||||
panic(fmt.Sprintf("unsupported json-patch op %q", op.Op))
|
||||
}
|
||||
if !strings.HasPrefix(op.Path, "/data/") {
|
||||
panic(fmt.Sprintf("unsupported json-patch path %q", op.Path))
|
||||
}
|
||||
delete(k.secret, strings.TrimPrefix(op.Path, "/data/"))
|
||||
}
|
||||
case "application/strategic-merge-patch+json":
|
||||
req := struct {
|
||||
@@ -1521,44 +1087,6 @@ func (k *kubeServer) serveSecret(w http.ResponseWriter, r *http.Request) {
|
||||
panic(fmt.Sprintf("unknown content type %q", r.Header.Get("Content-Type")))
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("unhandled HTTP request %s %s", r.Method, r.URL))
|
||||
}
|
||||
}
|
||||
|
||||
func mustBase64(t *testing.T, v any) string {
|
||||
b := mustJSON(t, v)
|
||||
s := base64.StdEncoding.WithPadding('=').EncodeToString(b)
|
||||
return s
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("error converting %v to json: %v", v, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// egress services status given one named tailnet target specified by FQDN. As written by the proxy to its state Secret.
|
||||
func egressSvcStatus(name, fqdn string) egressservices.Status {
|
||||
return egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{
|
||||
name: {
|
||||
TailnetTarget: egressservices.TailnetTarget{
|
||||
FQDN: fqdn,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// egress config given one named tailnet target specified by FQDN.
|
||||
func egressSvcConfig(name, fqdn string) egressservices.Configs {
|
||||
return egressservices.Configs{
|
||||
name: egressservices.Config{
|
||||
TailnetTarget: egressservices.TailnetTarget{
|
||||
FQDN: fqdn,
|
||||
},
|
||||
},
|
||||
panic(fmt.Sprintf("unhandled HTTP method %q", r.Method))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
)
|
||||
|
||||
// metrics is a simple metrics HTTP server, if enabled it forwards requests to
|
||||
// the tailscaled's LocalAPI usermetrics endpoint at /localapi/v0/usermetrics.
|
||||
type metrics struct {
|
||||
debugEndpoint string
|
||||
lc *local.Client
|
||||
}
|
||||
|
||||
func proxy(w http.ResponseWriter, r *http.Request, url string, do func(*http.Request) (*http.Response, error)) {
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to construct request: %s", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
req.Header = r.Header.Clone()
|
||||
|
||||
resp, err := do(req)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to proxy request: %s", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for key, val := range resp.Header {
|
||||
for _, v := range val {
|
||||
w.Header().Add(key, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *metrics) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi/v0/usermetrics"
|
||||
proxy(w, r, localAPIURL, m.lc.DoLocalRequest)
|
||||
}
|
||||
|
||||
func (m *metrics) handleDebug(w http.ResponseWriter, r *http.Request) {
|
||||
if m.debugEndpoint == "" {
|
||||
http.Error(w, "debug endpoint not configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
debugURL := "http://" + m.debugEndpoint + r.URL.Path
|
||||
proxy(w, r, debugURL, http.DefaultClient.Do)
|
||||
}
|
||||
|
||||
// metricsHandlers registers a simple HTTP metrics handler at /metrics, forwarding
|
||||
// requests to tailscaled's /localapi/v0/usermetrics API.
|
||||
//
|
||||
// In 1.78.x and 1.80.x, it also proxies debug paths to tailscaled's debug
|
||||
// endpoint if configured to ease migration for a breaking change serving user
|
||||
// metrics instead of debug metrics on the "metrics" port.
|
||||
func metricsHandlers(mux *http.ServeMux, lc *local.Client, debugAddrPort string) {
|
||||
m := &metrics{
|
||||
lc: lc,
|
||||
debugEndpoint: debugAddrPort,
|
||||
}
|
||||
|
||||
mux.HandleFunc("GET /metrics", m.handleMetrics)
|
||||
mux.HandleFunc("/debug/", m.handleDebug) // TODO(tomhjp): Remove for 1.82.0 release.
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/types/netmap"
|
||||
)
|
||||
|
||||
// watchServeConfigChanges watches path for changes, and when it sees one, reads
|
||||
// the serve config from it, replacing ${TS_CERT_DOMAIN} with certDomain, and
|
||||
// applies it to lc. It exits when ctx is canceled. cdChanged is a channel that
|
||||
// is written to when the certDomain changes, causing the serve config to be
|
||||
// re-read and applied.
|
||||
func watchServeConfigChanges(ctx context.Context, path string, cdChanged <-chan bool, certDomainAtomic *atomic.Pointer[string], lc *local.Client, kc *kubeClient) {
|
||||
if certDomainAtomic == nil {
|
||||
panic("certDomainAtomic must not be nil")
|
||||
}
|
||||
var tickChan <-chan time.Time
|
||||
var eventChan <-chan fsnotify.Event
|
||||
if w, err := fsnotify.NewWatcher(); err != nil {
|
||||
log.Printf("serve proxy: failed to create fsnotify watcher, timer-only mode: %v", err)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
tickChan = ticker.C
|
||||
} else {
|
||||
defer w.Close()
|
||||
if err := w.Add(filepath.Dir(path)); err != nil {
|
||||
log.Fatalf("serve proxy: failed to add fsnotify watch: %v", err)
|
||||
}
|
||||
eventChan = w.Events
|
||||
}
|
||||
|
||||
var certDomain string
|
||||
var prevServeConfig *ipn.ServeConfig
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-cdChanged:
|
||||
certDomain = *certDomainAtomic.Load()
|
||||
case <-tickChan:
|
||||
case <-eventChan:
|
||||
// We can't do any reasonable filtering on the event because of how
|
||||
// k8s handles these mounts. So just re-read the file and apply it
|
||||
// if it's changed.
|
||||
}
|
||||
sc, err := readServeConfig(path, certDomain)
|
||||
if err != nil {
|
||||
log.Fatalf("serve proxy: failed to read serve config: %v", err)
|
||||
}
|
||||
if sc == nil {
|
||||
log.Printf("serve proxy: no serve config at %q, skipping", path)
|
||||
continue
|
||||
}
|
||||
if prevServeConfig != nil && reflect.DeepEqual(sc, prevServeConfig) {
|
||||
continue
|
||||
}
|
||||
if err := updateServeConfig(ctx, sc, certDomain, lc); err != nil {
|
||||
log.Fatalf("serve proxy: error updating serve config: %v", err)
|
||||
}
|
||||
if kc != nil && kc.canPatch {
|
||||
if err := kc.storeHTTPSEndpoint(ctx, certDomain); err != nil {
|
||||
log.Fatalf("serve proxy: error storing HTTPS endpoint: %v", err)
|
||||
}
|
||||
}
|
||||
prevServeConfig = sc
|
||||
}
|
||||
}
|
||||
|
||||
func certDomainFromNetmap(nm *netmap.NetworkMap) string {
|
||||
if len(nm.DNS.CertDomains) == 0 {
|
||||
return ""
|
||||
}
|
||||
return nm.DNS.CertDomains[0]
|
||||
}
|
||||
|
||||
// localClient is a subset of [local.Client] that can be mocked for testing.
|
||||
type localClient interface {
|
||||
SetServeConfig(context.Context, *ipn.ServeConfig) error
|
||||
}
|
||||
|
||||
func updateServeConfig(ctx context.Context, sc *ipn.ServeConfig, certDomain string, lc localClient) error {
|
||||
if !isValidHTTPSConfig(certDomain, sc) {
|
||||
return nil
|
||||
}
|
||||
log.Printf("serve proxy: applying serve config")
|
||||
return lc.SetServeConfig(ctx, sc)
|
||||
}
|
||||
|
||||
func isValidHTTPSConfig(certDomain string, sc *ipn.ServeConfig) bool {
|
||||
if certDomain == kubetypes.ValueNoHTTPS && hasHTTPSEndpoint(sc) {
|
||||
log.Printf(
|
||||
`serve proxy: this node is configured as a proxy that exposes an HTTPS endpoint to tailnet,
|
||||
(perhaps a Kubernetes operator Ingress proxy) but it is not able to issue TLS certs, so this will likely not work.
|
||||
To make it work, ensure that HTTPS is enabled for your tailnet, see https://tailscale.com/kb/1153/enabling-https for more details.`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasHTTPSEndpoint(cfg *ipn.ServeConfig) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
for _, tcpCfg := range cfg.TCP {
|
||||
if tcpCfg.HTTPS {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readServeConfig reads the ipn.ServeConfig from path, replacing
|
||||
// ${TS_CERT_DOMAIN} with certDomain.
|
||||
func readServeConfig(path, certDomain string) (*ipn.ServeConfig, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
j, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Serve config can be provided by users as well as the Kubernetes Operator (for its proxies). User-provided
|
||||
// config could be empty for reasons.
|
||||
if len(j) == 0 {
|
||||
log.Printf("serve proxy: serve config file is empty, skipping")
|
||||
return nil, nil
|
||||
}
|
||||
j = bytes.ReplaceAll(j, []byte("${TS_CERT_DOMAIN}"), []byte(certDomain))
|
||||
var sc ipn.ServeConfig
|
||||
if err := json.Unmarshal(j, &sc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sc, nil
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
)
|
||||
|
||||
func TestUpdateServeConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sc *ipn.ServeConfig
|
||||
certDomain string
|
||||
wantCall bool
|
||||
}{
|
||||
{
|
||||
name: "no_https_no_cert_domain",
|
||||
sc: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTP: true},
|
||||
},
|
||||
},
|
||||
certDomain: kubetypes.ValueNoHTTPS, // tailnet has HTTPS disabled
|
||||
wantCall: true, // should set serve config as it doesn't have HTTPS endpoints
|
||||
},
|
||||
{
|
||||
name: "https_with_cert_domain",
|
||||
sc: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {HTTPS: true},
|
||||
},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"${TS_CERT_DOMAIN}:443": {
|
||||
Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://10.0.1.100:8080"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
certDomain: "test-node.tailnet.ts.net",
|
||||
wantCall: true,
|
||||
},
|
||||
{
|
||||
name: "https_without_cert_domain",
|
||||
sc: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {HTTPS: true},
|
||||
},
|
||||
},
|
||||
certDomain: kubetypes.ValueNoHTTPS,
|
||||
wantCall: false, // incorrect configuration- should not set serve config
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakeLC := &fakeLocalClient{}
|
||||
err := updateServeConfig(context.Background(), tt.sc, tt.certDomain, fakeLC)
|
||||
if err != nil {
|
||||
t.Errorf("updateServeConfig() error = %v", err)
|
||||
}
|
||||
if fakeLC.setServeCalled != tt.wantCall {
|
||||
t.Errorf("SetServeConfig() called = %v, want %v", fakeLC.setServeCalled, tt.wantCall)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadServeConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gotSC string
|
||||
certDomain string
|
||||
wantSC *ipn.ServeConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty_file",
|
||||
},
|
||||
{
|
||||
name: "valid_config_with_cert_domain_placeholder",
|
||||
gotSC: `{
|
||||
"TCP": {
|
||||
"443": {
|
||||
"HTTPS": true
|
||||
}
|
||||
},
|
||||
"Web": {
|
||||
"${TS_CERT_DOMAIN}:443": {
|
||||
"Handlers": {
|
||||
"/api": {
|
||||
"Proxy": "https://10.2.3.4/api"
|
||||
}}}}}`,
|
||||
certDomain: "example.com",
|
||||
wantSC: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
HTTPS: true,
|
||||
},
|
||||
},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
ipn.HostPort("example.com:443"): {
|
||||
Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/api": {
|
||||
Proxy: "https://10.2.3.4/api",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid_config_for_http_proxy",
|
||||
gotSC: `{
|
||||
"TCP": {
|
||||
"80": {
|
||||
"HTTP": true
|
||||
}
|
||||
}}`,
|
||||
wantSC: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {
|
||||
HTTP: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "config_without_cert_domain",
|
||||
gotSC: `{
|
||||
"TCP": {
|
||||
"443": {
|
||||
"HTTPS": true
|
||||
}
|
||||
},
|
||||
"Web": {
|
||||
"localhost:443": {
|
||||
"Handlers": {
|
||||
"/api": {
|
||||
"Proxy": "https://10.2.3.4/api"
|
||||
}}}}}`,
|
||||
certDomain: "",
|
||||
wantErr: false,
|
||||
wantSC: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
HTTPS: true,
|
||||
},
|
||||
},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
ipn.HostPort("localhost:443"): {
|
||||
Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/api": {
|
||||
Proxy: "https://10.2.3.4/api",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid_json",
|
||||
gotSC: "invalid json",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "serve-config.json")
|
||||
if err := os.WriteFile(path, []byte(tt.gotSC), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := readServeConfig(path, tt.certDomain)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("readServeConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !cmp.Equal(got, tt.wantSC) {
|
||||
t.Errorf("readServeConfig() diff (-got +want):\n%s", cmp.Diff(got, tt.wantSC))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeLocalClient struct {
|
||||
*local.Client
|
||||
setServeCalled bool
|
||||
}
|
||||
|
||||
func (m *fakeLocalClient) SetServeConfig(ctx context.Context, cfg *ipn.ServeConfig) error {
|
||||
m.setServeCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHasHTTPSEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *ipn.ServeConfig
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil_config",
|
||||
cfg: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty_config",
|
||||
cfg: &ipn.ServeConfig{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no_https_endpoints",
|
||||
cfg: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {
|
||||
HTTPS: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "has_https_endpoint",
|
||||
cfg: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
HTTPS: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "mixed_endpoints",
|
||||
cfg: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
80: {HTTPS: false},
|
||||
443: {HTTPS: true},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hasHTTPSEndpoint(tt.cfg)
|
||||
if got != tt.want {
|
||||
t.Errorf("hasHTTPSEndpoint() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,757 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/linuxfw"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
const tailscaleTunInterface = "tailscale0"
|
||||
|
||||
// This file contains functionality to run containerboot as a proxy that can
|
||||
// route cluster traffic to one or more tailnet targets, based on portmapping
|
||||
// rules read from a configfile. Currently (9/2024) this is only used for the
|
||||
// Kubernetes operator egress proxies.
|
||||
|
||||
// egressProxy knows how to configure firewall rules to route cluster traffic to
|
||||
// one or more tailnet services.
|
||||
type egressProxy struct {
|
||||
cfgPath string // path to a directory with egress services config files
|
||||
|
||||
nfr linuxfw.NetfilterRunner // never nil
|
||||
|
||||
kc kubeclient.Client // never nil
|
||||
stateSecret string // name of the kube state Secret
|
||||
|
||||
tsClient *local.Client // never nil
|
||||
|
||||
netmapChan chan ipn.Notify // chan to receive netmap updates on
|
||||
|
||||
podIPv4 string // never empty string, currently only IPv4 is supported
|
||||
|
||||
// tailnetFQDNs is the egress service FQDN to tailnet IP mappings that
|
||||
// were last used to configure firewall rules for this proxy.
|
||||
// TODO(irbekrm): target addresses are also stored in the state Secret.
|
||||
// Evaluate whether we should retrieve them from there and not store in
|
||||
// memory at all.
|
||||
targetFQDNs map[string][]netip.Prefix
|
||||
|
||||
tailnetAddrs []netip.Prefix // tailnet IPs of this tailnet device
|
||||
|
||||
// shortSleep is the backoff sleep between healthcheck endpoint calls - can be overridden in tests.
|
||||
shortSleep time.Duration
|
||||
// longSleep is the time to sleep after the routing rules are updated to increase the chance that kube
|
||||
// proxies on all nodes have updated their routing configuration. It can be configured to 0 in
|
||||
// tests.
|
||||
longSleep time.Duration
|
||||
// client is a client that can send HTTP requests.
|
||||
client httpClient
|
||||
}
|
||||
|
||||
// httpClient is a client that can send HTTP requests and can be mocked in tests.
|
||||
type httpClient interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// run configures egress proxy firewall rules and ensures that the firewall rules are reconfigured when:
|
||||
// - the mounted egress config has changed
|
||||
// - the proxy's tailnet IP addresses have changed
|
||||
// - tailnet IPs have changed for any backend targets specified by tailnet FQDN
|
||||
func (ep *egressProxy) run(ctx context.Context, n ipn.Notify, opts egressProxyRunOpts) error {
|
||||
ep.configure(opts)
|
||||
var tickChan <-chan time.Time
|
||||
var eventChan <-chan fsnotify.Event
|
||||
// TODO (irbekrm): take a look if this can be pulled into a single func
|
||||
// shared with serve config loader.
|
||||
if w, err := fsnotify.NewWatcher(); err != nil {
|
||||
log.Printf("failed to create fsnotify watcher, timer-only mode: %v", err)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
tickChan = ticker.C
|
||||
} else {
|
||||
defer w.Close()
|
||||
if err := w.Add(ep.cfgPath); err != nil {
|
||||
return fmt.Errorf("failed to add fsnotify watch: %w", err)
|
||||
}
|
||||
eventChan = w.Events
|
||||
}
|
||||
|
||||
if err := ep.sync(ctx, n); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-tickChan:
|
||||
log.Printf("periodic sync, ensuring firewall config is up to date...")
|
||||
case <-eventChan:
|
||||
log.Printf("config file change detected, ensuring firewall config is up to date...")
|
||||
case n = <-ep.netmapChan:
|
||||
shouldResync := ep.shouldResync(n)
|
||||
if !shouldResync {
|
||||
continue
|
||||
}
|
||||
log.Printf("netmap change detected, ensuring firewall config is up to date...")
|
||||
}
|
||||
if err := ep.sync(ctx, n); err != nil {
|
||||
return fmt.Errorf("error syncing egress service config: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type egressProxyRunOpts struct {
|
||||
cfgPath string
|
||||
nfr linuxfw.NetfilterRunner
|
||||
kc kubeclient.Client
|
||||
tsClient *local.Client
|
||||
stateSecret string
|
||||
netmapChan chan ipn.Notify
|
||||
podIPv4 string
|
||||
tailnetAddrs []netip.Prefix
|
||||
}
|
||||
|
||||
// applyOpts configures egress proxy using the provided options.
|
||||
func (ep *egressProxy) configure(opts egressProxyRunOpts) {
|
||||
ep.cfgPath = opts.cfgPath
|
||||
ep.nfr = opts.nfr
|
||||
ep.kc = opts.kc
|
||||
ep.tsClient = opts.tsClient
|
||||
ep.stateSecret = opts.stateSecret
|
||||
ep.netmapChan = opts.netmapChan
|
||||
ep.podIPv4 = opts.podIPv4
|
||||
ep.tailnetAddrs = opts.tailnetAddrs
|
||||
ep.client = &http.Client{} // default HTTP client
|
||||
ep.shortSleep = time.Second
|
||||
ep.longSleep = time.Second * 10
|
||||
}
|
||||
|
||||
// sync triggers an egress proxy config resync. The resync calculates the diff between config and status to determine if
|
||||
// any firewall rules need to be updated. Currently using status in state Secret as a reference for what is the current
|
||||
// firewall configuration is good enough because - the status is keyed by the Pod IP - we crash the Pod on errors such
|
||||
// as failed firewall update
|
||||
func (ep *egressProxy) sync(ctx context.Context, n ipn.Notify) error {
|
||||
cfgs, err := ep.getConfigs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving egress service configs: %w", err)
|
||||
}
|
||||
status, err := ep.getStatus(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving current egress proxy status: %w", err)
|
||||
}
|
||||
newStatus, err := ep.syncEgressConfigs(cfgs, status, n)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error syncing egress service configs: %w", err)
|
||||
}
|
||||
if !servicesStatusIsEqual(newStatus, status) {
|
||||
if err := ep.setStatus(ctx, newStatus, n); err != nil {
|
||||
return fmt.Errorf("error setting egress proxy status: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addrsHaveChanged returns true if the provided netmap update contains tailnet address change for this proxy node.
|
||||
// Netmap must not be nil.
|
||||
func (ep *egressProxy) addrsHaveChanged(n ipn.Notify) bool {
|
||||
return !reflect.DeepEqual(ep.tailnetAddrs, n.NetMap.SelfNode.Addresses())
|
||||
}
|
||||
|
||||
// syncEgressConfigs adds and deletes firewall rules to match the desired
|
||||
// configuration. It uses the provided status to determine what is currently
|
||||
// applied and updates the status after a successful sync.
|
||||
func (ep *egressProxy) syncEgressConfigs(cfgs *egressservices.Configs, status *egressservices.Status, n ipn.Notify) (*egressservices.Status, error) {
|
||||
if !(wantsServicesConfigured(cfgs) || hasServicesConfigured(status)) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Delete unnecessary services.
|
||||
if err := ep.deleteUnnecessaryServices(cfgs, status); err != nil {
|
||||
return nil, fmt.Errorf("error deleting services: %w", err)
|
||||
|
||||
}
|
||||
newStatus := &egressservices.Status{}
|
||||
if !wantsServicesConfigured(cfgs) {
|
||||
return newStatus, nil
|
||||
}
|
||||
|
||||
// Add new services, update rules for any that have changed.
|
||||
rulesPerSvcToAdd := make(map[string][]rule, 0)
|
||||
rulesPerSvcToDelete := make(map[string][]rule, 0)
|
||||
for svcName, cfg := range *cfgs {
|
||||
tailnetTargetIPs, err := ep.tailnetTargetIPsForSvc(cfg, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error determining tailnet target IPs: %w", err)
|
||||
}
|
||||
rulesToAdd, rulesToDelete, err := updatesForCfg(svcName, cfg, status, tailnetTargetIPs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error validating service changes: %v", err)
|
||||
}
|
||||
log.Printf("syncegressservices: looking at svc %s rulesToAdd %d rulesToDelete %d", svcName, len(rulesToAdd), len(rulesToDelete))
|
||||
if len(rulesToAdd) != 0 {
|
||||
mak.Set(&rulesPerSvcToAdd, svcName, rulesToAdd)
|
||||
}
|
||||
if len(rulesToDelete) != 0 {
|
||||
mak.Set(&rulesPerSvcToDelete, svcName, rulesToDelete)
|
||||
}
|
||||
if len(rulesToAdd) != 0 || ep.addrsHaveChanged(n) {
|
||||
// For each tailnet target, set up SNAT from the local tailnet device address of the matching
|
||||
// family.
|
||||
for _, t := range tailnetTargetIPs {
|
||||
var local netip.Addr
|
||||
for _, pfx := range n.NetMap.SelfNode.Addresses().All() {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() != t.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return nil, fmt.Errorf("no valid local IP: %v", local)
|
||||
}
|
||||
if err := ep.nfr.EnsureSNATForDst(local, t); err != nil {
|
||||
return nil, fmt.Errorf("error setting up SNAT rule: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update the status. Status will be written back to the state Secret by the caller.
|
||||
mak.Set(&newStatus.Services, svcName, &egressservices.ServiceStatus{TailnetTargetIPs: tailnetTargetIPs, TailnetTarget: cfg.TailnetTarget, Ports: cfg.Ports})
|
||||
}
|
||||
|
||||
// Actually apply the firewall rules.
|
||||
if err := ensureRulesAdded(rulesPerSvcToAdd, ep.nfr); err != nil {
|
||||
return nil, fmt.Errorf("error adding rules: %w", err)
|
||||
}
|
||||
if err := ensureRulesDeleted(rulesPerSvcToDelete, ep.nfr); err != nil {
|
||||
return nil, fmt.Errorf("error deleting rules: %w", err)
|
||||
}
|
||||
|
||||
return newStatus, nil
|
||||
}
|
||||
|
||||
// updatesForCfg calculates any rules that need to be added or deleted for an individucal egress service config.
|
||||
func updatesForCfg(svcName string, cfg egressservices.Config, status *egressservices.Status, tailnetTargetIPs []netip.Addr) ([]rule, []rule, error) {
|
||||
rulesToAdd := make([]rule, 0)
|
||||
rulesToDelete := make([]rule, 0)
|
||||
currentConfig, ok := lookupCurrentConfig(svcName, status)
|
||||
|
||||
// If no rules for service are present yet, add them all.
|
||||
if !ok {
|
||||
for _, t := range tailnetTargetIPs {
|
||||
for ports := range cfg.Ports {
|
||||
log.Printf("syncegressservices: svc %s adding port %v", svcName, ports)
|
||||
rulesToAdd = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: t})
|
||||
}
|
||||
}
|
||||
return rulesToAdd, rulesToDelete, nil
|
||||
}
|
||||
|
||||
// If there are no backend targets available, delete any currently configured rules.
|
||||
if len(tailnetTargetIPs) == 0 {
|
||||
log.Printf("tailnet target for egress service %s does not have any backend addresses, deleting all rules", svcName)
|
||||
for _, ip := range currentConfig.TailnetTargetIPs {
|
||||
for ports := range currentConfig.Ports {
|
||||
rulesToDelete = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip})
|
||||
}
|
||||
}
|
||||
return rulesToAdd, rulesToDelete, nil
|
||||
}
|
||||
|
||||
// If there are rules present for backend targets that no longer match, delete them.
|
||||
for _, ip := range currentConfig.TailnetTargetIPs {
|
||||
var found bool
|
||||
for _, wantsIP := range tailnetTargetIPs {
|
||||
if reflect.DeepEqual(ip, wantsIP) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
for ports := range currentConfig.Ports {
|
||||
rulesToDelete = append(rulesToDelete, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync rules for the currently wanted backend targets.
|
||||
for _, ip := range tailnetTargetIPs {
|
||||
|
||||
// If the backend target is not yet present in status, add all rules.
|
||||
var found bool
|
||||
for _, gotIP := range currentConfig.TailnetTargetIPs {
|
||||
if reflect.DeepEqual(ip, gotIP) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
for ports := range cfg.Ports {
|
||||
rulesToAdd = append(rulesToAdd, rule{tailnetPort: ports.TargetPort, containerPort: ports.MatchPort, protocol: ports.Protocol, tailnetIP: ip})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If the backend target is present in status, check that the
|
||||
// currently applied rules are up to date.
|
||||
|
||||
// Delete any current portmappings that are no longer present in config.
|
||||
for port := range currentConfig.Ports {
|
||||
if _, ok := cfg.Ports[port]; ok {
|
||||
continue
|
||||
}
|
||||
rulesToDelete = append(rulesToDelete, rule{tailnetPort: port.TargetPort, containerPort: port.MatchPort, protocol: port.Protocol, tailnetIP: ip})
|
||||
}
|
||||
|
||||
// Add any new portmappings.
|
||||
for port := range cfg.Ports {
|
||||
if _, ok := currentConfig.Ports[port]; ok {
|
||||
continue
|
||||
}
|
||||
rulesToAdd = append(rulesToAdd, rule{tailnetPort: port.TargetPort, containerPort: port.MatchPort, protocol: port.Protocol, tailnetIP: ip})
|
||||
}
|
||||
}
|
||||
return rulesToAdd, rulesToDelete, nil
|
||||
}
|
||||
|
||||
// deleteUnneccessaryServices ensure that any services found on status, but not
|
||||
// present in config are deleted.
|
||||
func (ep *egressProxy) deleteUnnecessaryServices(cfgs *egressservices.Configs, status *egressservices.Status) error {
|
||||
if !hasServicesConfigured(status) {
|
||||
return nil
|
||||
}
|
||||
if !wantsServicesConfigured(cfgs) {
|
||||
for svcName, svc := range status.Services {
|
||||
log.Printf("service %s is no longer required, deleting", svcName)
|
||||
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
|
||||
return fmt.Errorf("error deleting service %s: %w", svcName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for svcName, svc := range status.Services {
|
||||
if _, ok := (*cfgs)[svcName]; !ok {
|
||||
log.Printf("service %s is no longer required, deleting", svcName)
|
||||
if err := ensureServiceDeleted(svcName, svc, ep.nfr); err != nil {
|
||||
return fmt.Errorf("error deleting service %s: %w", svcName, err)
|
||||
}
|
||||
// TODO (irbekrm): also delete the SNAT rule here
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getConfigs gets the mounted egress service configuration.
|
||||
func (ep *egressProxy) getConfigs() (*egressservices.Configs, error) {
|
||||
svcsCfg := filepath.Join(ep.cfgPath, egressservices.KeyEgressServices)
|
||||
j, err := os.ReadFile(svcsCfg)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(j) == 0 || string(j) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cfg := &egressservices.Configs{}
|
||||
if err := json.Unmarshal(j, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// getStatus gets the current status of the configured firewall. The current
|
||||
// status is stored in state Secret. Returns nil status if no status that
|
||||
// applies to the current proxy Pod was found. Uses the Pod IP to determine if a
|
||||
// status found in the state Secret applies to this proxy Pod.
|
||||
func (ep *egressProxy) getStatus(ctx context.Context) (*egressservices.Status, error) {
|
||||
secret, err := ep.kc.GetSecret(ctx, ep.stateSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error retrieving state secret: %w", err)
|
||||
}
|
||||
status := &egressservices.Status{}
|
||||
raw, ok := secret.Data[egressservices.KeyEgressServices]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), status); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling previous config: %w", err)
|
||||
}
|
||||
if reflect.DeepEqual(status.PodIPv4, ep.podIPv4) {
|
||||
return status, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// setStatus writes egress proxy's currently configured firewall to the state
|
||||
// Secret and updates proxy's tailnet addresses.
|
||||
func (ep *egressProxy) setStatus(ctx context.Context, status *egressservices.Status, n ipn.Notify) error {
|
||||
// Pod IP is used to determine if a stored status applies to THIS proxy Pod.
|
||||
if status == nil {
|
||||
status = &egressservices.Status{}
|
||||
}
|
||||
status.PodIPv4 = ep.podIPv4
|
||||
secret, err := ep.kc.GetSecret(ctx, ep.stateSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving state Secret: %w", err)
|
||||
}
|
||||
bs, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling service config: %w", err)
|
||||
}
|
||||
secret.Data[egressservices.KeyEgressServices] = bs
|
||||
patch := kubeclient.JSONPatch{
|
||||
Op: "replace",
|
||||
Path: fmt.Sprintf("/data/%s", egressservices.KeyEgressServices),
|
||||
Value: bs,
|
||||
}
|
||||
if err := ep.kc.JSONPatchResource(ctx, ep.stateSecret, kubeclient.TypeSecrets, []kubeclient.JSONPatch{patch}); err != nil {
|
||||
return fmt.Errorf("error patching state Secret: %w", err)
|
||||
}
|
||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
return nil
|
||||
}
|
||||
|
||||
// tailnetTargetIPsForSvc returns the tailnet IPs to which traffic for this
|
||||
// egress service should be proxied. The egress service can be configured by IP
|
||||
// or by FQDN. If it's configured by IP, just return that. If it's configured by
|
||||
// FQDN, resolve the FQDN and return the resolved IPs. It checks if the
|
||||
// netfilter runner supports IPv6 NAT and skips any IPv6 addresses if it
|
||||
// doesn't.
|
||||
func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.Notify) (addrs []netip.Addr, err error) {
|
||||
if svc.TailnetTarget.IP != "" {
|
||||
addr, err := netip.ParseAddr(svc.TailnetTarget.IP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing tailnet target IP: %w", err)
|
||||
}
|
||||
if addr.Is6() && !ep.nfr.HasIPV6NAT() {
|
||||
log.Printf("tailnet target is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode. This will probably not work.")
|
||||
return addrs, nil
|
||||
}
|
||||
return []netip.Addr{addr}, nil
|
||||
}
|
||||
|
||||
if svc.TailnetTarget.FQDN == "" {
|
||||
return nil, errors.New("unexpected egress service config- neither tailnet target IP nor FQDN is set")
|
||||
}
|
||||
if n.NetMap == nil {
|
||||
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
|
||||
return addrs, nil
|
||||
}
|
||||
var (
|
||||
node tailcfg.NodeView
|
||||
nodeFound bool
|
||||
)
|
||||
for _, nn := range n.NetMap.Peers {
|
||||
if equalFQDNs(nn.Name(), svc.TailnetTarget.FQDN) {
|
||||
node = nn
|
||||
nodeFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if nodeFound {
|
||||
for _, addr := range node.Addresses().AsSlice() {
|
||||
if addr.Addr().Is6() && !ep.nfr.HasIPV6NAT() {
|
||||
log.Printf("tailnet target %v is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode, skipping.", addr.Addr().String())
|
||||
continue
|
||||
}
|
||||
addrs = append(addrs, addr.Addr())
|
||||
}
|
||||
// Egress target endpoints configured via FQDN are stored, so
|
||||
// that we can determine if a netmap update should trigger a
|
||||
// resync.
|
||||
mak.Set(&ep.targetFQDNs, svc.TailnetTarget.FQDN, node.Addresses().AsSlice())
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// shouldResync parses netmap update and returns true if the update contains
|
||||
// changes for which the egress proxy's firewall should be reconfigured.
|
||||
func (ep *egressProxy) shouldResync(n ipn.Notify) bool {
|
||||
if n.NetMap == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If proxy's tailnet addresses have changed, resync.
|
||||
if !reflect.DeepEqual(n.NetMap.SelfNode.Addresses().AsSlice(), ep.tailnetAddrs) {
|
||||
log.Printf("node addresses have changed, trigger egress config resync")
|
||||
ep.tailnetAddrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
return true
|
||||
}
|
||||
|
||||
// If the IPs for any of the egress services configured via FQDN have
|
||||
// changed, resync.
|
||||
for fqdn, ips := range ep.targetFQDNs {
|
||||
for _, nn := range n.NetMap.Peers {
|
||||
if equalFQDNs(nn.Name(), fqdn) {
|
||||
if !reflect.DeepEqual(ips, nn.Addresses().AsSlice()) {
|
||||
log.Printf("backend addresses for egress target %q have changed old IPs %v, new IPs %v trigger egress config resync", nn.Name(), ips, nn.Addresses().AsSlice())
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureServiceDeleted ensures that any rules for an egress service are removed
|
||||
// from the firewall configuration.
|
||||
func ensureServiceDeleted(svcName string, svc *egressservices.ServiceStatus, nfr linuxfw.NetfilterRunner) error {
|
||||
|
||||
// Note that the portmap is needed for iptables based firewall only.
|
||||
// Nftables group rules for a service in a chain, so there is no need to
|
||||
// specify individual portmapping based rules.
|
||||
pms := make([]linuxfw.PortMap, 0)
|
||||
for pm := range svc.Ports {
|
||||
pms = append(pms, linuxfw.PortMap{MatchPort: pm.MatchPort, TargetPort: pm.TargetPort, Protocol: pm.Protocol})
|
||||
}
|
||||
|
||||
if err := nfr.DeleteSvc(svcName, tailscaleTunInterface, svc.TailnetTargetIPs, pms); err != nil {
|
||||
return fmt.Errorf("error deleting service %s: %w", svcName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureRulesAdded ensures that all portmapping rules are added to the firewall
|
||||
// configuration. For any rules that already exist, calling this function is a
|
||||
// no-op. In case of nftables, a service consists of one or two (one per IP
|
||||
// family) chains that conain the portmapping rules for the service and the
|
||||
// chains as needed when this function is called.
|
||||
func ensureRulesAdded(rulesPerSvc map[string][]rule, nfr linuxfw.NetfilterRunner) error {
|
||||
for svc, rules := range rulesPerSvc {
|
||||
for _, rule := range rules {
|
||||
log.Printf("ensureRulesAdded svc %s tailnetTarget %s container port %d tailnet port %d protocol %s", svc, rule.tailnetIP, rule.containerPort, rule.tailnetPort, rule.protocol)
|
||||
if err := nfr.EnsurePortMapRuleForSvc(svc, tailscaleTunInterface, rule.tailnetIP, linuxfw.PortMap{MatchPort: rule.containerPort, TargetPort: rule.tailnetPort, Protocol: rule.protocol}); err != nil {
|
||||
return fmt.Errorf("error ensuring rule: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureRulesDeleted ensures that the given rules are deleted from the firewall
|
||||
// configuration. For any rules that do not exist, calling this funcion is a
|
||||
// no-op.
|
||||
func ensureRulesDeleted(rulesPerSvc map[string][]rule, nfr linuxfw.NetfilterRunner) error {
|
||||
for svc, rules := range rulesPerSvc {
|
||||
for _, rule := range rules {
|
||||
log.Printf("ensureRulesDeleted svc %s tailnetTarget %s container port %d tailnet port %d protocol %s", svc, rule.tailnetIP, rule.containerPort, rule.tailnetPort, rule.protocol)
|
||||
if err := nfr.DeletePortMapRuleForSvc(svc, tailscaleTunInterface, rule.tailnetIP, linuxfw.PortMap{MatchPort: rule.containerPort, TargetPort: rule.tailnetPort, Protocol: rule.protocol}); err != nil {
|
||||
return fmt.Errorf("error deleting rule: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupCurrentConfig(svcName string, status *egressservices.Status) (*egressservices.ServiceStatus, bool) {
|
||||
if status == nil || len(status.Services) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
c, ok := status.Services[svcName]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
func equalFQDNs(s, s1 string) bool {
|
||||
s, _ = strings.CutSuffix(s, ".")
|
||||
s1, _ = strings.CutSuffix(s1, ".")
|
||||
return strings.EqualFold(s, s1)
|
||||
}
|
||||
|
||||
// rule contains configuration for an egress proxy firewall rule.
|
||||
type rule struct {
|
||||
containerPort uint16 // port to match incoming traffic
|
||||
tailnetPort uint16 // tailnet service port
|
||||
tailnetIP netip.Addr // tailnet service IP
|
||||
protocol string
|
||||
}
|
||||
|
||||
func wantsServicesConfigured(cfgs *egressservices.Configs) bool {
|
||||
return cfgs != nil && len(*cfgs) != 0
|
||||
}
|
||||
|
||||
func hasServicesConfigured(status *egressservices.Status) bool {
|
||||
return status != nil && len(status.Services) != 0
|
||||
}
|
||||
|
||||
func servicesStatusIsEqual(st, st1 *egressservices.Status) bool {
|
||||
if st == nil && st1 == nil {
|
||||
return true
|
||||
}
|
||||
if st == nil || st1 == nil {
|
||||
return false
|
||||
}
|
||||
st.PodIPv4 = ""
|
||||
st1.PodIPv4 = ""
|
||||
return reflect.DeepEqual(*st, *st1)
|
||||
}
|
||||
|
||||
// registerHandlers adds a new handler to the provided ServeMux that can be called as a Kubernetes prestop hook to
|
||||
// delay shutdown till it's safe to do so.
|
||||
func (ep *egressProxy) registerHandlers(mux *http.ServeMux) {
|
||||
mux.Handle(fmt.Sprintf("GET %s", kubetypes.EgessServicesPreshutdownEP), ep)
|
||||
}
|
||||
|
||||
// ServeHTTP serves /internal-egress-services-preshutdown endpoint, when it receives a request, it periodically polls
|
||||
// the configured health check endpoint for each egress service till it the health check endpoint no longer hits this
|
||||
// proxy Pod. It uses the Pod-IPv4 header to verify if health check response is received from this Pod.
|
||||
func (ep *egressProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
cfgs, err := ep.getConfigs()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("error retrieving egress services configs: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if cfgs == nil {
|
||||
if _, err := w.Write([]byte("safe to terminate")); err != nil {
|
||||
http.Error(w, fmt.Sprintf("error writing termination status: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
hp, err := ep.getHEPPings()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("error determining the number of times health check endpoint should be pinged: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ep.waitTillSafeToShutdown(r.Context(), cfgs, hp)
|
||||
}
|
||||
|
||||
// waitTillSafeToShutdown looks up all egress targets configured to be proxied via this instance and, for each target
|
||||
// whose configuration includes a healthcheck endpoint, pings the endpoint till none of the responses
|
||||
// are returned by this instance or till the HTTP request times out. In practice, the endpoint will be a Kubernetes Service for whom one of the backends
|
||||
// would normally be this Pod. When this Pod is being deleted, the operator should have removed it from the Service
|
||||
// backends and eventually kube proxy routing rules should be updated to no longer route traffic for the Service to this
|
||||
// Pod.
|
||||
func (ep *egressProxy) waitTillSafeToShutdown(ctx context.Context, cfgs *egressservices.Configs, hp int) {
|
||||
if cfgs == nil || len(*cfgs) == 0 { // avoid sleeping if no services are configured
|
||||
return
|
||||
}
|
||||
log.Printf("Ensuring that cluster traffic for egress targets is no longer routed via this Pod...")
|
||||
wg := syncs.WaitGroup{}
|
||||
|
||||
for s, cfg := range *cfgs {
|
||||
hep := cfg.HealthCheckEndpoint
|
||||
if hep == "" {
|
||||
log.Printf("Tailnet target %q does not have a cluster healthcheck specified, unable to verify if cluster traffic for the target is still routed via this Pod", s)
|
||||
continue
|
||||
}
|
||||
svc := s
|
||||
wg.Go(func() {
|
||||
log.Printf("Ensuring that cluster traffic is no longer routed to %q via this Pod...", svc)
|
||||
for {
|
||||
if ctx.Err() != nil { // kubelet's HTTP request timeout
|
||||
log.Printf("Cluster traffic for %s did not stop being routed to this Pod.", svc)
|
||||
return
|
||||
}
|
||||
found, err := lookupPodRoute(ctx, hep, ep.podIPv4, hp, ep.client)
|
||||
if err != nil {
|
||||
log.Printf("unable to reach endpoint %q, assuming the routing rules for this Pod have been deleted: %v", hep, err)
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
log.Printf("service %q is no longer routed through this Pod", svc)
|
||||
break
|
||||
}
|
||||
log.Printf("service %q is still routed through this Pod, waiting...", svc)
|
||||
time.Sleep(ep.shortSleep)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
// The check above really only checked that the routing rules are updated on this node. Sleep for a bit to
|
||||
// ensure that the routing rules are updated on other nodes. TODO(irbekrm): this may or may not be good enough.
|
||||
// If it's not good enough, we'd probably want to do something more complex, where the proxies check each other.
|
||||
log.Printf("Sleeping for %s before shutdown to ensure that kube proxies on all nodes have updated routing configuration", ep.longSleep)
|
||||
time.Sleep(ep.longSleep)
|
||||
}
|
||||
|
||||
// lookupPodRoute calls the healthcheck endpoint repeat times and returns true if the endpoint returns with the podIP
|
||||
// header at least once.
|
||||
func lookupPodRoute(ctx context.Context, hep, podIP string, repeat int, client httpClient) (bool, error) {
|
||||
for range repeat {
|
||||
f, err := lookup(ctx, hep, podIP, client)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if f {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// lookup calls the healthcheck endpoint and returns true if the response contains the podIP header.
|
||||
func lookup(ctx context.Context, hep, podIP string, client httpClient) (bool, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, httpm.GET, hep, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error creating new HTTP request: %v", err)
|
||||
}
|
||||
|
||||
// Close the TCP connection to ensure that the next request is routed to a different backend.
|
||||
req.Close = true
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Endpoint %q can not be reached: %v, likely because there are no (more) healthy backends", hep, err)
|
||||
return true, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
gotIP := resp.Header.Get(kubetypes.PodIPv4Header)
|
||||
return strings.EqualFold(podIP, gotIP), nil
|
||||
}
|
||||
|
||||
// getHEPPings gets the number of pings that should be sent to a health check endpoint to ensure that each configured
|
||||
// backend is hit. This assumes that a health check endpoint is a Kubernetes Service and traffic to backend Pods is
|
||||
// round robin load balanced.
|
||||
func (ep *egressProxy) getHEPPings() (int, error) {
|
||||
hepPingsPath := filepath.Join(ep.cfgPath, egressservices.KeyHEPPings)
|
||||
j, err := os.ReadFile(hepPingsPath)
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
if len(j) == 0 || string(j) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
hp, err := strconv.Atoi(string(j))
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("error parsing hep pings as int: %v", err)
|
||||
}
|
||||
if hp < 0 {
|
||||
log.Printf("[unexpected] hep pings is negative: %d", hp)
|
||||
return 0, nil
|
||||
}
|
||||
return hp, nil
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/kube/egressservices"
|
||||
"tailscale.com/kube/kubetypes"
|
||||
)
|
||||
|
||||
func Test_updatesForSvc(t *testing.T) {
|
||||
tailnetIPv4, tailnetIPv6 := netip.MustParseAddr("100.99.99.99"), netip.MustParseAddr("fd7a:115c:a1e0::701:b62a")
|
||||
tailnetIPv4_1, tailnetIPv6_1 := netip.MustParseAddr("100.88.88.88"), netip.MustParseAddr("fd7a:115c:a1e0::4101:512f")
|
||||
ports := map[egressservices.PortMap]struct{}{{Protocol: "tcp", MatchPort: 4003, TargetPort: 80}: {}}
|
||||
ports1 := map[egressservices.PortMap]struct{}{{Protocol: "udp", MatchPort: 4004, TargetPort: 53}: {}}
|
||||
ports2 := map[egressservices.PortMap]struct{}{{Protocol: "tcp", MatchPort: 4003, TargetPort: 80}: {},
|
||||
{Protocol: "tcp", MatchPort: 4005, TargetPort: 443}: {}}
|
||||
fqdnSpec := egressservices.Config{
|
||||
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
|
||||
Ports: ports,
|
||||
}
|
||||
fqdnSpec1 := egressservices.Config{
|
||||
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
|
||||
Ports: ports1,
|
||||
}
|
||||
fqdnSpec2 := egressservices.Config{
|
||||
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
|
||||
Ports: ports,
|
||||
}
|
||||
fqdnSpec3 := egressservices.Config{
|
||||
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
|
||||
Ports: ports2,
|
||||
}
|
||||
r := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv4}
|
||||
r1 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv6}
|
||||
r2 := rule{tailnetPort: 53, containerPort: 4004, protocol: "udp", tailnetIP: tailnetIPv4}
|
||||
r3 := rule{tailnetPort: 53, containerPort: 4004, protocol: "udp", tailnetIP: tailnetIPv6}
|
||||
r4 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv4_1}
|
||||
r5 := rule{containerPort: 4003, tailnetPort: 80, protocol: "tcp", tailnetIP: tailnetIPv6_1}
|
||||
r6 := rule{containerPort: 4005, tailnetPort: 443, protocol: "tcp", tailnetIP: tailnetIPv4}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
svcName string
|
||||
tailnetTargetIPs []netip.Addr
|
||||
podIP string
|
||||
spec egressservices.Config
|
||||
status *egressservices.Status
|
||||
wantRulesToAdd []rule
|
||||
wantRulesToDelete []rule
|
||||
}{
|
||||
{
|
||||
name: "add_fqdn_svc_that_does_not_yet_exist",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
spec: fqdnSpec,
|
||||
status: &egressservices.Status{},
|
||||
wantRulesToAdd: []rule{r, r1},
|
||||
wantRulesToDelete: []rule{},
|
||||
},
|
||||
{
|
||||
name: "fqdn_svc_already_exists",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
spec: fqdnSpec,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
|
||||
Ports: ports,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{},
|
||||
wantRulesToDelete: []rule{},
|
||||
},
|
||||
{
|
||||
name: "fqdn_svc_already_exists_add_port_remove_port",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
spec: fqdnSpec1,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
|
||||
Ports: ports,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{r2, r3},
|
||||
wantRulesToDelete: []rule{r, r1},
|
||||
},
|
||||
{
|
||||
name: "fqdn_svc_already_exists_change_fqdn_backend_ips",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4_1, tailnetIPv6_1},
|
||||
spec: fqdnSpec,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4, tailnetIPv6},
|
||||
TailnetTarget: egressservices.TailnetTarget{FQDN: "test"},
|
||||
Ports: ports,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{r4, r5},
|
||||
wantRulesToDelete: []rule{r, r1},
|
||||
},
|
||||
{
|
||||
name: "add_ip_service",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
spec: fqdnSpec2,
|
||||
status: &egressservices.Status{},
|
||||
wantRulesToAdd: []rule{r},
|
||||
wantRulesToDelete: []rule{},
|
||||
},
|
||||
{
|
||||
name: "add_ip_service_already_exists",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
spec: fqdnSpec2,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
|
||||
Ports: ports,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{},
|
||||
wantRulesToDelete: []rule{},
|
||||
},
|
||||
{
|
||||
name: "ip_service_add_port",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
spec: fqdnSpec3,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
|
||||
Ports: ports,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{r6},
|
||||
wantRulesToDelete: []rule{},
|
||||
},
|
||||
{
|
||||
name: "ip_service_delete_port",
|
||||
svcName: "test",
|
||||
tailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
spec: fqdnSpec,
|
||||
status: &egressservices.Status{
|
||||
Services: map[string]*egressservices.ServiceStatus{"test": {
|
||||
TailnetTargetIPs: []netip.Addr{tailnetIPv4},
|
||||
TailnetTarget: egressservices.TailnetTarget{IP: tailnetIPv4.String()},
|
||||
Ports: ports2,
|
||||
}}},
|
||||
wantRulesToAdd: []rule{},
|
||||
wantRulesToDelete: []rule{r6},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotRulesToAdd, gotRulesToDelete, err := updatesForCfg(tt.svcName, tt.spec, tt.status, tt.tailnetTargetIPs)
|
||||
if err != nil {
|
||||
t.Errorf("updatesForSvc() unexpected error %v", err)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(gotRulesToAdd, tt.wantRulesToAdd) {
|
||||
t.Errorf("updatesForSvc() got rulesToAdd = \n%v\n want rulesToAdd \n%v", gotRulesToAdd, tt.wantRulesToAdd)
|
||||
}
|
||||
if !reflect.DeepEqual(gotRulesToDelete, tt.wantRulesToDelete) {
|
||||
t.Errorf("updatesForSvc() got rulesToDelete = \n%v\n want rulesToDelete \n%v", gotRulesToDelete, tt.wantRulesToDelete)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A failure of this test will most likely look like a timeout.
|
||||
func TestWaitTillSafeToShutdown(t *testing.T) {
|
||||
podIP := "10.0.0.1"
|
||||
anotherIP := "10.0.0.2"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// services is a map of service name to the number of calls to make to the healthcheck endpoint before
|
||||
// returning a response that does NOT contain this Pod's IP in headers.
|
||||
services map[string]int
|
||||
replicas int
|
||||
healthCheckSet bool
|
||||
}{
|
||||
{
|
||||
name: "no_configs",
|
||||
},
|
||||
{
|
||||
name: "one_service_immediately_safe_to_shutdown",
|
||||
services: map[string]int{
|
||||
"svc1": 0,
|
||||
},
|
||||
replicas: 2,
|
||||
healthCheckSet: true,
|
||||
},
|
||||
{
|
||||
name: "multiple_services_immediately_safe_to_shutdown",
|
||||
services: map[string]int{
|
||||
"svc1": 0,
|
||||
"svc2": 0,
|
||||
"svc3": 0,
|
||||
},
|
||||
replicas: 2,
|
||||
healthCheckSet: true,
|
||||
},
|
||||
{
|
||||
name: "multiple_services_no_healthcheck_endpoints",
|
||||
services: map[string]int{
|
||||
"svc1": 0,
|
||||
"svc2": 0,
|
||||
"svc3": 0,
|
||||
},
|
||||
replicas: 2,
|
||||
},
|
||||
{
|
||||
name: "one_service_eventually_safe_to_shutdown",
|
||||
services: map[string]int{
|
||||
"svc1": 3, // After 3 calls to health check endpoint, no longer returns this Pod's IP
|
||||
},
|
||||
replicas: 2,
|
||||
healthCheckSet: true,
|
||||
},
|
||||
{
|
||||
name: "multiple_services_eventually_safe_to_shutdown",
|
||||
services: map[string]int{
|
||||
"svc1": 1, // After 1 call to health check endpoint, no longer returns this Pod's IP
|
||||
"svc2": 3, // After 3 calls to health check endpoint, no longer returns this Pod's IP
|
||||
"svc3": 5, // After 5 calls to the health check endpoint, no longer returns this Pod's IP
|
||||
},
|
||||
replicas: 2,
|
||||
healthCheckSet: true,
|
||||
},
|
||||
{
|
||||
name: "multiple_services_eventually_safe_to_shutdown_with_higher_replica_count",
|
||||
services: map[string]int{
|
||||
"svc1": 7,
|
||||
"svc2": 10,
|
||||
},
|
||||
replicas: 5,
|
||||
healthCheckSet: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfgs := &egressservices.Configs{}
|
||||
switches := make(map[string]int)
|
||||
|
||||
for svc, callsToSwitch := range tt.services {
|
||||
endpoint := fmt.Sprintf("http://%s.local", svc)
|
||||
if tt.healthCheckSet {
|
||||
(*cfgs)[svc] = egressservices.Config{
|
||||
HealthCheckEndpoint: endpoint,
|
||||
}
|
||||
}
|
||||
switches[endpoint] = callsToSwitch
|
||||
}
|
||||
|
||||
ep := &egressProxy{
|
||||
podIPv4: podIP,
|
||||
client: &mockHTTPClient{
|
||||
podIP: podIP,
|
||||
anotherIP: anotherIP,
|
||||
switches: switches,
|
||||
},
|
||||
}
|
||||
|
||||
ep.waitTillSafeToShutdown(context.Background(), cfgs, tt.replicas)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mockHTTPClient is a client that receives an HTTP call for an egress service endpoint and returns a response with an
|
||||
// IP address in a 'Pod-IPv4' header. It can be configured to return one IP address for N calls, then switch to another
|
||||
// IP address to simulate a scenario where an IP is eventually no longer a backend for an endpoint.
|
||||
// TODO(irbekrm): to test this more thoroughly, we should have the client take into account the number of replicas and
|
||||
// return as if traffic was round robin load balanced across different Pods.
|
||||
type mockHTTPClient struct {
|
||||
// podIP - initial IP address to return, that matches the current proxy's IP address.
|
||||
podIP string
|
||||
anotherIP string
|
||||
// after how many calls to an endpoint, the client should start returning 'anotherIP' instead of 'podIP.
|
||||
switches map[string]int
|
||||
mu sync.Mutex // protects the following
|
||||
// calls tracks the number of calls received.
|
||||
calls map[string]int
|
||||
}
|
||||
|
||||
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
m.mu.Lock()
|
||||
if m.calls == nil {
|
||||
m.calls = make(map[string]int)
|
||||
}
|
||||
|
||||
endpoint := req.URL.String()
|
||||
m.calls[endpoint]++
|
||||
calls := m.calls[endpoint]
|
||||
m.mu.Unlock()
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
}
|
||||
|
||||
if calls <= m.switches[endpoint] {
|
||||
resp.Header.Set(kubetypes.PodIPv4Header, m.podIP) // Pod is still routable
|
||||
} else {
|
||||
resp.Header.Set(kubetypes.PodIPv4Header, m.anotherIP) // Pod is no longer routable
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/kube/kubeclient"
|
||||
)
|
||||
|
||||
// settings is all the configuration for containerboot.
|
||||
type settings struct {
|
||||
AuthKey string
|
||||
Hostname string
|
||||
Routes *string
|
||||
// ProxyTargetIP is the destination IP to which all incoming
|
||||
// Tailscale traffic should be proxied. If empty, no proxying
|
||||
// is done. This is typically a locally reachable IP.
|
||||
ProxyTargetIP string
|
||||
// ProxyTargetDNSName is a DNS name to whose backing IP addresses all
|
||||
// incoming Tailscale traffic should be proxied.
|
||||
ProxyTargetDNSName string
|
||||
// TailnetTargetIP is the destination IP to which all incoming
|
||||
// non-Tailscale traffic should be proxied. This is typically a
|
||||
// Tailscale IP.
|
||||
TailnetTargetIP string
|
||||
// TailnetTargetFQDN is an MagicDNS name to which all incoming
|
||||
// non-Tailscale traffic should be proxied. This must be a full Tailnet
|
||||
// node FQDN.
|
||||
TailnetTargetFQDN string
|
||||
ServeConfigPath string
|
||||
DaemonExtraArgs string
|
||||
ExtraArgs string
|
||||
InKubernetes bool
|
||||
UserspaceMode bool
|
||||
StateDir string
|
||||
AcceptDNS *bool
|
||||
KubeSecret string
|
||||
SOCKSProxyAddr string
|
||||
HTTPProxyAddr string
|
||||
Socket string
|
||||
AuthOnce bool
|
||||
Root string
|
||||
KubernetesCanPatch bool
|
||||
TailscaledConfigFilePath string
|
||||
EnableForwardingOptimizations bool
|
||||
// If set to true and, if this containerboot instance is a Kubernetes
|
||||
// ingress proxy, set up rules to forward incoming cluster traffic to be
|
||||
// forwarded to the ingress target in cluster.
|
||||
AllowProxyingClusterTrafficViaIngress bool
|
||||
// PodIP is the IP of the Pod if running in Kubernetes. This is used
|
||||
// when setting up rules to proxy cluster traffic to cluster ingress
|
||||
// target.
|
||||
// Deprecated: use PodIPv4, PodIPv6 instead to support dual stack clusters
|
||||
PodIP string
|
||||
PodIPv4 string
|
||||
PodIPv6 string
|
||||
PodUID string
|
||||
HealthCheckAddrPort string
|
||||
LocalAddrPort string
|
||||
MetricsEnabled bool
|
||||
HealthCheckEnabled bool
|
||||
DebugAddrPort string
|
||||
EgressProxiesCfgPath string
|
||||
}
|
||||
|
||||
func configFromEnv() (*settings, error) {
|
||||
cfg := &settings{
|
||||
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
|
||||
Hostname: defaultEnv("TS_HOSTNAME", ""),
|
||||
Routes: defaultEnvStringPointer("TS_ROUTES"),
|
||||
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
|
||||
ProxyTargetIP: defaultEnv("TS_DEST_IP", ""),
|
||||
ProxyTargetDNSName: defaultEnv("TS_EXPERIMENTAL_DEST_DNS_NAME", ""),
|
||||
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
|
||||
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
|
||||
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
|
||||
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
|
||||
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
|
||||
UserspaceMode: defaultBool("TS_USERSPACE", true),
|
||||
StateDir: defaultEnv("TS_STATE_DIR", ""),
|
||||
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
|
||||
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
|
||||
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
|
||||
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
|
||||
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
TailscaledConfigFilePath: tailscaledConfigFilePath(),
|
||||
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
|
||||
PodIP: defaultEnv("POD_IP", ""),
|
||||
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
|
||||
HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""),
|
||||
LocalAddrPort: defaultEnv("TS_LOCAL_ADDR_PORT", "[::]:9002"),
|
||||
MetricsEnabled: defaultBool("TS_ENABLE_METRICS", false),
|
||||
HealthCheckEnabled: defaultBool("TS_ENABLE_HEALTH_CHECK", false),
|
||||
DebugAddrPort: defaultEnv("TS_DEBUG_ADDR_PORT", ""),
|
||||
EgressProxiesCfgPath: defaultEnv("TS_EGRESS_PROXIES_CONFIG_PATH", ""),
|
||||
PodUID: defaultEnv("POD_UID", ""),
|
||||
}
|
||||
podIPs, ok := os.LookupEnv("POD_IPS")
|
||||
if ok {
|
||||
ips := strings.Split(podIPs, ",")
|
||||
if len(ips) > 2 {
|
||||
return nil, fmt.Errorf("POD_IPs can contain at most 2 IPs, got %d (%v)", len(ips), ips)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
parsed, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing IP address %s: %w", ip, err)
|
||||
}
|
||||
if parsed.Is4() {
|
||||
cfg.PodIPv4 = parsed.String()
|
||||
continue
|
||||
}
|
||||
cfg.PodIPv6 = parsed.String()
|
||||
}
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %v", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *settings) validate() error {
|
||||
if s.TailscaledConfigFilePath != "" {
|
||||
dir, file := path.Split(s.TailscaledConfigFilePath)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return fmt.Errorf("error validating whether directory with tailscaled config file %s exists: %w", dir, err)
|
||||
}
|
||||
if _, err := os.Stat(s.TailscaledConfigFilePath); err != nil {
|
||||
return fmt.Errorf("error validating whether tailscaled config directory %q contains tailscaled config for current capability version %q: %w. If this is a Tailscale Kubernetes operator proxy, please ensure that the version of the operator is not older than the version of the proxy", dir, file, err)
|
||||
}
|
||||
if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil {
|
||||
return fmt.Errorf("error validating tailscaled configfile contents: %w", err)
|
||||
}
|
||||
}
|
||||
if s.ProxyTargetIP != "" && s.UserspaceMode {
|
||||
return errors.New("TS_DEST_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.ProxyTargetDNSName != "" && s.UserspaceMode {
|
||||
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.ProxyTargetDNSName != "" && s.ProxyTargetIP != "" {
|
||||
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME and TS_DEST_IP cannot both be set")
|
||||
}
|
||||
if s.TailnetTargetIP != "" && s.UserspaceMode {
|
||||
return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.TailnetTargetFQDN != "" && s.UserspaceMode {
|
||||
return errors.New("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" {
|
||||
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
|
||||
}
|
||||
if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") {
|
||||
return errors.New("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
|
||||
}
|
||||
if s.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode {
|
||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode")
|
||||
}
|
||||
if s.AllowProxyingClusterTrafficViaIngress && s.ServeConfigPath == "" {
|
||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but this is not a cluster ingress proxy")
|
||||
}
|
||||
if s.AllowProxyingClusterTrafficViaIngress && s.PodIP == "" {
|
||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but POD_IP is not set")
|
||||
}
|
||||
if s.EnableForwardingOptimizations && s.UserspaceMode {
|
||||
return errors.New("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS is not supported in userspace mode")
|
||||
}
|
||||
if s.HealthCheckAddrPort != "" {
|
||||
log.Printf("[warning] TS_HEALTHCHECK_ADDR_PORT is deprecated and will be removed in 1.82.0. Please use TS_ENABLE_HEALTH_CHECK and optionally TS_LOCAL_ADDR_PORT instead.")
|
||||
if _, err := netip.ParseAddrPort(s.HealthCheckAddrPort); err != nil {
|
||||
return fmt.Errorf("error parsing TS_HEALTHCHECK_ADDR_PORT value %q: %w", s.HealthCheckAddrPort, err)
|
||||
}
|
||||
}
|
||||
if s.localMetricsEnabled() || s.localHealthEnabled() || s.EgressProxiesCfgPath != "" {
|
||||
if _, err := netip.ParseAddrPort(s.LocalAddrPort); err != nil {
|
||||
return fmt.Errorf("error parsing TS_LOCAL_ADDR_PORT value %q: %w", s.LocalAddrPort, err)
|
||||
}
|
||||
}
|
||||
if s.DebugAddrPort != "" {
|
||||
if _, err := netip.ParseAddrPort(s.DebugAddrPort); err != nil {
|
||||
return fmt.Errorf("error parsing TS_DEBUG_ADDR_PORT value %q: %w", s.DebugAddrPort, err)
|
||||
}
|
||||
}
|
||||
if s.HealthCheckEnabled && s.HealthCheckAddrPort != "" {
|
||||
return errors.New("TS_HEALTHCHECK_ADDR_PORT is deprecated and will be removed in 1.82.0, use TS_ENABLE_HEALTH_CHECK and optionally TS_LOCAL_ADDR_PORT")
|
||||
}
|
||||
if s.EgressProxiesCfgPath != "" && !(s.InKubernetes && s.KubeSecret != "") {
|
||||
return errors.New("TS_EGRESS_PROXIES_CONFIG_PATH is only supported for Tailscale running on Kubernetes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupKube is responsible for doing any necessary configuration and checks to
|
||||
// ensure that tailscale state storage and authentication mechanism will work on
|
||||
// Kubernetes.
|
||||
func (cfg *settings) setupKube(ctx context.Context, kc *kubeClient) error {
|
||||
if cfg.KubeSecret == "" {
|
||||
return nil
|
||||
}
|
||||
canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
|
||||
}
|
||||
cfg.KubernetesCanPatch = canPatch
|
||||
kc.canPatch = canPatch
|
||||
|
||||
s, err := kc.GetSecret(ctx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
if !kubeclient.IsNotFoundErr(err) {
|
||||
return fmt.Errorf("getting Tailscale state Secret %s: %v", cfg.KubeSecret, err)
|
||||
}
|
||||
|
||||
if !canCreate {
|
||||
return fmt.Errorf("tailscale state Secret %s does not exist and we don't have permissions to create it. "+
|
||||
"If you intend to store tailscale state elsewhere than a Kubernetes Secret, "+
|
||||
"you can explicitly set TS_KUBE_SECRET env var to an empty string. "+
|
||||
"Else ensure that RBAC is set up that allows the service account associated with this installation to create Secrets.", cfg.KubeSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// Return early if we already have an auth key.
|
||||
if cfg.AuthKey != "" || isOneStepConfig(cfg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
log.Print("TS_AUTHKEY not provided and state Secret does not exist, login will be interactive if needed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
keyBytes, _ := s.Data["authkey"]
|
||||
key := string(keyBytes)
|
||||
|
||||
if key != "" {
|
||||
// Enforce that we must be able to patch out the authkey after
|
||||
// authenticating if you want to use this feature. This avoids
|
||||
// us having to deal with the case where we might leave behind
|
||||
// an unnecessary reusable authkey in a secret, like a rake in
|
||||
// the grass.
|
||||
if !cfg.KubernetesCanPatch {
|
||||
return errors.New("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the Secret to manage the authkey.")
|
||||
}
|
||||
cfg.AuthKey = key
|
||||
}
|
||||
|
||||
log.Print("No authkey found in state Secret and TS_AUTHKEY not provided, login will be interactive if needed.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTwoStepConfigAuthOnce returns true if the Tailscale node should be configured
|
||||
// in two steps and login should only happen once.
|
||||
// Step 1: run 'tailscaled'
|
||||
// Step 2):
|
||||
// A) if this is the first time starting this node run 'tailscale up --authkey <authkey> <config opts>'
|
||||
// B) if this is not the first time starting this node run 'tailscale set <config opts>'.
|
||||
func isTwoStepConfigAuthOnce(cfg *settings) bool {
|
||||
return cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
|
||||
}
|
||||
|
||||
// isTwoStepConfigAlwaysAuth returns true if the Tailscale node should be configured
|
||||
// in two steps and we should log in every time it starts.
|
||||
// Step 1: run 'tailscaled'
|
||||
// Step 2): run 'tailscale up --authkey <authkey> <config opts>'
|
||||
func isTwoStepConfigAlwaysAuth(cfg *settings) bool {
|
||||
return !cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
|
||||
}
|
||||
|
||||
// isOneStepConfig returns true if the Tailscale node should always be ran and
|
||||
// configured in a single step by running 'tailscaled <config opts>'
|
||||
func isOneStepConfig(cfg *settings) bool {
|
||||
return cfg.TailscaledConfigFilePath != ""
|
||||
}
|
||||
|
||||
// isL3Proxy returns true if the Tailscale node needs to be configured to act
|
||||
// as an L3 proxy, proxying to an endpoint provided via one of the config env
|
||||
// vars.
|
||||
func isL3Proxy(cfg *settings) bool {
|
||||
return cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress || cfg.EgressProxiesCfgPath != ""
|
||||
}
|
||||
|
||||
// hasKubeStateStore returns true if the state must be stored in a Kubernetes
|
||||
// Secret.
|
||||
func hasKubeStateStore(cfg *settings) bool {
|
||||
return cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != ""
|
||||
}
|
||||
|
||||
func (cfg *settings) localMetricsEnabled() bool {
|
||||
return cfg.LocalAddrPort != "" && cfg.MetricsEnabled
|
||||
}
|
||||
|
||||
func (cfg *settings) localHealthEnabled() bool {
|
||||
return cfg.LocalAddrPort != "" && cfg.HealthCheckEnabled
|
||||
}
|
||||
|
||||
func (cfg *settings) egressSvcsTerminateEPEnabled() bool {
|
||||
return cfg.LocalAddrPort != "" && cfg.EgressProxiesCfgPath != ""
|
||||
}
|
||||
|
||||
// defaultEnv returns the value of the given envvar name, or defVal if
|
||||
// unset.
|
||||
func defaultEnv(name, defVal string) string {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
|
||||
// defaultEnvStringPointer returns a pointer to the given envvar value if set, else
|
||||
// returns nil. This is useful in cases where we need to distinguish between a
|
||||
// variable being set to empty string vs unset.
|
||||
func defaultEnvStringPointer(name string) *string {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultEnvBoolPointer returns a pointer to the given envvar value if set, else
|
||||
// returns nil. This is useful in cases where we need to distinguish between a
|
||||
// variable being explicitly set to false vs unset.
|
||||
func defaultEnvBoolPointer(name string) *bool {
|
||||
v := os.Getenv(name)
|
||||
ret, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &ret
|
||||
}
|
||||
|
||||
func defaultEnvs(names []string, defVal string) string {
|
||||
for _, name := range names {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
|
||||
// defaultBool returns the boolean value of the given envvar name, or
|
||||
// defVal if unset or not a bool.
|
||||
func defaultBool(name string, defVal bool) bool {
|
||||
v := os.Getenv(name)
|
||||
ret, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return defVal
|
||||
}
|
||||
return ret
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"tailscale.com/client/local"
|
||||
)
|
||||
|
||||
func startTailscaled(ctx context.Context, cfg *settings) (*local.Client, *os.Process, error) {
|
||||
args := tailscaledArgs(cfg)
|
||||
// tailscaled runs without context, since it needs to persist
|
||||
// beyond the startup timeout in ctx.
|
||||
cmd := exec.Command("tailscaled", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
log.Printf("Starting tailscaled")
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, nil, fmt.Errorf("starting tailscaled failed: %v", err)
|
||||
}
|
||||
|
||||
// Wait for the socket file to appear, otherwise API ops will racily fail.
|
||||
log.Printf("Waiting for tailscaled socket")
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil, nil, errors.New("timed out waiting for tailscaled socket")
|
||||
}
|
||||
_, err := os.Stat(cfg.Socket)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, nil, fmt.Errorf("error waiting for tailscaled socket: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
tsClient := &local.Client{
|
||||
Socket: cfg.Socket,
|
||||
UseSocketOnly: true,
|
||||
}
|
||||
|
||||
return tsClient, cmd.Process, nil
|
||||
}
|
||||
|
||||
// tailscaledArgs uses cfg to construct the argv for tailscaled.
|
||||
func tailscaledArgs(cfg *settings) []string {
|
||||
args := []string{"--socket=" + cfg.Socket}
|
||||
switch {
|
||||
case cfg.InKubernetes && cfg.KubeSecret != "":
|
||||
args = append(args, "--state=kube:"+cfg.KubeSecret)
|
||||
if cfg.StateDir == "" {
|
||||
cfg.StateDir = "/tmp"
|
||||
}
|
||||
fallthrough
|
||||
case cfg.StateDir != "":
|
||||
args = append(args, "--statedir="+cfg.StateDir)
|
||||
default:
|
||||
args = append(args, "--state=mem:", "--statedir=/tmp")
|
||||
}
|
||||
|
||||
if cfg.UserspaceMode {
|
||||
args = append(args, "--tun=userspace-networking")
|
||||
} else if err := ensureTunFile(cfg.Root); err != nil {
|
||||
log.Fatalf("ensuring that /dev/net/tun exists: %v", err)
|
||||
}
|
||||
|
||||
if cfg.SOCKSProxyAddr != "" {
|
||||
args = append(args, "--socks5-server="+cfg.SOCKSProxyAddr)
|
||||
}
|
||||
if cfg.HTTPProxyAddr != "" {
|
||||
args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr)
|
||||
}
|
||||
if cfg.TailscaledConfigFilePath != "" {
|
||||
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
|
||||
}
|
||||
// Once enough proxy versions have been released for all the supported
|
||||
// versions to understand this cfg setting, the operator can stop
|
||||
// setting TS_TAILSCALED_EXTRA_ARGS for the debug flag.
|
||||
if cfg.DebugAddrPort != "" && !strings.Contains(cfg.DaemonExtraArgs, cfg.DebugAddrPort) {
|
||||
args = append(args, "--debug="+cfg.DebugAddrPort)
|
||||
}
|
||||
if cfg.DaemonExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// tailscaleUp uses cfg to run 'tailscale up' everytime containerboot starts, or
|
||||
// if TS_AUTH_ONCE is set, only the first time containerboot starts.
|
||||
func tailscaleUp(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "up"}
|
||||
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
|
||||
args = append(args, "--accept-dns=true")
|
||||
} else {
|
||||
args = append(args, "--accept-dns=false")
|
||||
}
|
||||
if cfg.AuthKey != "" {
|
||||
args = append(args, "--authkey="+cfg.AuthKey)
|
||||
}
|
||||
// --advertise-routes can be passed an empty string to configure a
|
||||
// device (that might have previously advertised subnet routes) to not
|
||||
// advertise any routes. Respect an empty string passed by a user and
|
||||
// use it to explicitly unset the routes.
|
||||
if cfg.Routes != nil {
|
||||
args = append(args, "--advertise-routes="+*cfg.Routes)
|
||||
}
|
||||
if cfg.Hostname != "" {
|
||||
args = append(args, "--hostname="+cfg.Hostname)
|
||||
}
|
||||
if cfg.ExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.ExtraArgs)...)
|
||||
}
|
||||
log.Printf("Running 'tailscale up'")
|
||||
cmd := exec.CommandContext(ctx, "tailscale", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tailscale up failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tailscaleSet uses cfg to run 'tailscale set' to set any known configuration
|
||||
// options that are passed in via environment variables. This is run after the
|
||||
// node is in Running state and only if TS_AUTH_ONCE is set.
|
||||
func tailscaleSet(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "set"}
|
||||
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
|
||||
args = append(args, "--accept-dns=true")
|
||||
} else {
|
||||
args = append(args, "--accept-dns=false")
|
||||
}
|
||||
// --advertise-routes can be passed an empty string to configure a
|
||||
// device (that might have previously advertised subnet routes) to not
|
||||
// advertise any routes. Respect an empty string passed by a user and
|
||||
// use it to explicitly unset the routes.
|
||||
if cfg.Routes != nil {
|
||||
args = append(args, "--advertise-routes="+*cfg.Routes)
|
||||
}
|
||||
if cfg.Hostname != "" {
|
||||
args = append(args, "--hostname="+cfg.Hostname)
|
||||
}
|
||||
log.Printf("Running 'tailscale set'")
|
||||
cmd := exec.CommandContext(ctx, "tailscale", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tailscale set failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func watchTailscaledConfigChanges(ctx context.Context, path string, lc *local.Client, errCh chan<- error) {
|
||||
var (
|
||||
tickChan <-chan time.Time
|
||||
tailscaledCfgDir = filepath.Dir(path)
|
||||
prevTailscaledCfg []byte
|
||||
)
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Printf("tailscaled config watch: failed to create fsnotify watcher, timer-only mode: %v", err)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
tickChan = ticker.C
|
||||
} else {
|
||||
defer w.Close()
|
||||
if err := w.Add(tailscaledCfgDir); err != nil {
|
||||
errCh <- fmt.Errorf("failed to add fsnotify watch: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("error reading configfile: %w", err)
|
||||
return
|
||||
}
|
||||
prevTailscaledCfg = b
|
||||
// kubelet mounts Secrets to Pods using a series of symlinks, one of
|
||||
// which is <mount-dir>/..data that Kubernetes recommends consumers to
|
||||
// use if they need to monitor changes
|
||||
// https://github.com/kubernetes/kubernetes/blob/v1.28.1/pkg/volume/util/atomic_writer.go#L39-L61
|
||||
const kubeletMountedCfg = "..data"
|
||||
toWatch := filepath.Join(tailscaledCfgDir, kubeletMountedCfg)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case err := <-w.Errors:
|
||||
errCh <- fmt.Errorf("watcher error: %w", err)
|
||||
return
|
||||
case <-tickChan:
|
||||
case event := <-w.Events:
|
||||
if event.Name != toWatch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("error reading configfile: %w", err)
|
||||
return
|
||||
}
|
||||
// For some proxy types the mounted volume also contains tailscaled state and other files. We
|
||||
// don't want to reload config unnecessarily on unrelated changes to these files.
|
||||
if reflect.DeepEqual(b, prevTailscaledCfg) {
|
||||
continue
|
||||
}
|
||||
prevTailscaledCfg = b
|
||||
log.Printf("tailscaled config watch: ensuring that config is up to date")
|
||||
ok, err := lc.ReloadConfig(ctx)
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("error reloading tailscaled config: %w", err)
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
log.Printf("tailscaled config watch: config was reloaded")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
# DERP
|
||||
|
||||
This is the code for the [Tailscale DERP server](https://tailscale.com/kb/1232/derp-servers).
|
||||
|
||||
In general, you should not need to or want to run this code. The overwhelming
|
||||
majority of Tailscale users (both individuals and companies) do not.
|
||||
|
||||
In the happy path, Tailscale establishes direct connections between peers and
|
||||
data plane traffic flows directly between them, without using DERP for more than
|
||||
acting as a low bandwidth side channel to bootstrap the NAT traversal. If you
|
||||
find yourself wanting DERP for more bandwidth, the real problem is usually the
|
||||
network configuration of your Tailscale node(s), making sure that Tailscale can
|
||||
get direction connections via some mechanism.
|
||||
|
||||
If you've decided or been advised to run your own `derper`, then read on.
|
||||
|
||||
## Caveats
|
||||
|
||||
* Node sharing and other cross-Tailnet features don't work when using custom
|
||||
DERP servers.
|
||||
|
||||
* DERP servers only see encrypted WireGuard packets and thus are not useful for
|
||||
network-level debugging.
|
||||
|
||||
* The Tailscale control plane does certain geo-level steering features and
|
||||
optimizations that are not available when using custom DERP servers.
|
||||
|
||||
## Guide to running `cmd/derper`
|
||||
|
||||
* You must build and update the `cmd/derper` binary yourself. There are no
|
||||
packages. Use `go install tailscale.com/cmd/derper@latest` with the latest
|
||||
version of Go. You should update this binary approximately as regularly as
|
||||
you update Tailscale nodes. If using `--verify-clients`, the `derper` binary
|
||||
and `tailscaled` binary on the machine must be built from the same git revision.
|
||||
(It might work otherwise, but they're developed and only tested together.)
|
||||
|
||||
* The DERP protocol does a protocol switch inside TLS from HTTP to a custom
|
||||
bidirectional binary protocol. It is thus incompatible with many HTTP proxies.
|
||||
Do not put `derper` behind another HTTP proxy.
|
||||
|
||||
* The `tailscaled` client does its own selection of the fastest/nearest DERP
|
||||
server based on latency measurements. Do not put `derper` behind a global load
|
||||
balancer.
|
||||
|
||||
* DERP servers should ideally have both a static IPv4 and static IPv6 address.
|
||||
Both of those should be listed in the DERP map so the client doesn't need to
|
||||
rely on its DNS which might be broken and dependent on DERP to get back up.
|
||||
|
||||
* A DERP server should not share an IP address with any other DERP server.
|
||||
|
||||
* Avoid having multiple DERP nodes in a region. If you must, they all need to be
|
||||
meshed with each other and monitored. Having two one-node "regions" in the
|
||||
same datacenter is usually easier and more reliable than meshing, at the cost
|
||||
of more required connections from clients in some cases. If your clients
|
||||
aren't mobile (battery constrained), one node regions are definitely
|
||||
preferred. If you really need multiple nodes in a region for HA reasons, two
|
||||
is sufficient.
|
||||
|
||||
* Monitor your DERP servers with [`cmd/derpprobe`](../derpprobe/).
|
||||
|
||||
* If using `--verify-clients`, a `tailscaled` must be running alongside the
|
||||
`derper`, and all clients must be visible to the derper tailscaled in the ACL.
|
||||
|
||||
* If using `--verify-clients`, a `tailscaled` must also be running alongside
|
||||
your `derpprobe`, and `derpprobe` needs to use `--derp-map=local`.
|
||||
|
||||
* The firewall on the `derper` should permit TCP ports 80 and 443 and UDP port
|
||||
3478.
|
||||
|
||||
* Only LetsEncrypt certs are rotated automatically. Other cert updates require a
|
||||
restart.
|
||||
|
||||
* Don't use a firewall in front of `derper` that suppresses `RST`s upon
|
||||
receiving traffic to a dead or unknown connection.
|
||||
|
||||
* Don't rate-limit UDP STUN packets.
|
||||
|
||||
* Don't rate-limit outbound TCP traffic (only inbound).
|
||||
|
||||
## Diagnostics
|
||||
|
||||
This is not a complete guide on DERP diagnostics.
|
||||
|
||||
Running your own DERP services requires exeprtise in multi-layer network and
|
||||
application diagnostics. As the DERP runs multiple protocols at multiple layers
|
||||
and is not a regular HTTP(s) server you will need expertise in correlative
|
||||
analysis to diagnose the most tricky problems. There is no "plain text" or
|
||||
"open" mode of operation for DERP.
|
||||
|
||||
* The debug handler is accessible at URL path `/debug/`. It is only accessible
|
||||
over localhost or from a Tailscale IP address.
|
||||
|
||||
* Go pprof can be accessed via the debug handler at `/debug/pprof/`
|
||||
|
||||
* Prometheus compatible metrics can be gathered from the debug handler at
|
||||
`/debug/varz`.
|
||||
|
||||
* `cmd/stunc` in the Tailscale repository provides a basic tool for diagnosing
|
||||
issues with STUN.
|
||||
|
||||
* `cmd/derpprobe` provides a service for monitoring DERP cluster health.
|
||||
|
||||
* `tailscale debug derp` and `tailscale netcheck` provide additional client
|
||||
driven diagnostic information for DERP communications.
|
||||
|
||||
* Tailscale logs may provide insight for certain problems, such as if DERPs are
|
||||
unreachable or peers are regularly not reachable in their DERP home regions.
|
||||
There are many possible misconfiguration causes for these problems, but
|
||||
regular log entries are a good first indicator that there is a problem.
|
||||
@@ -5,45 +5,35 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"expvar"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/slicesx"
|
||||
)
|
||||
|
||||
const refreshTimeout = time.Minute
|
||||
|
||||
type dnsEntryMap struct {
|
||||
IPs map[string][]net.IP
|
||||
Percent map[string]float64 // "foo.com" => 0.5 for 50%
|
||||
}
|
||||
type dnsEntryMap map[string][]net.IP
|
||||
|
||||
var (
|
||||
dnsCache atomic.Pointer[dnsEntryMap]
|
||||
dnsCache syncs.AtomicValue[dnsEntryMap]
|
||||
dnsCacheBytes syncs.AtomicValue[[]byte] // of JSON
|
||||
unpublishedDNSCache atomic.Pointer[dnsEntryMap]
|
||||
unpublishedDNSCache syncs.AtomicValue[dnsEntryMap]
|
||||
bootstrapLookupMap syncs.Map[string, bool]
|
||||
)
|
||||
|
||||
var (
|
||||
bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
|
||||
publishedDNSHits = expvar.NewInt("counter_bootstrap_dns_published_hits")
|
||||
publishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_published_misses")
|
||||
unpublishedDNSHits = expvar.NewInt("counter_bootstrap_dns_unpublished_hits")
|
||||
unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses")
|
||||
unpublishedDNSPercentMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_percent_misses")
|
||||
bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
|
||||
publishedDNSHits = expvar.NewInt("counter_bootstrap_dns_published_hits")
|
||||
publishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_published_misses")
|
||||
unpublishedDNSHits = expvar.NewInt("counter_bootstrap_dns_unpublished_hits")
|
||||
unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses")
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -69,13 +59,15 @@ func refreshBootstrapDNS() {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
|
||||
defer cancel()
|
||||
dnsEntries := resolveList(ctx, *bootstrapDNS)
|
||||
dnsEntries := resolveList(ctx, strings.Split(*bootstrapDNS, ","))
|
||||
// Randomize the order of the IPs for each name to avoid the client biasing
|
||||
// to IPv6
|
||||
for _, vv := range dnsEntries.IPs {
|
||||
slicesx.Shuffle(vv)
|
||||
for k := range dnsEntries {
|
||||
ips := dnsEntries[k]
|
||||
slicesx.Shuffle(ips)
|
||||
dnsEntries[k] = ips
|
||||
}
|
||||
j, err := json.MarshalIndent(dnsEntries.IPs, "", "\t")
|
||||
j, err := json.MarshalIndent(dnsEntries, "", "\t")
|
||||
if err != nil {
|
||||
// leave the old values in place
|
||||
return
|
||||
@@ -89,50 +81,27 @@ func refreshUnpublishedDNS() {
|
||||
if *unpublishedDNS == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
|
||||
defer cancel()
|
||||
dnsEntries := resolveList(ctx, *unpublishedDNS)
|
||||
|
||||
dnsEntries := resolveList(ctx, strings.Split(*unpublishedDNS, ","))
|
||||
unpublishedDNSCache.Store(dnsEntries)
|
||||
}
|
||||
|
||||
// resolveList takes a comma-separated list of DNS names to resolve.
|
||||
//
|
||||
// If an entry contains a slash, it's two DNS names: the first is the one to
|
||||
// resolve and the second is that of a TXT recording containing the rollout
|
||||
// percentage in range "0".."100". If the TXT record doesn't exist or is
|
||||
// malformed, the percentage is 0. If the TXT record is not provided (there's no
|
||||
// slash), then the percentage is 100.
|
||||
func resolveList(ctx context.Context, list string) *dnsEntryMap {
|
||||
ents := strings.Split(list, ",")
|
||||
|
||||
ret := &dnsEntryMap{}
|
||||
func resolveList(ctx context.Context, names []string) dnsEntryMap {
|
||||
dnsEntries := make(dnsEntryMap)
|
||||
|
||||
var r net.Resolver
|
||||
for _, ent := range ents {
|
||||
name, txtName, _ := strings.Cut(ent, "/")
|
||||
for _, name := range names {
|
||||
addrs, err := r.LookupIP(ctx, "ip", name)
|
||||
if err != nil {
|
||||
log.Printf("bootstrap DNS lookup %q: %v", name, err)
|
||||
continue
|
||||
}
|
||||
mak.Set(&ret.IPs, name, addrs)
|
||||
|
||||
if txtName == "" {
|
||||
mak.Set(&ret.Percent, name, 1.0)
|
||||
continue
|
||||
}
|
||||
vals, err := r.LookupTXT(ctx, txtName)
|
||||
if err != nil {
|
||||
log.Printf("bootstrap DNS lookup %q: %v", txtName, err)
|
||||
continue
|
||||
}
|
||||
for _, v := range vals {
|
||||
if v, err := strconv.Atoi(v); err == nil && v >= 0 && v <= 100 {
|
||||
mak.Set(&ret.Percent, name, float64(v)/100)
|
||||
}
|
||||
}
|
||||
dnsEntries[name] = addrs
|
||||
}
|
||||
return ret
|
||||
return dnsEntries
|
||||
}
|
||||
|
||||
func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -146,36 +115,22 @@ func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
|
||||
// Try answering a query from our hidden map first
|
||||
if q := r.URL.Query().Get("q"); q != "" {
|
||||
bootstrapLookupMap.Store(q, true)
|
||||
if bootstrapLookupMap.Len() > 500 { // defensive
|
||||
bootstrapLookupMap.Clear()
|
||||
}
|
||||
if m := unpublishedDNSCache.Load(); m != nil && len(m.IPs[q]) > 0 {
|
||||
if ips, ok := unpublishedDNSCache.Load()[q]; ok && len(ips) > 0 {
|
||||
unpublishedDNSHits.Add(1)
|
||||
|
||||
percent := m.Percent[q]
|
||||
if remoteAddrMatchesPercent(r.RemoteAddr, percent) {
|
||||
// Only return the specific query, not everything.
|
||||
m := map[string][]net.IP{q: m.IPs[q]}
|
||||
j, err := json.MarshalIndent(m, "", "\t")
|
||||
if err == nil {
|
||||
w.Write(j)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
unpublishedDNSPercentMisses.Add(1)
|
||||
// Only return the specific query, not everything.
|
||||
m := dnsEntryMap{q: ips}
|
||||
j, err := json.MarshalIndent(m, "", "\t")
|
||||
if err == nil {
|
||||
w.Write(j)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a "q" query for a name in the published cache
|
||||
// list, then track whether that's a hit/miss.
|
||||
m := dnsCache.Load()
|
||||
var inPub bool
|
||||
var ips []net.IP
|
||||
if m != nil {
|
||||
ips, inPub = m.IPs[q]
|
||||
}
|
||||
if inPub {
|
||||
if len(ips) > 0 {
|
||||
if m, ok := dnsCache.Load()[q]; ok {
|
||||
if len(m) > 0 {
|
||||
publishedDNSHits.Add(1)
|
||||
} else {
|
||||
publishedDNSMisses.Add(1)
|
||||
@@ -191,29 +146,3 @@ func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
|
||||
j := dnsCacheBytes.Load()
|
||||
w.Write(j)
|
||||
}
|
||||
|
||||
// percent is [0.0, 1.0].
|
||||
func remoteAddrMatchesPercent(remoteAddr string, percent float64) bool {
|
||||
if percent == 0 {
|
||||
return false
|
||||
}
|
||||
if percent == 1 {
|
||||
return true
|
||||
}
|
||||
reqIPStr, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
reqIP, err := netip.ParseAddr(reqIPStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if reqIP.IsLoopback() {
|
||||
// For local testing.
|
||||
return rand.Float64() < 0.5
|
||||
}
|
||||
reqIP16 := reqIP.As16()
|
||||
rndSrc := rand.NewPCG(binary.LittleEndian.Uint64(reqIP16[:8]), binary.LittleEndian.Uint64(reqIP16[8:]))
|
||||
rnd := rand.New(rndSrc)
|
||||
return percent > rnd.Float64()
|
||||
}
|
||||
|
||||
@@ -4,26 +4,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/tstest/nettest"
|
||||
)
|
||||
|
||||
func BenchmarkHandleBootstrapDNS(b *testing.B) {
|
||||
tstest.Replace(b, bootstrapDNS, "log.tailscale.com,login.tailscale.com,controlplane.tailscale.com,login.us.tailscale.com")
|
||||
tstest.Replace(b, bootstrapDNS, "log.tailscale.io,login.tailscale.com,controlplane.tailscale.com,login.us.tailscale.com")
|
||||
refreshBootstrapDNS()
|
||||
w := new(bitbucketResponseWriter)
|
||||
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape("log.tailscale.com"), nil)
|
||||
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape("log.tailscale.io"), nil)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(b *testing.PB) {
|
||||
@@ -41,7 +37,7 @@ func (b *bitbucketResponseWriter) Write(p []byte) (int, error) { return len(p),
|
||||
|
||||
func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {}
|
||||
|
||||
func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP {
|
||||
func getBootstrapDNS(t *testing.T, q string) dnsEntryMap {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -51,19 +47,16 @@ func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP {
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("got status=%d; want %d", res.StatusCode, 200)
|
||||
}
|
||||
var m map[string][]net.IP
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewDecoder(io.TeeReader(res.Body, &buf)).Decode(&m); err != nil {
|
||||
t.Fatalf("error decoding response body %q: %v", buf.Bytes(), err)
|
||||
var ips dnsEntryMap
|
||||
if err := json.NewDecoder(res.Body).Decode(&ips); err != nil {
|
||||
t.Fatalf("error decoding response body: %v", err)
|
||||
}
|
||||
return m
|
||||
return ips
|
||||
}
|
||||
|
||||
func TestUnpublishedDNS(t *testing.T) {
|
||||
nettest.SkipIfNoNetwork(t)
|
||||
|
||||
const published = "login.tailscale.com"
|
||||
const unpublished = "log.tailscale.com"
|
||||
const unpublished = "log.tailscale.io"
|
||||
|
||||
prev1, prev2 := *bootstrapDNS, *unpublishedDNS
|
||||
*bootstrapDNS = published
|
||||
@@ -111,32 +104,26 @@ func resetMetrics() {
|
||||
// Verify that we don't count an empty list in the unpublishedDNSCache as a
|
||||
// cache hit in our metrics.
|
||||
func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
pub := &dnsEntryMap{
|
||||
IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}},
|
||||
pub := dnsEntryMap{
|
||||
"tailscale.com": {net.IPv4(10, 10, 10, 10)},
|
||||
}
|
||||
dnsCache.Store(pub)
|
||||
dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`))
|
||||
|
||||
unpublishedDNSCache.Store(&dnsEntryMap{
|
||||
IPs: map[string][]net.IP{
|
||||
"log.tailscale.com": {},
|
||||
"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)},
|
||||
},
|
||||
Percent: map[string]float64{
|
||||
"log.tailscale.com": 1.0,
|
||||
"controlplane.tailscale.com": 1.0,
|
||||
},
|
||||
unpublishedDNSCache.Store(dnsEntryMap{
|
||||
"log.tailscale.io": {},
|
||||
"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)},
|
||||
})
|
||||
|
||||
t.Run("CacheMiss", func(t *testing.T) {
|
||||
// One domain in map but empty, one not in map at all
|
||||
for _, q := range []string{"log.tailscale.com", "login.tailscale.com"} {
|
||||
for _, q := range []string{"log.tailscale.io", "login.tailscale.com"} {
|
||||
resetMetrics()
|
||||
ips := getBootstrapDNS(t, q)
|
||||
|
||||
// Expected our public map to be returned on a cache miss
|
||||
if !reflect.DeepEqual(ips, pub.IPs) {
|
||||
t.Errorf("got ips=%+v; want %+v", ips, pub.IPs)
|
||||
if !reflect.DeepEqual(ips, pub) {
|
||||
t.Errorf("got ips=%+v; want %+v", ips, pub)
|
||||
}
|
||||
if v := unpublishedDNSHits.Value(); v != 0 {
|
||||
t.Errorf("got hits=%d; want 0", v)
|
||||
@@ -151,7 +138,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
t.Run("CacheHit", func(t *testing.T) {
|
||||
resetMetrics()
|
||||
ips := getBootstrapDNS(t, "controlplane.tailscale.com")
|
||||
want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
|
||||
want := dnsEntryMap{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
|
||||
if !reflect.DeepEqual(ips, want) {
|
||||
t.Errorf("got ips=%+v; want %+v", ips, want)
|
||||
}
|
||||
@@ -176,54 +163,3 @@ func TestLookupMetric(t *testing.T) {
|
||||
t.Errorf("bootstrapLookupMap.Len() want=5, got %v", bootstrapLookupMap.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteAddrMatchesPercent(t *testing.T) {
|
||||
tests := []struct {
|
||||
remoteAddr string
|
||||
percent float64
|
||||
want bool
|
||||
}{
|
||||
// 0% and 100%.
|
||||
{"10.0.0.1:1234", 0.0, false},
|
||||
{"10.0.0.1:1234", 1.0, true},
|
||||
|
||||
// Invalid IP.
|
||||
{"", 1.0, true},
|
||||
{"", 0.0, false},
|
||||
{"", 0.5, false},
|
||||
|
||||
// Small manual sample at 50%. The func uses a deterministic PRNG seed.
|
||||
{"1.2.3.4:567", 0.5, true},
|
||||
{"1.2.3.5:567", 0.5, true},
|
||||
{"1.2.3.6:567", 0.5, false},
|
||||
{"1.2.3.7:567", 0.5, true},
|
||||
{"1.2.3.8:567", 0.5, false},
|
||||
{"1.2.3.9:567", 0.5, true},
|
||||
{"1.2.3.10:567", 0.5, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := remoteAddrMatchesPercent(tt.remoteAddr, tt.percent)
|
||||
if got != tt.want {
|
||||
t.Errorf("remoteAddrMatchesPercent(%q, %v) = %v; want %v", tt.remoteAddr, tt.percent, got, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
var match, all int
|
||||
const wantPercent = 0.5
|
||||
for a := range 256 {
|
||||
for b := range 256 {
|
||||
all++
|
||||
if remoteAddrMatchesPercent(
|
||||
netip.AddrPortFrom(netip.AddrFrom4([4]byte{1, 2, byte(a), byte(b)}), 12345).String(),
|
||||
wantPercent) {
|
||||
match++
|
||||
}
|
||||
}
|
||||
}
|
||||
gotPercent := float64(match) / float64(all)
|
||||
const tolerance = 0.005
|
||||
t.Logf("got percent %v (goal %v)", gotPercent, wantPercent)
|
||||
if gotPercent < wantPercent-tolerance || gotPercent > wantPercent+tolerance {
|
||||
t.Errorf("got %v; want %v ± %v", gotPercent, wantPercent, tolerance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -54,9 +53,8 @@ func certProviderByCertMode(mode, dir, hostname string) (certProvider, error) {
|
||||
}
|
||||
|
||||
type manualCertManager struct {
|
||||
cert *tls.Certificate
|
||||
hostname string // hostname or IP address of server
|
||||
noHostname bool // whether hostname is an IP address
|
||||
cert *tls.Certificate
|
||||
hostname string
|
||||
}
|
||||
|
||||
// NewManualCertManager returns a cert provider which read certificate by given hostname on create.
|
||||
@@ -76,11 +74,7 @@ func NewManualCertManager(certdir, hostname string) (certProvider, error) {
|
||||
if err := x509Cert.VerifyHostname(hostname); err != nil {
|
||||
return nil, fmt.Errorf("cert invalid for hostname %q: %w", hostname, err)
|
||||
}
|
||||
return &manualCertManager{
|
||||
cert: &cert,
|
||||
hostname: hostname,
|
||||
noHostname: net.ParseIP(hostname) != nil,
|
||||
}, nil
|
||||
return &manualCertManager{cert: &cert, hostname: hostname}, nil
|
||||
}
|
||||
|
||||
func (m *manualCertManager) TLSConfig() *tls.Config {
|
||||
@@ -94,7 +88,7 @@ func (m *manualCertManager) TLSConfig() *tls.Config {
|
||||
}
|
||||
|
||||
func (m *manualCertManager) getCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if hi.ServerName != m.hostname && !m.noHostname {
|
||||
if hi.ServerName != m.hostname {
|
||||
return nil, fmt.Errorf("cert mismatch with hostname: %q", hi.ServerName)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user