Compare commits
1 Commits
docker_sta
...
marwan/dis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8092eaed80 |
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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build checklocks
|
||||
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
|
||||
|
||||
- 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
# Install a more recent Go that understands modern go.mod content.
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.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@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
|
||||
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@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
|
||||
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@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
|
||||
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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- uses: "actions/checkout@v4"
|
||||
with:
|
||||
ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}"
|
||||
- uses: "DeterminateSystems/nix-installer-action@main"
|
||||
|
||||
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
|
||||
10
.github/workflows/golangci-lint.yml
vendored
10
.github/workflows/golangci-lint.yml
vendored
@@ -23,18 +23,18 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: golangci-lint
|
||||
# Note: this is the 'v6.1.0' tag as of 2024-08-21
|
||||
uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86
|
||||
# Note: this is the 'v3' tag as of 2023-08-14
|
||||
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
|
||||
with:
|
||||
version: v1.60
|
||||
version: v1.54.2
|
||||
|
||||
# Show only new issues if it's a pull request.
|
||||
only-new-issues: true
|
||||
|
||||
37
.github/workflows/govulncheck.yml
vendored
37
.github/workflows/govulncheck.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install govulncheck
|
||||
run: ./tool/go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@@ -22,30 +22,17 @@ jobs:
|
||||
- name: Scan source code for known vulnerabilities
|
||||
run: PATH=$PWD/tool/:$PATH "$(./tool/go env GOPATH)/bin/govulncheck" -test ./...
|
||||
|
||||
- name: Post to slack
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.GOVULNCHECK_BOT_TOKEN }}
|
||||
- uses: ruby/action-slack@v3.2.1
|
||||
with:
|
||||
channel-id: 'C05PXRM304B'
|
||||
payload: |
|
||||
payload: >
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "Govulncheck failed in ${{ github.repository }}"
|
||||
},
|
||||
"accessory": {
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View results"
|
||||
},
|
||||
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
"attachments": [{
|
||||
"title": "${{ job.status }}: ${{ github.workflow }}",
|
||||
"title_link": "https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks",
|
||||
"text": "${{ github.repository }}@${{ github.sha }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
|
||||
11
.github/workflows/installer.yml
vendored
11
.github/workflows/installer.yml
vendored
@@ -32,6 +32,7 @@ jobs:
|
||||
- "ubuntu:18.04"
|
||||
- "ubuntu:20.04"
|
||||
- "ubuntu:22.04"
|
||||
- "ubuntu:22.10"
|
||||
- "ubuntu:23.04"
|
||||
- "elementary/docker:stable"
|
||||
- "elementary/docker:unstable"
|
||||
@@ -67,11 +68,6 @@ jobs:
|
||||
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 }}
|
||||
@@ -95,10 +91,7 @@ jobs:
|
||||
|| contains(matrix.image, 'parrotsec')
|
||||
|| contains(matrix.image, 'kalilinux')
|
||||
- name: checkout
|
||||
# We cannot use v4, as it requires a newer glibc version than some of the
|
||||
# tested images provide. See
|
||||
# https://github.com/actions/checkout/issues/1487
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
uses: actions/checkout@v4
|
||||
- name: run installer
|
||||
run: scripts/installer.sh
|
||||
# Package installation can fail in docker because systemd is not running
|
||||
|
||||
11
.github/workflows/kubemanifests.yaml
vendored
11
.github/workflows/kubemanifests.yaml
vendored
@@ -2,8 +2,7 @@ name: "Kubernetes manifests"
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cmd/k8s-operator/**'
|
||||
- 'k8s-operator/**'
|
||||
- './cmd/k8s-operator/'
|
||||
- '.github/workflows/kubemanifests.yaml'
|
||||
|
||||
# Cancel workflow run if there is a newer push to the same PR for which it is
|
||||
@@ -17,15 +16,9 @@ jobs:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: Build and lint Helm chart
|
||||
run: |
|
||||
eval `./tool/go run ./cmd/mkversion`
|
||||
./tool/helm package --app-version="${VERSION_SHORT}" --version=${VERSION_SHORT} './cmd/k8s-operator/deploy/chart'
|
||||
./tool/helm lint "tailscale-operator-${VERSION_SHORT}.tgz"
|
||||
- name: Verify that static manifests are up to date
|
||||
run: |
|
||||
make kube-generate-all
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "Generated files for Tailscale Kubernetes operator are out of date. Please run 'make kube-generate-all' and commit the diff."; exit 1)
|
||||
|
||||
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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Run SSH integration tests
|
||||
run: |
|
||||
make sshintegrationtest
|
||||
118
.github/workflows/test.yml
vendored
118
.github/workflows/test.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
- shard: '4/4'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: build test wrapper
|
||||
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
|
||||
- name: integration tests as root
|
||||
@@ -78,9 +78,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@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
|
||||
@@ -150,16 +150,16 @@ jobs:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@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
|
||||
@@ -183,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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: Run VM tests
|
||||
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
|
||||
env:
|
||||
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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: build all
|
||||
run: ./tool/go install -race ./cmd/...
|
||||
- name: build tests
|
||||
@@ -254,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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
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
|
||||
@@ -295,54 +285,13 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: build some
|
||||
run: ./tool/go build ./ipn/... ./wgengine/ ./types/... ./control/controlclient
|
||||
env:
|
||||
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
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
# Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed
|
||||
# and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch
|
||||
# some Android breakages early.
|
||||
# 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: test tailscale_go
|
||||
run: ./tool/go test -tags=tailscale_go,ts_enable_sockstats ./net/sockstats/...
|
||||
|
||||
@@ -456,22 +405,18 @@ jobs:
|
||||
fuzz-seconds: 300
|
||||
dry-run: false
|
||||
language: go
|
||||
- name: Set artifacts_path in env (workaround for actions/upload-artifact#176)
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
run: |
|
||||
echo "artifacts_path=$(realpath .)" >> $GITHUB_ENV
|
||||
- name: upload crash
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
uses: actions/upload-artifact@v3
|
||||
if: steps.run.outcome != 'success' && steps.build.outcome == 'success'
|
||||
with:
|
||||
name: artifacts
|
||||
path: ${{ env.artifacts_path }}/out/artifacts
|
||||
path: ./out/artifacts
|
||||
|
||||
depaware:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: check depaware
|
||||
run: |
|
||||
export PATH=$(./tool/go env GOROOT)/bin:$PATH
|
||||
@@ -481,10 +426,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go generate' is clean
|
||||
run: |
|
||||
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator|xdp')
|
||||
pkgs=$(./tool/go list ./... | grep -v dnsfallback)
|
||||
./tool/go generate $pkgs
|
||||
echo
|
||||
echo
|
||||
@@ -494,7 +439,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go mod tidy' is clean
|
||||
run: |
|
||||
./tool/go mod tidy
|
||||
@@ -506,7 +451,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: check licenses
|
||||
run: ./scripts/check_license_headers.sh .
|
||||
|
||||
@@ -522,7 +467,7 @@ jobs:
|
||||
goarch: "386"
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
- name: install staticcheck
|
||||
run: GOBIN=~/.local/bin ./tool/go install honnef.co/go/tools/cmd/staticcheck
|
||||
- name: run staticcheck
|
||||
@@ -563,7 +508,7 @@ jobs:
|
||||
# By having the job always run, but skipping its only step as needed, we
|
||||
# let the CI output collapse nicely in PRs.
|
||||
if: failure() && github.event_name == 'push'
|
||||
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
|
||||
uses: ruby/action-slack@v3.2.1
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
@@ -578,7 +523,6 @@ jobs:
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
|
||||
check_mergeability:
|
||||
if: always()
|
||||
@@ -601,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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run update-flakes
|
||||
run: ./update-flake.sh
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_retrieval_mode: "id"
|
||||
installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5
|
||||
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: Flakes Updater <noreply+flakes-updater@tailscale.com>
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run go get
|
||||
run: |
|
||||
@@ -23,19 +23,18 @@ jobs:
|
||||
./tool/go mod tidy
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
|
||||
id: generate-token
|
||||
with:
|
||||
# TODO(will): this should use the code updater app rather than licensing.
|
||||
# It has the same permissions, so not a big deal, but still.
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_retrieval_mode: "id"
|
||||
installation_retrieval_payload: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
id: pull-request
|
||||
uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f #v7.0.5
|
||||
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: OSS Updater <noreply+oss-updater@tailscale.com>
|
||||
|
||||
40
.github/workflows/webclient.yml
vendored
40
.github/workflows/webclient.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: webclient
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# For now, only run on requests, not the main branches.
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
paths:
|
||||
- "client/web/**"
|
||||
- ".github/workflows/webclient.yml"
|
||||
- "!**.md"
|
||||
# TODO(soniaappasamy): enable for main branch after an initial waiting period.
|
||||
#push:
|
||||
# branches:
|
||||
# - main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
webclient:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
- name: Install deps
|
||||
run: ./tool/yarn --cwd client/web
|
||||
- name: Run lint
|
||||
run: ./tool/yarn --cwd client/web run --silent lint
|
||||
- name: Run test
|
||||
run: ./tool/yarn --cwd client/web run --silent test
|
||||
- name: Run formatter check
|
||||
run: |
|
||||
./tool/yarn --cwd client/web run --silent format-check || ( \
|
||||
echo "Run this command on your local device to fix the error:" && \
|
||||
echo "" && \
|
||||
echo " ./tool/yarn --cwd client/web format" && \
|
||||
echo "" && exit 1)
|
||||
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
|
||||
|
||||
@@ -6,7 +6,6 @@ linters:
|
||||
- bidichk
|
||||
- gofmt
|
||||
- goimports
|
||||
- govet
|
||||
- misspell
|
||||
- revive
|
||||
|
||||
@@ -36,48 +35,6 @@ linters-settings:
|
||||
|
||||
goimports:
|
||||
|
||||
govet:
|
||||
# Matches what we use in corp as of 2023-12-07
|
||||
enable:
|
||||
- asmdecl
|
||||
- assign
|
||||
- atomic
|
||||
- bools
|
||||
- buildtag
|
||||
- cgocall
|
||||
- copylocks
|
||||
- deepequalerrors
|
||||
- errorsas
|
||||
- framepointer
|
||||
- httpresponse
|
||||
- ifaceassert
|
||||
- loopclosure
|
||||
- lostcancel
|
||||
- nilfunc
|
||||
- nilness
|
||||
- printf
|
||||
- reflectvaluecompare
|
||||
- shift
|
||||
- sigchanyzer
|
||||
- sortslice
|
||||
- stdmethods
|
||||
- stringintconv
|
||||
- structtag
|
||||
- testinggoroutine
|
||||
- tests
|
||||
- unmarshal
|
||||
- unreachable
|
||||
- unsafeptr
|
||||
- unusedresult
|
||||
settings:
|
||||
printf:
|
||||
# List of print function names to check (in addition to default)
|
||||
funcs:
|
||||
- github.com/tailscale/tailscale/types/logger.Discard
|
||||
# NOTE(andrew-d): this doesn't currently work because the printf
|
||||
# analyzer doesn't support type declarations
|
||||
#- github.com/tailscale/tailscale/types/logger.Logf
|
||||
|
||||
misspell:
|
||||
|
||||
revive:
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.18
|
||||
3.16
|
||||
20
Dockerfile
20
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 . .
|
||||
@@ -62,7 +66,7 @@ RUN GOARCH=$TARGETARCH go install -ldflags="\
|
||||
-X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \
|
||||
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
|
||||
|
||||
FROM alpine:3.18
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
|
||||
COPY --from=build-env /go/bin/* /usr/local/bin/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
FROM alpine:3.18
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables iputils
|
||||
|
||||
51
Makefile
51
Makefile
@@ -1,28 +1,21 @@
|
||||
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 ./...
|
||||
|
||||
tidy: ## Run go mod tidy
|
||||
./tool/go mod tidy
|
||||
|
||||
lint: ## Run golangci-lint
|
||||
./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run
|
||||
|
||||
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 \
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper \
|
||||
tailscale.com/cmd/k8s-operator \
|
||||
tailscale.com/cmd/stund
|
||||
tailscale.com/cmd/derper
|
||||
|
||||
depaware: ## Run depaware checks
|
||||
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
|
||||
@@ -30,9 +23,7 @@ depaware: ## Run depaware checks
|
||||
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
|
||||
tailscale.com/cmd/derper
|
||||
|
||||
buildwindows: ## Build tailscale CLI for windows/amd64
|
||||
GOOS=windows GOARCH=amd64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
@@ -60,21 +51,6 @@ check: staticcheck vet depaware buildwindows build386 buildlinuxarm buildwasm ##
|
||||
staticcheck: ## Run staticcheck.io checks
|
||||
./tool/go run honnef.co/go/tools/cmd/staticcheck -- $$(./tool/go list ./... | grep -v tempfork)
|
||||
|
||||
kube-generate-all: kube-generate-deepcopy ## Refresh generated files for Tailscale Kubernetes Operator
|
||||
./tool/go generate ./cmd/k8s-operator
|
||||
|
||||
# Tailscale operator watches Connector custom resources in a Kubernetes cluster
|
||||
# and caches them locally. Caching is done implicitly by controller-runtime
|
||||
# library (the middleware used by Tailscale operator to create kube control
|
||||
# loops). When a Connector resource is GET/LIST-ed from within our control loop,
|
||||
# the request goes through the cache. To ensure that cache contents don't get
|
||||
# modified by control loops, controller-runtime deep copies the requested
|
||||
# object. In order for this to work, Connector must implement deep copy
|
||||
# functionality so we autogenerate it here.
|
||||
# https://github.com/kubernetes-sigs/controller-runtime/blob/v0.16.3/pkg/cache/internal/cache_reader.go#L86-L89
|
||||
kube-generate-deepcopy: ## Refresh generated deepcopy functionality for Tailscale kube API types
|
||||
./scripts/kube-deepcopy.sh
|
||||
|
||||
spk: ## Build synology package for ${SYNO_ARCH} architecture and ${SYNO_DSM} DSM version
|
||||
./tool/go run ./cmd/dist build synology/dsm${SYNO_DSM}/${SYNO_ARCH}
|
||||
|
||||
@@ -92,7 +68,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 +76,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.)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.79.0
|
||||
1.55.0
|
||||
|
||||
@@ -10,123 +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
|
||||
@@ -142,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()
|
||||
|
||||
@@ -275,80 +90,13 @@ 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", xmaps.Keys(oldDomains), toRemove, err)
|
||||
}
|
||||
}
|
||||
|
||||
e.logf("handling domains: %v and wildcards: %v", xmaps.Keys(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)
|
||||
}
|
||||
}
|
||||
|
||||
// Domains returns the currently configured domain list.
|
||||
func (e *AppConnector) Domains() views.Slice[string] {
|
||||
e.mu.Lock()
|
||||
@@ -384,16 +132,6 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
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 {
|
||||
@@ -409,188 +147,75 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
}
|
||||
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
|
||||
}
|
||||
continue
|
||||
|
||||
}
|
||||
|
||||
domain := strings.TrimSuffix(strings.ToLower(h.Name.String()), ".")
|
||||
domain := h.Name.String()
|
||||
if len(domain) == 0 {
|
||||
continue
|
||||
return
|
||||
}
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
domain = strings.ToLower(domain)
|
||||
e.logf("[v2] observed DNS response for %s", domain)
|
||||
|
||||
if h.Type == dnsmessage.TypeCNAME {
|
||||
res, err := p.CNAMEResource()
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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()))
|
||||
}
|
||||
// TODO(raggi): check for existing prefixes
|
||||
if err := e.routeAdvertiser.AdvertiseRoute(netip.PrefixFrom(addr, addr.BitLen())); err != nil {
|
||||
e.logf("failed to advertise route for %v: %v", 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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,254 +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"
|
||||
)
|
||||
|
||||
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 := xmaps.Keys(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
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1"))
|
||||
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
|
||||
a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1"))
|
||||
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"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
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
|
||||
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)
|
||||
}
|
||||
// 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"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
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"})
|
||||
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com."))
|
||||
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
|
||||
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org."))
|
||||
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"))
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
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
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
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)
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1"))
|
||||
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"})
|
||||
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,313 +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{})
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
|
||||
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.3"))
|
||||
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.4"))
|
||||
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{})
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
|
||||
a.ObserveDNSResponse(dnsResponse("1.b.example.com.", "1.2.3.3"))
|
||||
a.ObserveDNSResponse(dnsResponse("2.b.example.com.", "1.2.3.4"))
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
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"
|
||||
DEFAULT_BASE="tailscale/alpine-base:3.16"
|
||||
|
||||
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
|
||||
@@ -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,10 +4,7 @@
|
||||
// Package apitype contains types for the Tailscale LocalAPI and control plane API.
|
||||
package apitype
|
||||
|
||||
import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/dnstype"
|
||||
)
|
||||
import "tailscale.com/tailcfg"
|
||||
|
||||
// LocalAPIHost is the Host header value used by the LocalAPI.
|
||||
const LocalAPIHost = "local-tailscaled.sock"
|
||||
@@ -52,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"`
|
||||
@@ -73,17 +71,6 @@ type Device struct {
|
||||
AdvertisedRoutes []string `json:"advertisedRoutes"` // Empty for external devices.
|
||||
|
||||
ClientConnectivity *ClientConnectivity `json:"clientConnectivity"`
|
||||
|
||||
// PostureIdentity contains extra identifiers collected from the device when
|
||||
// the tailnet has the device posture identification features enabled. If
|
||||
// Tailscale have attempted to collect this from the device but it has not
|
||||
// opted in, PostureIdentity will have Disabled=true.
|
||||
PostureIdentity *DevicePostureIdentity `json:"postureIdentity"`
|
||||
}
|
||||
|
||||
type DevicePostureIdentity struct {
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
SerialNumbers []string `json:"serialNumbers,omitempty"`
|
||||
}
|
||||
|
||||
// DeviceFieldsOpts determines which fields should be returned in the response.
|
||||
@@ -215,9 +202,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 {
|
||||
|
||||
@@ -7,7 +7,6 @@ package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
@@ -28,7 +27,6 @@ import (
|
||||
|
||||
"go4.org/mem"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
@@ -37,10 +35,9 @@ import (
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/types/dnstype"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/tkatype"
|
||||
"tailscale.com/util/syspolicy/setting"
|
||||
"tailscale.com/util/cmpx"
|
||||
)
|
||||
|
||||
// defaultLocalClient is the default LocalClient when using the legacy
|
||||
@@ -71,14 +68,6 @@ type LocalClient struct {
|
||||
// connecting to the GUI client variants.
|
||||
UseSocketOnly bool
|
||||
|
||||
// OmitAuth, if true, omits sending the local Tailscale daemon any
|
||||
// authentication token that might be required by the platform.
|
||||
//
|
||||
// As of 2024-08-12, only macOS uses an authentication token. OmitAuth is
|
||||
// meant for when Dial is set and the LocalAPI is being proxied to a
|
||||
// different operating system, such as in integration tests.
|
||||
OmitAuth bool
|
||||
|
||||
// tsClient does HTTP requests to the local Tailscale daemon.
|
||||
// It's lazily initialized on first use.
|
||||
tsClient *http.Client
|
||||
@@ -113,7 +102,8 @@ func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string)
|
||||
return d.DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
|
||||
}
|
||||
}
|
||||
return safesocket.ConnectContext(ctx, lc.socket())
|
||||
s := safesocket.DefaultConnectionStrategy(lc.socket())
|
||||
return safesocket.Connect(s)
|
||||
}
|
||||
|
||||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||||
@@ -134,10 +124,8 @@ func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error)
|
||||
},
|
||||
}
|
||||
})
|
||||
if !lc.OmitAuth {
|
||||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
req.SetBasicAuth("", token)
|
||||
}
|
||||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
req.SetBasicAuth("", token)
|
||||
}
|
||||
return lc.tsClient.Do(req)
|
||||
}
|
||||
@@ -265,16 +253,11 @@ func (lc *LocalClient) sendWithHeaders(
|
||||
}
|
||||
if res.StatusCode != wantStatus {
|
||||
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
|
||||
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
|
||||
return nil, nil, bestError(err, slurp)
|
||||
}
|
||||
return slurp, res.Header, nil
|
||||
}
|
||||
|
||||
type httpStatusError struct {
|
||||
error
|
||||
HTTPStatus int
|
||||
}
|
||||
|
||||
func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) {
|
||||
return lc.send(ctx, "GET", path, 200, nil)
|
||||
}
|
||||
@@ -295,50 +278,9 @@ func decodeJSON[T any](b []byte) (ret T, err error) {
|
||||
}
|
||||
|
||||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||||
//
|
||||
// If not found, the error is ErrPeerNotFound.
|
||||
//
|
||||
// For connections proxied by tailscaled, this looks up the owner of the given
|
||||
// address as TCP first, falling back to UDP; if you want to only check a
|
||||
// specific address family, use WhoIsProto.
|
||||
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
|
||||
return nil, ErrPeerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
}
|
||||
|
||||
// ErrPeerNotFound is returned by WhoIs and WhoIsNodeKey when a peer is not found.
|
||||
var ErrPeerNotFound = errors.New("peer not found")
|
||||
|
||||
// WhoIsNodeKey returns the owner of the given wireguard public key.
|
||||
//
|
||||
// If not found, the error is ErrPeerNotFound.
|
||||
func (lc *LocalClient) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(key.String()))
|
||||
if err != nil {
|
||||
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
|
||||
return nil, ErrPeerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
}
|
||||
|
||||
// WhoIsProto returns the owner of the remoteAddr, which must be an IP or
|
||||
// IP:port, for the given protocol (tcp or udp).
|
||||
//
|
||||
// If not found, the error is ErrPeerNotFound.
|
||||
func (lc *LocalClient) WhoIsProto(ctx context.Context, proto, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?proto="+url.QueryEscape(proto)+"&addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
|
||||
return nil, ErrPeerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
@@ -355,12 +297,6 @@ func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/metrics")
|
||||
}
|
||||
|
||||
// UserMetrics returns the user metrics in
|
||||
// the Prometheus text exposition format.
|
||||
func (lc *LocalClient) UserMetrics(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/usermetrics")
|
||||
}
|
||||
|
||||
// IncrementCounter increments the value of a Tailscale daemon's counter
|
||||
// metric by the given delta. If the metric has yet to exist, a new counter
|
||||
// metric is created and initialized to delta.
|
||||
@@ -493,17 +429,6 @@ func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DebugActionBody invokes a debug action with a body parameter, such as
|
||||
// "debug-force-prefer-derp".
|
||||
// These are development tools and subject to change or removal over time.
|
||||
func (lc *LocalClient) DebugActionBody(ctx context.Context, action string, rbody io.Reader) error {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, rbody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DebugResultJSON invokes a debug action and returns its result as something JSON-able.
|
||||
// These are development tools and subject to change or removal over time.
|
||||
func (lc *LocalClient) DebugResultJSON(ctx context.Context, action string) (any, error) {
|
||||
@@ -555,7 +480,7 @@ func (lc *LocalClient) DebugPortmap(ctx context.Context, opts *DebugPortmapOpts)
|
||||
opts = &DebugPortmapOpts{}
|
||||
}
|
||||
|
||||
vals.Set("duration", cmp.Or(opts.Duration, 5*time.Second).String())
|
||||
vals.Set("duration", cmpx.Or(opts.Duration, 5*time.Second).String())
|
||||
vals.Set("type", opts.Type)
|
||||
vals.Set("log_http", strconv.FormatBool(opts.LogHTTP))
|
||||
|
||||
@@ -774,27 +699,6 @@ func (lc *LocalClient) CheckUDPGROForwarding(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUDPGROForwarding enables UDP GRO forwarding for the main interface of this
|
||||
// node. This can be done to improve performance of tailnet nodes acting as exit
|
||||
// nodes or subnet routers.
|
||||
// See https://tailscale.com/kb/1320/performance-best-practices#linux-optimizations-for-subnet-routers-and-exit-nodes
|
||||
func (lc *LocalClient) SetUDPGROForwarding(ctx context.Context) error {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/set-udp-gro-forwarding")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jres struct {
|
||||
Warning string
|
||||
}
|
||||
if err := json.Unmarshal(body, &jres); err != nil {
|
||||
return fmt.Errorf("invalid JSON from set-udp-gro-forwarding: %w", err)
|
||||
}
|
||||
if jres.Warning != "" {
|
||||
return errors.New(jres.Warning)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPrefs validates the provided preferences, without making any changes.
|
||||
//
|
||||
// The CLI uses this before a Start call to fail fast if the preferences won't
|
||||
@@ -826,62 +730,6 @@ func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn
|
||||
return decodeJSON[*ipn.Prefs](body)
|
||||
}
|
||||
|
||||
// GetEffectivePolicy returns the effective policy for the specified scope.
|
||||
func (lc *LocalClient) GetEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
|
||||
scopeID, err := scope.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := lc.get200(ctx, "/localapi/v0/policy/"+string(scopeID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*setting.Snapshot](body)
|
||||
}
|
||||
|
||||
// ReloadEffectivePolicy reloads the effective policy for the specified scope
|
||||
// by reading and merging policy settings from all applicable policy sources.
|
||||
func (lc *LocalClient) ReloadEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
|
||||
scopeID, err := scope.MarshalText()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/policy/"+string(scopeID), 200, http.NoBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*setting.Snapshot](body)
|
||||
}
|
||||
|
||||
// GetDNSOSConfig returns the system DNS configuration for the current device.
|
||||
// That is, it returns the DNS configuration that the system would use if Tailscale weren't being used.
|
||||
func (lc *LocalClient) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/dns-osconfig")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var osCfg apitype.DNSOSConfig
|
||||
if err := json.Unmarshal(body, &osCfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid dns.OSConfig: %w", err)
|
||||
}
|
||||
return &osCfg, nil
|
||||
}
|
||||
|
||||
// QueryDNS executes a DNS query for a name (`google.com.`) and query type (`CNAME`).
|
||||
// It returns the raw DNS response bytes and the resolvers that were used to answer the query
|
||||
// (often just one, but can be more if we raced multiple resolvers).
|
||||
func (lc *LocalClient) QueryDNS(ctx context.Context, name string, queryType string) (bytes []byte, resolvers []*dnstype.Resolver, err error) {
|
||||
body, err := lc.get200(ctx, fmt.Sprintf("/localapi/v0/dns-query?name=%s&type=%s", url.QueryEscape(name), queryType))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var res apitype.DNSQueryResponse
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid query response: %w", err)
|
||||
}
|
||||
return res.Bytes, res.Resolvers, nil
|
||||
}
|
||||
|
||||
// StartLoginInteractive starts an interactive login.
|
||||
func (lc *LocalClient) StartLoginInteractive(ctx context.Context) error {
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/login-interactive", http.StatusNoContent, nil)
|
||||
@@ -930,17 +778,6 @@ func (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error {
|
||||
//
|
||||
// The ctx is only used for the duration of the call, not the lifetime of the net.Conn.
|
||||
func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
|
||||
return lc.UserDial(ctx, "tcp", host, port)
|
||||
}
|
||||
|
||||
// UserDial connects to the host's port via Tailscale for the given network.
|
||||
//
|
||||
// The host may be a base DNS name (resolved from the netmap inside tailscaled),
|
||||
// a FQDN, or an IP address.
|
||||
//
|
||||
// The ctx is only used for the duration of the call, not the lifetime of the
|
||||
// net.Conn.
|
||||
func (lc *LocalClient) UserDial(ctx context.Context, network, host string, port uint16) (net.Conn, error) {
|
||||
connCh := make(chan net.Conn, 1)
|
||||
trace := httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
@@ -953,11 +790,10 @@ func (lc *LocalClient) UserDial(ctx context.Context, network, host string, port
|
||||
return nil, err
|
||||
}
|
||||
req.Header = http.Header{
|
||||
"Upgrade": []string{"ts-dial"},
|
||||
"Connection": []string{"upgrade"},
|
||||
"Dial-Host": []string{host},
|
||||
"Dial-Port": []string{fmt.Sprint(port)},
|
||||
"Dial-Network": []string{network},
|
||||
"Upgrade": []string{"ts-dial"},
|
||||
"Connection": []string{"upgrade"},
|
||||
"Dial-Host": []string{host},
|
||||
"Dial-Port": []string{fmt.Sprint(port)},
|
||||
}
|
||||
res, err := lc.DoLocalRequest(req)
|
||||
if err != nil {
|
||||
@@ -1018,20 +854,7 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *LocalClient) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
return lc.CertPairWithValidity(ctx, domain, 0)
|
||||
}
|
||||
|
||||
// CertPairWithValidity returns a cert and private key for the provided DNS
|
||||
// domain.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
// When minValidity is non-zero, the returned certificate will be valid for at
|
||||
// least the given duration, if permitted by the CA. If the certificate is
|
||||
// valid, but for less than minValidity, it will be synchronously renewed.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *LocalClient) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
|
||||
res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
|
||||
res, err := lc.send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -1338,17 +1161,6 @@ func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConf
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisconnectControl shuts down all connections to control, thus making control consider this node inactive. This can be
|
||||
// run on HA subnet router or app connector replicas before shutting them down to ensure peers get told to switch over
|
||||
// to another replica whilst there is still some grace period for the existing connections to terminate.
|
||||
func (lc *LocalClient) DisconnectControl(ctx context.Context) error {
|
||||
_, _, err := lc.sendWithHeaders(ctx, "POST", "/localapi/v0/disconnect-control", 200, nil, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error disconnecting control: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetworkLockDisable shuts down network-lock across the tailnet.
|
||||
func (lc *LocalClient) NetworkLockDisable(ctx context.Context, secret []byte) error {
|
||||
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
|
||||
@@ -1520,15 +1332,6 @@ func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode strin
|
||||
return decodeJSON[*ipnstate.DebugDERPRegionReport](body)
|
||||
}
|
||||
|
||||
// DebugPacketFilterRules returns the packet filter rules for the current device.
|
||||
func (lc *LocalClient) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterRule, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-packet-filter-rules", 200, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
return decodeJSON[[]tailcfg.FilterRule](body)
|
||||
}
|
||||
|
||||
// DebugSetExpireIn marks the current node key to expire in d.
|
||||
//
|
||||
// This is meant primarily for debug and testing.
|
||||
@@ -1606,66 +1409,6 @@ func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion,
|
||||
return &cv, nil
|
||||
}
|
||||
|
||||
// SetUseExitNode toggles the use of an exit node on or off.
|
||||
// To turn it on, there must have been a previously used exit node.
|
||||
// The most previously used one is reused.
|
||||
// This is a convenience method for GUIs. To select an actual one, update the prefs.
|
||||
func (lc *LocalClient) SetUseExitNode(ctx context.Context, on bool) error {
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/set-use-exit-node-enabled?enabled="+strconv.FormatBool(on), http.StatusOK, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DriveSetServerAddr instructs Taildrive to use the server at addr to access
|
||||
// the filesystem. This is used on platforms like Windows and MacOS to let
|
||||
// Taildrive know to use the file server running in the GUI app.
|
||||
func (lc *LocalClient) DriveSetServerAddr(ctx context.Context, addr string) error {
|
||||
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/fileserver-address", http.StatusCreated, strings.NewReader(addr))
|
||||
return err
|
||||
}
|
||||
|
||||
// DriveShareSet adds or updates the given share in the list of shares that
|
||||
// Taildrive will serve to remote nodes. If a share with the same name already
|
||||
// exists, the existing share is replaced/updated.
|
||||
func (lc *LocalClient) DriveShareSet(ctx context.Context, share *drive.Share) error {
|
||||
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/shares", http.StatusCreated, jsonBody(share))
|
||||
return err
|
||||
}
|
||||
|
||||
// DriveShareRemove removes the share with the given name from the list of
|
||||
// shares that Taildrive will serve to remote nodes.
|
||||
func (lc *LocalClient) DriveShareRemove(ctx context.Context, name string) error {
|
||||
_, err := lc.send(
|
||||
ctx,
|
||||
"DELETE",
|
||||
"/localapi/v0/drive/shares",
|
||||
http.StatusNoContent,
|
||||
strings.NewReader(name))
|
||||
return err
|
||||
}
|
||||
|
||||
// DriveShareRename renames the share from old to new name.
|
||||
func (lc *LocalClient) DriveShareRename(ctx context.Context, oldName, newName string) error {
|
||||
_, err := lc.send(
|
||||
ctx,
|
||||
"POST",
|
||||
"/localapi/v0/drive/shares",
|
||||
http.StatusNoContent,
|
||||
jsonBody([2]string{oldName, newName}))
|
||||
return err
|
||||
}
|
||||
|
||||
// DriveShareList returns the list of shares that drive is currently serving
|
||||
// to remote nodes.
|
||||
func (lc *LocalClient) DriveShareList(ctx context.Context) ([]*drive.Share, error) {
|
||||
result, err := lc.get200(ctx, "/localapi/v0/drive/shares")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var shares []*drive.Share
|
||||
err = json.Unmarshal(result, &shares)
|
||||
return shares, err
|
||||
}
|
||||
|
||||
// IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus.
|
||||
// It's returned by LocalClient.WatchIPNBus.
|
||||
//
|
||||
@@ -1702,12 +1445,3 @@ func (w *IPNBusWatcher) Next() (ipn.Notify, error) {
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SuggestExitNode requests an exit node suggestion and returns the exit node's details.
|
||||
func (lc *LocalClient) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/suggest-exit-node")
|
||||
if err != nil {
|
||||
return apitype.ExitNodeSuggestionResponse{}, err
|
||||
}
|
||||
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
|
||||
}
|
||||
|
||||
@@ -5,16 +5,7 @@
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest/deptest"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
sc, err := getServeConfigFromJSON([]byte("null"))
|
||||
@@ -34,41 +25,3 @@ func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
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 := &LocalClient{
|
||||
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,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()
|
||||
}
|
||||
|
||||
@@ -51,9 +51,6 @@ type Client struct {
|
||||
// HTTPClient optionally specifies an alternate HTTP client to use.
|
||||
// If nil, http.DefaultClient is used.
|
||||
HTTPClient *http.Client
|
||||
|
||||
// UserAgent optionally specifies an alternate User-Agent header
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
@@ -100,9 +97,8 @@ func (c *Client) setAuth(r *http.Request) {
|
||||
// and can be changed manually by the user.
|
||||
func NewClient(tailnet string, auth AuthMethod) *Client {
|
||||
return &Client{
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
UserAgent: "tailscale-client-oss",
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,16 +110,17 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
if c.UserAgent != "" {
|
||||
req.Header.Set("User-Agent", c.UserAgent)
|
||||
}
|
||||
return c.httpClient().Do(req)
|
||||
}
|
||||
|
||||
// sendRequest add the authentication key to the request and sends it. It
|
||||
// receives the response and reads up to 10MB of it.
|
||||
func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
|
||||
resp, err := c.Do(req)
|
||||
if !I_Acknowledge_This_API_Is_Unstable {
|
||||
return nil, nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -14,13 +13,10 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
prebuilt "github.com/tailscale/web-client-prebuilt"
|
||||
)
|
||||
|
||||
var start = time.Now()
|
||||
|
||||
func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
|
||||
if devMode {
|
||||
// When in dev mode, proxy asset requests to the Vite dev server.
|
||||
@@ -29,48 +25,19 @@ func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
|
||||
}
|
||||
|
||||
fsys := prebuilt.FS()
|
||||
fileserver := http.FileServer(http.FS(fsys))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
f, err := openPrecompressedFile(w, r, path, fsys)
|
||||
if err != nil {
|
||||
// Rewrite request to just fetch index.html and let
|
||||
_, err := fs.Stat(fsys, strings.TrimPrefix(r.URL.Path, "/"))
|
||||
if os.IsNotExist(err) {
|
||||
// rewrite request to just fetch /index.html and let
|
||||
// the frontend router handle it.
|
||||
r = r.Clone(r.Context())
|
||||
path = "index.html"
|
||||
f, err = openPrecompressedFile(w, r, path, fsys)
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
if f == nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// fs.File does not claim to implement Seeker, but in practice it does.
|
||||
fSeeker, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
http.Error(w, "Not seekable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "assets/") {
|
||||
// Aggressively cache static assets, since we cache-bust our assets with
|
||||
// hashed filenames.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31535996")
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, path, start, fSeeker)
|
||||
fileserver.ServeHTTP(w, r)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func openPrecompressedFile(w http.ResponseWriter, r *http.Request, path string, fs fs.FS) (fs.File, error) {
|
||||
if f, err := fs.Open(path + ".gz"); err == nil {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
return f, nil
|
||||
}
|
||||
return fs.Open(path) // fallback
|
||||
}
|
||||
|
||||
// startDevServer starts the JS dev server that does on-demand rebuilding
|
||||
// and serving of web client JS and CSS resources.
|
||||
func startDevServer() (cleanup func()) {
|
||||
|
||||
@@ -8,15 +8,12 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -105,48 +102,48 @@ var (
|
||||
//
|
||||
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
|
||||
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
|
||||
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, *ipnstate.Status, error) {
|
||||
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
|
||||
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
|
||||
switch {
|
||||
case whoIsErr != nil:
|
||||
return nil, nil, status, errNotUsingTailscale
|
||||
return nil, nil, errNotUsingTailscale
|
||||
case statusErr != nil:
|
||||
return nil, whoIs, nil, statusErr
|
||||
return nil, whoIs, statusErr
|
||||
case status.Self == nil:
|
||||
return nil, whoIs, status, errors.New("missing self node in tailscale status")
|
||||
return nil, whoIs, errors.New("missing self node in tailscale status")
|
||||
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
|
||||
return nil, whoIs, status, errTaggedLocalSource
|
||||
return nil, whoIs, errTaggedLocalSource
|
||||
case whoIs.Node.IsTagged():
|
||||
return nil, whoIs, status, errTaggedRemoteSource
|
||||
return nil, whoIs, errTaggedRemoteSource
|
||||
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
|
||||
return nil, whoIs, status, errNotOwner
|
||||
return nil, whoIs, errNotOwner
|
||||
}
|
||||
srcNode := whoIs.Node.ID
|
||||
srcUser := whoIs.UserProfile.ID
|
||||
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
return nil, whoIs, status, errNoSession
|
||||
return nil, whoIs, errNoSession
|
||||
} else if err != nil {
|
||||
return nil, whoIs, status, err
|
||||
return nil, whoIs, err
|
||||
}
|
||||
v, ok := s.browserSessions.Load(cookie.Value)
|
||||
if !ok {
|
||||
return nil, whoIs, status, errNoSession
|
||||
return nil, whoIs, errNoSession
|
||||
}
|
||||
session := v.(*browserSession)
|
||||
if session.SrcNode != srcNode || session.SrcUser != srcUser {
|
||||
// In this case the browser cookie is associated with another tailscale node.
|
||||
// Maybe the source browser's machine was logged out and then back in as a different node.
|
||||
// Return errNoSession because there is no session for this user.
|
||||
return nil, whoIs, status, errNoSession
|
||||
return nil, whoIs, errNoSession
|
||||
} else if session.isExpired(s.timeNow()) {
|
||||
// Session expired, remove from session map and return errNoSession.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return nil, whoIs, status, errNoSession
|
||||
return nil, whoIs, errNoSession
|
||||
}
|
||||
return session, whoIs, status, nil
|
||||
return session, whoIs, nil
|
||||
}
|
||||
|
||||
// newSession creates a new session associated with the given source user/node,
|
||||
@@ -192,7 +189,7 @@ func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
controlURL, err := url.Parse(prefs.ControlURLOrDefault())
|
||||
controlURL, err := url.Parse(prefs.ControlURL)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
@@ -223,7 +220,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 +231,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
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
|
||||
|
||||
<link rel="preload" as="font" href="./assets/Inter.var.latin-39e72c07.woff2" type="font/woff2" crossorigin />
|
||||
<script type="module" crossorigin src="./assets/index-fd4af382.js"></script>
|
||||
<link rel="stylesheet" href="./assets/index-218918fa.css">
|
||||
<script type="module" crossorigin src="./assets/index-4d1f45ea.js"></script>
|
||||
<link rel="stylesheet" href="./assets/index-8612dca6.css">
|
||||
</head>
|
||||
<body class="px-2">
|
||||
<body>
|
||||
<noscript>
|
||||
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
|
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
|
||||
|
||||
@@ -8,21 +8,20 @@
|
||||
<link rel="stylesheet" type="text/css" href="/src/index.css" />
|
||||
<link rel="preload" as="font" href="/src/assets/fonts/Inter.var.latin.woff2" type="font/woff2" crossorigin />
|
||||
</head>
|
||||
<body class="px-2">
|
||||
<body>
|
||||
<noscript>
|
||||
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
|
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
|
||||
</noscript>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<script>
|
||||
// if this script is changed, also change hash in web.go
|
||||
window.addEventListener("load", () => {
|
||||
if (!window.Tailscale) {
|
||||
const rootEl = document.createElement("p")
|
||||
rootEl.innerHTML = 'Tailscale web interface is unavailable.';
|
||||
rootEl.innerHTML = 'Tailscale was built without the web client. See <a href="https://github.com/tailscale/tailscale#building-the-web-client">Building the web client</a> for more information.'
|
||||
document.body.append(rootEl)
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,45 +3,35 @@
|
||||
"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",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-popover": "^1.0.6",
|
||||
"classnames": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"swr": "^2.2.4",
|
||||
"wouter": "^2.11.0",
|
||||
"zustand": "^4.4.7"
|
||||
"wouter": "^2.11.0"
|
||||
},
|
||||
"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 +46,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"
|
||||
},
|
||||
@@ -71,11 +59,5 @@
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 80
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"tailwindcss": {},
|
||||
"autoprefixer": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
client/web/postcss.config.js
Normal file
6
client/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,273 +1,24 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useCallback } from "react"
|
||||
import useToaster from "src/hooks/toaster"
|
||||
import { ExitNode, NodeData, SubnetRoute } from "src/types"
|
||||
import { assertNever } from "src/utils/util"
|
||||
import { MutatorOptions, SWRConfiguration, useSWRConfig } from "swr"
|
||||
import { noExitNode, runAsExitNode } from "./hooks/exit-nodes"
|
||||
|
||||
export const swrConfig: SWRConfiguration = {
|
||||
fetcher: (url: string) => apiFetch(url, "GET"),
|
||||
onError: (err, _) => console.error(err),
|
||||
}
|
||||
|
||||
type APIType =
|
||||
| { action: "up"; data: TailscaleUpData }
|
||||
| { action: "logout" }
|
||||
| { action: "new-auth-session"; data: AuthSessionNewData }
|
||||
| { action: "update-prefs"; data: LocalPrefsData }
|
||||
| { action: "update-routes"; data: SubnetRoute[] }
|
||||
| { action: "update-exit-node"; data: ExitNode }
|
||||
|
||||
/**
|
||||
* POST /api/up data
|
||||
*/
|
||||
type TailscaleUpData = {
|
||||
Reauthenticate?: boolean // force reauthentication
|
||||
ControlURL?: string
|
||||
AuthKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/session/new data
|
||||
*/
|
||||
type AuthSessionNewData = {
|
||||
authUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/local/v0/prefs data
|
||||
*/
|
||||
type LocalPrefsData = {
|
||||
RunSSHSet?: boolean
|
||||
RunSSH?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/routes data
|
||||
*/
|
||||
type RoutesData = {
|
||||
SetExitNode?: boolean
|
||||
SetRoutes?: boolean
|
||||
UseExitNode?: string
|
||||
AdvertiseExitNode?: boolean
|
||||
AdvertiseRoutes?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* useAPI hook returns an api handler that can execute api calls
|
||||
* throughout the web client UI.
|
||||
*/
|
||||
export function useAPI() {
|
||||
const toaster = useToaster()
|
||||
const { mutate } = useSWRConfig() // allows for global mutation
|
||||
|
||||
const handlePostError = useCallback(
|
||||
(toast?: string) => (err: Error) => {
|
||||
console.error(err)
|
||||
toast && toaster.show({ variant: "danger", message: toast })
|
||||
throw err
|
||||
},
|
||||
[toaster]
|
||||
)
|
||||
|
||||
/**
|
||||
* optimisticMutate wraps the SWR `mutate` function to apply some
|
||||
* type-awareness with the following behavior:
|
||||
*
|
||||
* 1. `optimisticData` update is applied immediately on FetchDataType
|
||||
* throughout the web client UI.
|
||||
*
|
||||
* 2. `fetch` data mutation runs.
|
||||
*
|
||||
* 3. On completion, FetchDataType is revalidated to exactly reflect the
|
||||
* updated server state.
|
||||
*
|
||||
* The `key` argument is the useSWR key associated with the MutateDataType.
|
||||
* All `useSWR(key)` consumers throughout the UI will see updates reflected.
|
||||
*/
|
||||
const optimisticMutate = useCallback(
|
||||
<MutateDataType, FetchDataType = any>(
|
||||
key: string,
|
||||
fetch: Promise<FetchDataType>,
|
||||
optimisticData: (current: MutateDataType) => MutateDataType,
|
||||
revalidate?: boolean // optionally specify whether to run final revalidation (step 3)
|
||||
): Promise<FetchDataType | undefined> => {
|
||||
const options: MutatorOptions = {
|
||||
/**
|
||||
* populateCache is meant for use when the remote request returns back
|
||||
* the updated data directly. i.e. When FetchDataType is the same as
|
||||
* MutateDataType. Most of our data manipulation requests return a 200
|
||||
* with empty data on success. We turn off populateCache so that the
|
||||
* cache only gets updated after completion of the remote reqeust when
|
||||
* the revalidation step runs.
|
||||
*/
|
||||
populateCache: false,
|
||||
optimisticData,
|
||||
revalidate: revalidate,
|
||||
}
|
||||
return mutate(key, fetch, options)
|
||||
},
|
||||
[mutate]
|
||||
)
|
||||
|
||||
const api = useCallback(
|
||||
(t: APIType) => {
|
||||
switch (t.action) {
|
||||
/**
|
||||
* "up" handles authenticating the machine to tailnet.
|
||||
*/
|
||||
case "up":
|
||||
return apiFetch<{ url?: string }>("/up", "POST", t.data)
|
||||
.then((d) => d.url && window.open(d.url, "_blank")) // "up" login step
|
||||
.then(() => incrementMetric("web_client_node_connect"))
|
||||
.then(() => mutate("/data"))
|
||||
.catch(handlePostError("Failed to login"))
|
||||
|
||||
/**
|
||||
* "logout" handles logging the node out of tailscale, effectively
|
||||
* expiring its node key.
|
||||
*/
|
||||
case "logout":
|
||||
// For logout, must increment metric before running api call,
|
||||
// as tailscaled will be unreachable after the call completes.
|
||||
incrementMetric("web_client_node_disconnect")
|
||||
return apiFetch("/local/v0/logout", "POST").catch(
|
||||
handlePostError("Failed to logout")
|
||||
)
|
||||
|
||||
/**
|
||||
* "new-auth-session" handles creating a new check mode session to
|
||||
* authorize the viewing user to manage the node via the web client.
|
||||
*/
|
||||
case "new-auth-session":
|
||||
return apiFetch<AuthSessionNewData>("/auth/session/new", "GET").catch(
|
||||
handlePostError("Failed to create new session")
|
||||
)
|
||||
|
||||
/**
|
||||
* "update-prefs" handles setting the node's tailscale prefs.
|
||||
*/
|
||||
case "update-prefs": {
|
||||
return optimisticMutate<NodeData>(
|
||||
"/data",
|
||||
apiFetch<LocalPrefsData>("/local/v0/prefs", "PATCH", t.data),
|
||||
(old) => ({
|
||||
...old,
|
||||
RunningSSHServer: t.data.RunSSHSet
|
||||
? Boolean(t.data.RunSSH)
|
||||
: old.RunningSSHServer,
|
||||
})
|
||||
)
|
||||
.then(
|
||||
() =>
|
||||
t.data.RunSSHSet &&
|
||||
incrementMetric(
|
||||
t.data.RunSSH
|
||||
? "web_client_ssh_enable"
|
||||
: "web_client_ssh_disable"
|
||||
)
|
||||
)
|
||||
.catch(handlePostError("Failed to update node preference"))
|
||||
}
|
||||
|
||||
/**
|
||||
* "update-routes" handles setting the node's advertised routes.
|
||||
*/
|
||||
case "update-routes": {
|
||||
const body: RoutesData = {
|
||||
SetRoutes: true,
|
||||
AdvertiseRoutes: t.data.map((r) => r.Route),
|
||||
}
|
||||
return optimisticMutate<NodeData>(
|
||||
"/data",
|
||||
apiFetch<void>("/routes", "POST", body),
|
||||
(old) => ({ ...old, AdvertisedRoutes: t.data })
|
||||
)
|
||||
.then(() => incrementMetric("web_client_advertise_routes_change"))
|
||||
.catch(handlePostError("Failed to update routes"))
|
||||
}
|
||||
|
||||
/**
|
||||
* "update-exit-node" handles updating the node's state as either
|
||||
* running as an exit node or using another node as an exit node.
|
||||
*/
|
||||
case "update-exit-node": {
|
||||
const id = t.data.ID
|
||||
const body: RoutesData = {
|
||||
SetExitNode: true,
|
||||
}
|
||||
if (id !== noExitNode.ID && id !== runAsExitNode.ID) {
|
||||
body.UseExitNode = id
|
||||
} else if (id === runAsExitNode.ID) {
|
||||
body.AdvertiseExitNode = true
|
||||
}
|
||||
const metrics: MetricName[] = []
|
||||
return optimisticMutate<NodeData>(
|
||||
"/data",
|
||||
apiFetch<void>("/routes", "POST", body),
|
||||
(old) => {
|
||||
// Only update metrics whose values have changed.
|
||||
if (old.AdvertisingExitNode !== Boolean(body.AdvertiseExitNode)) {
|
||||
metrics.push(
|
||||
body.AdvertiseExitNode
|
||||
? "web_client_advertise_exitnode_enable"
|
||||
: "web_client_advertise_exitnode_disable"
|
||||
)
|
||||
}
|
||||
if (Boolean(old.UsingExitNode) !== Boolean(body.UseExitNode)) {
|
||||
metrics.push(
|
||||
body.UseExitNode
|
||||
? "web_client_use_exitnode_enable"
|
||||
: "web_client_use_exitnode_disable"
|
||||
)
|
||||
}
|
||||
return {
|
||||
...old,
|
||||
UsingExitNode: Boolean(body.UseExitNode) ? t.data : undefined,
|
||||
AdvertisingExitNode: Boolean(body.AdvertiseExitNode),
|
||||
AdvertisingExitNodeApproved: Boolean(body.AdvertiseExitNode)
|
||||
? true // gets updated in revalidation
|
||||
: old.AdvertisingExitNodeApproved,
|
||||
}
|
||||
},
|
||||
false // skip final revalidation
|
||||
)
|
||||
.then(() => metrics.forEach((m) => incrementMetric(m)))
|
||||
.catch(handlePostError("Failed to update exit node"))
|
||||
}
|
||||
|
||||
default:
|
||||
assertNever(t)
|
||||
}
|
||||
},
|
||||
[handlePostError, mutate, optimisticMutate]
|
||||
)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
let csrfToken: string
|
||||
let synoToken: string | undefined // required for synology API requests
|
||||
let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062)
|
||||
|
||||
/**
|
||||
* apiFetch wraps the standard JS fetch function with csrf header
|
||||
* management and param additions specific to the web client.
|
||||
*
|
||||
* apiFetch adds the `api` prefix to the request URL,
|
||||
* so endpoint should be provided without the `api` prefix
|
||||
* (i.e. provide `/data` rather than `api/data`).
|
||||
*/
|
||||
export function apiFetch<T>(
|
||||
// apiFetch wraps the standard JS fetch function with csrf header
|
||||
// management and param additions specific to the web client.
|
||||
//
|
||||
// apiFetch adds the `api` prefix to the request URL,
|
||||
// so endpoint should be provided without the `api` prefix
|
||||
// (i.e. provide `/data` rather than `api/data`).
|
||||
export function apiFetch(
|
||||
endpoint: string,
|
||||
method: "GET" | "POST" | "PATCH",
|
||||
body?: any
|
||||
): Promise<T> {
|
||||
body?: any,
|
||||
params?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const nextParams = new URLSearchParams()
|
||||
const nextParams = new URLSearchParams(params)
|
||||
if (synoToken) {
|
||||
nextParams.set("SynoToken", synoToken)
|
||||
} else {
|
||||
@@ -300,26 +51,16 @@ export function apiFetch<T>(
|
||||
"Content-Type": contentType,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body: body,
|
||||
body,
|
||||
}).then((r) => {
|
||||
updateCsrfToken(r)
|
||||
if (!r.ok) {
|
||||
return r.text().then((err) => {
|
||||
throw new Error(err)
|
||||
})
|
||||
}
|
||||
return r
|
||||
})
|
||||
.then((r) => {
|
||||
updateCsrfToken(r)
|
||||
if (!r.ok) {
|
||||
return r.text().then((err) => {
|
||||
throw new Error(err)
|
||||
})
|
||||
}
|
||||
return r
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.headers.get("Content-Type") === "application/json") {
|
||||
return r.json()
|
||||
}
|
||||
})
|
||||
.then((r) => {
|
||||
r?.UnraidToken && setUnraidCsrfToken(r.UnraidToken)
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
function updateCsrfToken(r: Response) {
|
||||
@@ -333,45 +74,6 @@ export function setSynoToken(token?: string) {
|
||||
synoToken = token
|
||||
}
|
||||
|
||||
function setUnraidCsrfToken(token?: string) {
|
||||
export function setUnraidCsrfToken(token?: string) {
|
||||
unraidCsrfToken = token
|
||||
}
|
||||
|
||||
/**
|
||||
* incrementMetric hits the client metrics local API endpoint to
|
||||
* increment the given counter metric by one.
|
||||
*/
|
||||
export function incrementMetric(metricName: MetricName) {
|
||||
const postData: MetricsPOSTData[] = [
|
||||
{
|
||||
Name: metricName,
|
||||
Type: "counter",
|
||||
Value: 1,
|
||||
},
|
||||
]
|
||||
|
||||
apiFetch("/local/v0/upload-client-metrics", "POST", postData).catch(
|
||||
(error) => {
|
||||
console.error(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
type MetricsPOSTData = {
|
||||
Name: MetricName
|
||||
Type: MetricType
|
||||
Value: number
|
||||
}
|
||||
|
||||
type MetricType = "counter" | "gauge"
|
||||
|
||||
export type MetricName =
|
||||
| "web_client_advertise_exitnode_enable"
|
||||
| "web_client_advertise_exitnode_disable"
|
||||
| "web_client_use_exitnode_enable"
|
||||
| "web_client_use_exitnode_disable"
|
||||
| "web_client_ssh_enable"
|
||||
| "web_client_ssh_disable"
|
||||
| "web_client_node_connect"
|
||||
| "web_client_node_disconnect"
|
||||
| "web_client_advertise_routes_change"
|
||||
|
||||
15
client/web/src/assets/icons/connected-device.svg
Normal file
15
client/web/src/assets/icons/connected-device.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="20" fill="#F7F5F4"/>
|
||||
<g clip-path="url(#clip0_13627_11903)">
|
||||
<path d="M26.6666 11.6667H13.3333C12.4128 11.6667 11.6666 12.4129 11.6666 13.3333V16.6667C11.6666 17.5871 12.4128 18.3333 13.3333 18.3333H26.6666C27.5871 18.3333 28.3333 17.5871 28.3333 16.6667V13.3333C28.3333 12.4129 27.5871 11.6667 26.6666 11.6667Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M26.6666 21.6667H13.3333C12.4128 21.6667 11.6666 22.4129 11.6666 23.3333V26.6667C11.6666 27.5871 12.4128 28.3333 13.3333 28.3333H26.6666C27.5871 28.3333 28.3333 27.5871 28.3333 26.6667V23.3333C28.3333 22.4129 27.5871 21.6667 26.6666 21.6667Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 15H15.01" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 25H15.01" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<circle cx="34" cy="34" r="4.5" fill="#1EA672" stroke="white"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_13627_11903">
|
||||
<rect width="20" height="20" fill="white" transform="translate(10 10)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 9H11C9.89543 9 9 9.89543 9 11V20C9 21.1046 9.89543 22 11 22H20C21.1046 22 22 21.1046 22 20V11C22 9.89543 21.1046 9 20 9Z" stroke="#292828" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5" stroke="#292828" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 649 B |
@@ -1,13 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_14860_117136)">
|
||||
<path d="M16.666 1.66667H3.33268C2.41221 1.66667 1.66602 2.41286 1.66602 3.33334V6.66667C1.66602 7.58715 2.41221 8.33334 3.33268 8.33334H16.666C17.5865 8.33334 18.3327 7.58715 18.3327 6.66667V3.33334C18.3327 2.41286 17.5865 1.66667 16.666 1.66667Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16.666 11.6667H3.33268C2.41221 11.6667 1.66602 12.4129 1.66602 13.3333V16.6667C1.66602 17.5871 2.41221 18.3333 3.33268 18.3333H16.666C17.5865 18.3333 18.3327 17.5871 18.3327 16.6667V13.3333C18.3327 12.4129 17.5865 11.6667 16.666 11.6667Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 5H5.01" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 15H5.01" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_14860_117136">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 6L6 18" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 6L18 18" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 277 B |
@@ -1,133 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
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 NiceIP from "src/components/nice-ip"
|
||||
import useToaster from "src/hooks/toaster"
|
||||
import Button from "src/ui/button"
|
||||
import { copyText } from "src/utils/clipboard"
|
||||
|
||||
/**
|
||||
* AddressCard renders a clickable IP address text that opens a
|
||||
* dialog with a copyable list of all addresses (IPv4, IPv6, DNS)
|
||||
* for the machine.
|
||||
*/
|
||||
export default function AddressCard({
|
||||
v4Address,
|
||||
v6Address,
|
||||
shortDomain,
|
||||
fullDomain,
|
||||
className,
|
||||
triggerClassName,
|
||||
}: {
|
||||
v4Address: string
|
||||
v6Address: string
|
||||
shortDomain?: string
|
||||
fullDomain?: string
|
||||
className?: string
|
||||
triggerClassName?: string
|
||||
}) {
|
||||
const children = (
|
||||
<ul className="flex flex-col divide-y rounded-md overflow-hidden">
|
||||
{shortDomain && <AddressRow label="short domain" value={shortDomain} />}
|
||||
{fullDomain && <AddressRow label="full domain" value={fullDomain} />}
|
||||
{v4Address && (
|
||||
<AddressRow
|
||||
key={v4Address}
|
||||
label="IPv4 address"
|
||||
ip={true}
|
||||
value={v4Address}
|
||||
/>
|
||||
)}
|
||||
{v6Address && (
|
||||
<AddressRow
|
||||
key={v6Address}
|
||||
label="IPv6 address"
|
||||
ip={true}
|
||||
value={v6Address}
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
|
||||
return (
|
||||
<Primitive.Root>
|
||||
<Primitive.Trigger asChild>
|
||||
<Button
|
||||
variant="minimal"
|
||||
className={cx("-ml-1 px-1 py-0 font-normal", className)}
|
||||
suffixIcon={
|
||||
<ChevronDown className="w-5 h-5" stroke="#232222" /* gray-800 */ />
|
||||
}
|
||||
aria-label="See all addresses for this device."
|
||||
>
|
||||
<NiceIP className={triggerClassName} ip={v4Address ?? v6Address} />
|
||||
</Button>
|
||||
</Primitive.Trigger>
|
||||
<Primitive.Content
|
||||
className="shadow-popover origin-radix-popover state-open:animate-scale-in state-closed:animate-scale-out bg-white rounded-md z-50 max-w-sm"
|
||||
sideOffset={10}
|
||||
side="top"
|
||||
>
|
||||
{children}
|
||||
</Primitive.Content>
|
||||
</Primitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function AddressRow({
|
||||
label,
|
||||
value,
|
||||
ip,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
ip?: boolean
|
||||
}) {
|
||||
const toaster = useToaster()
|
||||
const onCopyClick = useCallback(() => {
|
||||
copyText(value)
|
||||
.then(() => toaster.show({ message: `Copied ${label} to clipboard` }))
|
||||
.catch(() =>
|
||||
toaster.show({
|
||||
message: `Failed to copy ${label} to clipboard`,
|
||||
variant: "danger",
|
||||
})
|
||||
)
|
||||
}, [label, toaster, value])
|
||||
|
||||
return (
|
||||
<li className="py flex items-center gap-2">
|
||||
<button
|
||||
className={cx(
|
||||
"relative flex group items-center transition-colors",
|
||||
"focus:outline-none focus-visible:ring",
|
||||
"disabled:text-text-muted enabled:hover:text-gray-500",
|
||||
"w-60 text-sm flex-1"
|
||||
)}
|
||||
onClick={onCopyClick}
|
||||
aria-label={`Copy ${value} to your clip board.`}
|
||||
>
|
||||
<div className="overflow-hidden pl-3 pr-10 py-2 tabular-nums">
|
||||
{ip ? (
|
||||
<NiceIP ip={value} />
|
||||
) : (
|
||||
<div className="truncate m-w-full">{value}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cx(
|
||||
"absolute right-0 pl-6 pr-3 bg-gradient-to-r from-transparent",
|
||||
"text-gray-900 group-hover:text-gray-600"
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,26 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
|
||||
import React, { useEffect } from "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"
|
||||
import HomeView from "src/components/views/home-view"
|
||||
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 Card from "src/ui/card"
|
||||
import EmptyState from "src/ui/empty-state"
|
||||
import LoadingDots from "src/ui/loading-dots"
|
||||
import useSWR from "swr"
|
||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||
import useNodeData, { NodeData } from "src/hooks/node-data"
|
||||
import { Link, Route, Router, Switch, useLocation } from "wouter"
|
||||
|
||||
export default function App() {
|
||||
const { data: auth, loading: loadingAuth, newSession } = useAuth()
|
||||
|
||||
return (
|
||||
<main className="min-w-sm max-w-lg mx-auto py-4 sm:py-14 px-5">
|
||||
<main className="min-w-sm max-w-lg mx-auto py-14 px-5">
|
||||
{loadingAuth || !auth ? (
|
||||
<LoadingView />
|
||||
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
|
||||
) : (
|
||||
<WebClient auth={auth} newSession={newSession} />
|
||||
)}
|
||||
@@ -40,50 +35,57 @@ function WebClient({
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const { data: node } = useSWR<NodeData>("/data")
|
||||
const { data, refreshData, nodeUpdaters } = useNodeData()
|
||||
useEffect(() => {
|
||||
refreshData()
|
||||
}, [auth, refreshData])
|
||||
|
||||
return !node ? (
|
||||
<LoadingView />
|
||||
) : node.Status === "NeedsLogin" ||
|
||||
node.Status === "NoState" ||
|
||||
node.Status === "Stopped" ? (
|
||||
return !data ? (
|
||||
<div className="text-center py-14">Loading...</div>
|
||||
) : data.Status === "NeedsLogin" ||
|
||||
data.Status === "NoState" ||
|
||||
data.Status === "Stopped" ? (
|
||||
// Client not on a tailnet, render login.
|
||||
<LoginView data={node} />
|
||||
<LoginView data={data} refreshData={refreshData} />
|
||||
) : (
|
||||
// Otherwise render the new web client.
|
||||
<>
|
||||
<Router base={node.URLPrefix}>
|
||||
<Header node={node} auth={auth} newSession={newSession} />
|
||||
<Router base={data.URLPrefix}>
|
||||
<Header node={data} auth={auth} newSession={newSession} />
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<HomeView node={node} auth={auth} />
|
||||
<HomeView
|
||||
readonly={!auth.canManageNode}
|
||||
node={data}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/details">
|
||||
<DeviceDetailsView node={node} auth={auth} />
|
||||
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
|
||||
</Route>
|
||||
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
|
||||
<Route path="/subnets">
|
||||
<SubnetRouterView
|
||||
readonly={!canEdit("subnets", auth)}
|
||||
node={node}
|
||||
readonly={!auth.canManageNode}
|
||||
node={data}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
/>
|
||||
</FeatureRoute>
|
||||
<FeatureRoute path="/ssh" feature="ssh" node={node}>
|
||||
<SSHView readonly={!canEdit("ssh", auth)} node={node} />
|
||||
</FeatureRoute>
|
||||
{/* <Route path="/serve">Share local content</Route> */}
|
||||
<FeatureRoute path="/update" feature="auto-update" node={node}>
|
||||
</Route>
|
||||
<Route path="/ssh">
|
||||
<SSHView
|
||||
readonly={!auth.canManageNode}
|
||||
node={data}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/serve">{/* TODO */}Share local content</Route>
|
||||
<Route path="/update">
|
||||
<UpdatingView
|
||||
versionInfo={node.ClientVersion}
|
||||
currentVersion={node.IPNVersion}
|
||||
versionInfo={data.ClientVersion}
|
||||
currentVersion={data.IPNVersion}
|
||||
/>
|
||||
</FeatureRoute>
|
||||
<Route path="/disconnected">
|
||||
<DisconnectedView />
|
||||
</Route>
|
||||
<Route>
|
||||
<Card className="mt-8">
|
||||
<EmptyState description="Page not found" />
|
||||
</Card>
|
||||
<h2 className="mt-8">Page not found</h2>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
@@ -91,40 +93,6 @@ function WebClient({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* FeatureRoute renders a Route component,
|
||||
* but only displays the child view if the specified feature is
|
||||
* available for use on this node's platform. If not available,
|
||||
* a not allowed view is rendered instead.
|
||||
*/
|
||||
function FeatureRoute({
|
||||
path,
|
||||
node,
|
||||
feature,
|
||||
children,
|
||||
}: {
|
||||
path: string
|
||||
node: NodeData
|
||||
feature: Feature
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Route path={path}>
|
||||
{!node.Features[feature] ? (
|
||||
<Card className="mt-8">
|
||||
<EmptyState
|
||||
description={`${featureDescription(
|
||||
feature
|
||||
)} not available on this device.`}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Route>
|
||||
)
|
||||
}
|
||||
|
||||
function Header({
|
||||
node,
|
||||
auth,
|
||||
@@ -136,37 +104,25 @@ function Header({
|
||||
}) {
|
||||
const [loc] = useLocation()
|
||||
|
||||
if (loc === "/disconnected") {
|
||||
// No header on view presented after logout.
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-4 justify-between items-center mb-9 md:mb-12">
|
||||
<Link to="/" className="flex gap-3 overflow-hidden">
|
||||
<div className="flex justify-between mb-12">
|
||||
<div className="flex gap-3">
|
||||
<TailscaleIcon />
|
||||
<div className="inline text-gray-800 text-lg font-medium leading-snug truncate">
|
||||
<div className="inline text-neutral-800 text-lg font-medium leading-snug">
|
||||
{node.DomainName}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<LoginToggle node={node} auth={auth} newSession={newSession} />
|
||||
</div>
|
||||
{loc !== "/" && loc !== "/update" && (
|
||||
<Link to="/" className="link font-medium block mb-2">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-indigo-500 font-medium leading-snug block mb-[10px]"
|
||||
>
|
||||
← Back to {node.DeviceName}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LoadingView fills its container with small animated loading dots
|
||||
* in the center.
|
||||
*/
|
||||
export function LoadingView() {
|
||||
return (
|
||||
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import { NodeData } from "src/types"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
|
||||
/**
|
||||
* AdminContainer renders its contents only if the node's control
|
||||
|
||||
@@ -2,40 +2,32 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
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 React, { useCallback, useMemo, useRef, useState } from "react"
|
||||
import { ReactComponent as Check } from "src/assets/icons/check.svg"
|
||||
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||
import useExitNodes, {
|
||||
ExitNode,
|
||||
noExitNode,
|
||||
runAsExitNode,
|
||||
trimDNSSuffix,
|
||||
} from "src/hooks/exit-nodes"
|
||||
import { ExitNode, NodeData } from "src/types"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import Popover from "src/ui/popover"
|
||||
import SearchInput from "src/ui/search-input"
|
||||
import { useSWRConfig } from "swr"
|
||||
|
||||
export default function ExitNodeSelector({
|
||||
className,
|
||||
node,
|
||||
nodeUpdaters,
|
||||
disabled,
|
||||
}: {
|
||||
className?: string
|
||||
node: NodeData
|
||||
nodeUpdaters: NodeUpdaters
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const api = useAPI()
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
|
||||
const [pending, setPending] = useState<boolean>(false)
|
||||
const { mutate } = useSWRConfig() // allows for global mutation
|
||||
useEffect(() => setSelected(toSelectedExitNode(node)), [node])
|
||||
useEffect(() => {
|
||||
setPending(
|
||||
node.AdvertisingExitNode && node.AdvertisingExitNodeApproved === false
|
||||
)
|
||||
}, [node])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(n: ExitNode) => {
|
||||
@@ -43,148 +35,114 @@ export default function ExitNodeSelector({
|
||||
if (n.ID === selected.ID) {
|
||||
return // no update
|
||||
}
|
||||
// Eager clear of pending state to avoid UI oddities
|
||||
if (n.ID !== runAsExitNode.ID) {
|
||||
setPending(false)
|
||||
}
|
||||
api({ action: "update-exit-node", data: n })
|
||||
|
||||
// refresh data after short timeout to pick up any pending approval updates
|
||||
setTimeout(() => {
|
||||
mutate("/data")
|
||||
}, 1000)
|
||||
const old = selected
|
||||
setSelected(n) // optimistic UI update
|
||||
nodeUpdaters.postExitNode(n).catch(() => setSelected(old))
|
||||
},
|
||||
[api, mutate, selected.ID]
|
||||
[nodeUpdaters, selected]
|
||||
)
|
||||
|
||||
const [
|
||||
none, // not using exit nodes
|
||||
advertising, // advertising as exit node
|
||||
using, // using another exit node
|
||||
offline, // selected exit node node is offline
|
||||
] = useMemo(
|
||||
() => [
|
||||
selected.ID === noExitNode.ID,
|
||||
selected.ID === runAsExitNode.ID,
|
||||
selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
|
||||
!selected.Online,
|
||||
],
|
||||
[selected.ID, selected.Online]
|
||||
[selected]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"rounded-md",
|
||||
{
|
||||
"bg-red-600": offline,
|
||||
"bg-yellow-400": pending,
|
||||
},
|
||||
className
|
||||
)}
|
||||
<Popover
|
||||
open={disabled ? false : open}
|
||||
onOpenChange={setOpen}
|
||||
side="bottom"
|
||||
sideOffset={5}
|
||||
align="start"
|
||||
alignOffset={8}
|
||||
content={
|
||||
<ExitNodeSelectorInner
|
||||
node={node}
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
}
|
||||
asChild
|
||||
>
|
||||
<div
|
||||
className={cx("p-1.5 rounded-md border flex items-stretch gap-1.5", {
|
||||
"border-gray-200": none,
|
||||
"bg-yellow-300 border-yellow-300": advertising && !offline,
|
||||
"bg-blue-500 border-blue-500": using && !offline,
|
||||
"bg-red-500 border-red-500": offline,
|
||||
})}
|
||||
className={cx(
|
||||
"p-1.5 rounded-md border flex items-stretch gap-1.5",
|
||||
{
|
||||
"border-gray-200": none,
|
||||
"bg-amber-600 border-amber-600": advertising,
|
||||
"bg-indigo-500 border-indigo-500": using,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Popover
|
||||
open={disabled ? false : open}
|
||||
onOpenChange={setOpen}
|
||||
className="overflow-hidden"
|
||||
side="bottom"
|
||||
sideOffset={0}
|
||||
align="start"
|
||||
content={
|
||||
<ExitNodeSelectorInner
|
||||
node={node}
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
}
|
||||
asChild
|
||||
<button
|
||||
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
|
||||
"bg-white hover:bg-stone-100": none,
|
||||
"bg-amber-600 hover:bg-orange-400": advertising,
|
||||
"bg-indigo-500 hover:bg-indigo-400": using,
|
||||
"cursor-not-allowed": disabled,
|
||||
})}
|
||||
onClick={() => setOpen(!open)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<button
|
||||
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
|
||||
"bg-white": none,
|
||||
"hover:bg-gray-100": none && !disabled,
|
||||
"bg-yellow-300": advertising && !offline,
|
||||
"hover:bg-yellow-200": advertising && !offline && !disabled,
|
||||
"bg-blue-500": using && !offline,
|
||||
"hover:bg-blue-400": using && !offline && !disabled,
|
||||
"bg-red-500": offline,
|
||||
"hover:bg-red-400": offline && !disabled,
|
||||
})}
|
||||
onClick={() => setOpen(!open)}
|
||||
disabled={disabled}
|
||||
<p
|
||||
className={cx(
|
||||
"text-neutral-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
|
||||
{ "bg-opacity-70 text-white": advertising || using }
|
||||
)}
|
||||
>
|
||||
Exit node
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className={cx(
|
||||
"text-gray-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
|
||||
{ "opacity-70 text-white": advertising || using }
|
||||
)}
|
||||
className={cx("text-neutral-800", {
|
||||
"text-white": advertising || using,
|
||||
})}
|
||||
>
|
||||
Exit node{offline && " offline"}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className={cx("text-gray-800", {
|
||||
"text-white": advertising || using,
|
||||
})}
|
||||
>
|
||||
{selected.Location && (
|
||||
<>
|
||||
<CountryFlag code={selected.Location.CountryCode} />{" "}
|
||||
</>
|
||||
)}
|
||||
{selected === runAsExitNode
|
||||
? "Running as exit node"
|
||||
: selected.Name}
|
||||
</p>
|
||||
{!disabled && (
|
||||
<ChevronDown
|
||||
className={cx("ml-1", {
|
||||
"stroke-gray-800": none,
|
||||
"stroke-white": advertising || using,
|
||||
})}
|
||||
/>
|
||||
{selected.Location && (
|
||||
<>
|
||||
<CountryFlag code={selected.Location.CountryCode} />{" "}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Popover>
|
||||
{!disabled && (advertising || using) && (
|
||||
{selected === runAsExitNode
|
||||
? "Running as exit node"
|
||||
: selected.Name}
|
||||
</p>
|
||||
<ChevronDown
|
||||
className={cx("ml-1", {
|
||||
"stroke-neutral-800": none,
|
||||
"stroke-white": advertising || using,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{(advertising || using) && (
|
||||
<button
|
||||
className={cx("px-3 py-2 rounded-sm text-white", {
|
||||
"hover:bg-yellow-200": advertising && !offline,
|
||||
"hover:bg-blue-400": using && !offline,
|
||||
"hover:bg-red-400": offline,
|
||||
"bg-orange-400": advertising,
|
||||
"bg-indigo-400": using,
|
||||
"cursor-not-allowed": disabled,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleSelect(noExitNode)
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{offline && (
|
||||
<p className="text-white p-3">
|
||||
The selected exit node is currently offline. Your internet traffic is
|
||||
blocked until you disable the exit node or select a different one.
|
||||
</p>
|
||||
)}
|
||||
{pending && (
|
||||
<p className="text-white p-3">
|
||||
Pending approval to run as exit node. This device won’t be usable as
|
||||
an exit node until then.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -217,7 +175,7 @@ function ExitNodeSelectorInner({
|
||||
onSelect: (node: ExitNode) => void
|
||||
}) {
|
||||
const [filter, setFilter] = useState<string>("")
|
||||
const { data: exitNodes } = useExitNodes(node, filter)
|
||||
const { data: exitNodes } = useExitNodes(node.TailnetName, filter)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const hasNodes = useMemo(
|
||||
@@ -226,12 +184,10 @@ function ExitNodeSelectorInner({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-[var(--radix-popover-trigger-width)]">
|
||||
<div className="w-[calc(var(--radix-popover-trigger-width)-16px)] py-1 rounded-lg shadow">
|
||||
<SearchInput
|
||||
name="exit-node-search"
|
||||
className="px-2"
|
||||
inputClassName="w-full py-3 !h-auto border-none rounded-b-none !ring-0"
|
||||
autoFocus
|
||||
inputClassName="w-full px-4 py-2"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
@@ -246,7 +202,7 @@ function ExitNodeSelectorInner({
|
||||
{/* TODO(sonia): use loading spinner when loading useExitNodes */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="pt-1 border-t border-gray-200 max-h-60 overflow-y-scroll"
|
||||
className="pt-1 border-t border-gray-200 max-h-64 overflow-y-scroll"
|
||||
>
|
||||
{hasNodes ? (
|
||||
exitNodes.map(
|
||||
@@ -254,10 +210,10 @@ function ExitNodeSelectorInner({
|
||||
group.nodes.length > 0 && (
|
||||
<div
|
||||
key={group.id}
|
||||
className="pb-1 mb-1 border-b last:border-b-0 border-gray-200 last:mb-0"
|
||||
className="pb-1 mb-1 border-b last:border-b-0 last:mb-0"
|
||||
>
|
||||
{group.name && (
|
||||
<div className="px-4 py-2 text-gray-500 text-xs font-medium uppercase tracking-wide">
|
||||
<div className="px-4 py-2 text-neutral-500 text-xs font-medium uppercase tracking-wide">
|
||||
{group.name}
|
||||
</div>
|
||||
)}
|
||||
@@ -296,16 +252,10 @@ function ExitNodeSelectorItem({
|
||||
return (
|
||||
<button
|
||||
key={node.ID}
|
||||
className={cx(
|
||||
"w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-gray-100",
|
||||
{
|
||||
"text-gray-400 cursor-not-allowed": !node.Online,
|
||||
}
|
||||
)}
|
||||
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
|
||||
onClick={onSelect}
|
||||
disabled={!node.Online}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div>
|
||||
{node.Location && (
|
||||
<>
|
||||
<CountryFlag code={node.Location.CountryCode} />{" "}
|
||||
@@ -313,8 +263,7 @@ function ExitNodeSelectorItem({
|
||||
)}
|
||||
<span className="leading-snug">{node.Name}</span>
|
||||
</div>
|
||||
{node.Online || <span className="leading-snug">Offline</span>}
|
||||
{isSelected && <Check className="ml-1" />}
|
||||
{isSelected && <Check />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,14 @@
|
||||
// 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 { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
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/hooks/node-data"
|
||||
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 +21,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,323 +34,164 @@ 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-zinc-800 rounded-full flex justify-start items-center",
|
||||
{ "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 items-center inline-flex",
|
||||
{
|
||||
"bg-transparent": !open,
|
||||
"bg-neutral-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.IP}:5252/ok`, { mode: "no-cors" })
|
||||
.then(() => {
|
||||
setIsRunningCheck(false)
|
||||
setCanConnectOverTS(true)
|
||||
})
|
||||
.catch(() => setIsRunningCheck(false))
|
||||
}, [auth.viewerIdentity, isRunningCheck, node.IP])
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
newSession()
|
||||
} else {
|
||||
// Must be connected over Tailscale to log in.
|
||||
// If not already connected, reroute to the Tailscale IP
|
||||
// before sending user through check mode.
|
||||
window.location.href = `http://${node.IP}:5252/?check=now`
|
||||
}
|
||||
}, [node.IP, auth.viewerIdentity, newSession])
|
||||
|
||||
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 || auth.authNeeded === AuthType.tailscale ? (
|
||||
<>
|
||||
<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 className="text-neutral-500 text-xs">
|
||||
{auth.viewerIdentity ? (
|
||||
<>
|
||||
To make changes, sign in to confirm your identity. This extra
|
||||
step helps us keep your device secure.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
You can see most of this device's details. To make changes,
|
||||
you need to sign in.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<SignInButton auth={auth} onClick={handleLogin} />
|
||||
<button
|
||||
className={cx(
|
||||
"w-full px-3 py-2 bg-indigo-500 rounded shadow text-center text-white text-sm font-medium mt-2",
|
||||
{
|
||||
"mb-2": auth.viewerIdentity,
|
||||
"cursor-not-allowed": !canConnectOverTS,
|
||||
}
|
||||
)}
|
||||
onClick={handleSignInClick}
|
||||
// TODO: add some helper info when disabled
|
||||
// due to needing to connect to TS
|
||||
disabled={!canConnectOverTS}
|
||||
>
|
||||
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// User is connected over tailscale, but doesn't have permission to manage.
|
||||
<p className="text-gray-500 text-xs">
|
||||
<p className="text-neutral-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>
|
||||
can view most of its details.
|
||||
</p>
|
||||
))}
|
||||
<PopoverContentFooter auth={auth} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SignInButton({
|
||||
auth,
|
||||
onClick,
|
||||
}: {
|
||||
auth: AuthResponse
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className={cx("text-center w-full mt-2", {
|
||||
"mb-2": auth.viewerIdentity,
|
||||
})}
|
||||
intent="primary"
|
||||
sizeVariant="small"
|
||||
onClick={onClick}
|
||||
>
|
||||
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
|
||||
</Button>
|
||||
{auth.viewerIdentity && (
|
||||
<>
|
||||
<hr className="my-2" />
|
||||
<div className="flex items-center">
|
||||
<User className="flex-shrink-0" />
|
||||
<p className="text-neutral-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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import { isTailscaleIPv6 } from "src/utils/util"
|
||||
|
||||
type Props = {
|
||||
ip: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* NiceIP displays IP addresses with nice truncation.
|
||||
*/
|
||||
export default function NiceIP(props: Props) {
|
||||
const { ip, className } = props
|
||||
|
||||
if (!isTailscaleIPv6(ip)) {
|
||||
return <span className={className}>{ip}</span>
|
||||
}
|
||||
|
||||
const [trimmable, untrimmable] = splitIPv6(ip)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cx("inline-flex justify-start min-w-0 max-w-full", className)}
|
||||
>
|
||||
{trimmable.length > 0 && (
|
||||
<span className="truncate w-fit flex-shrink">{trimmable}</span>
|
||||
)}
|
||||
<span className="flex-grow-0 flex-shrink-0">{untrimmable}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an IPv6 address into two pieces, to help with truncating the middle.
|
||||
* Only exported for testing purposes. Do not use.
|
||||
*/
|
||||
export function splitIPv6(ip: string): [string, string] {
|
||||
// We want to split the IPv6 address into segments, but not remove the delimiter.
|
||||
// So we inject an invalid IPv6 character ("|") as a delimiter into the string,
|
||||
// then split on that.
|
||||
const parts = ip.replace(/(:{1,2})/g, "|$1").split("|")
|
||||
|
||||
// Then we find the number of end parts that fits within the character limit,
|
||||
// and join them back together.
|
||||
const characterLimit = 12
|
||||
let characterCount = 0
|
||||
let idxFromEnd = 1
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (characterCount + part.length > characterLimit) {
|
||||
break
|
||||
}
|
||||
characterCount += part.length
|
||||
idxFromEnd++
|
||||
}
|
||||
|
||||
const start = parts.slice(0, -idxFromEnd).join("")
|
||||
const end = parts.slice(-idxFromEnd).join("")
|
||||
|
||||
return [start, end]
|
||||
}
|
||||
@@ -2,20 +2,16 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import { VersionInfo } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Card from "src/ui/card"
|
||||
import { useLocation } from "wouter"
|
||||
import { VersionInfo } from "src/hooks/self-update"
|
||||
import { Link } from "wouter"
|
||||
|
||||
export function UpdateAvailableNotification({
|
||||
details,
|
||||
}: {
|
||||
details: VersionInfo
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="card">
|
||||
<h2 className="mb-2">
|
||||
Update available{" "}
|
||||
{details.LatestVersion && `(v${details.LatestVersion})`}
|
||||
@@ -26,14 +22,13 @@ export function UpdateAvailableNotification({
|
||||
: "A new update"}{" "}
|
||||
is now available. <ChangelogText version={details.LatestVersion} />
|
||||
</p>
|
||||
<Button
|
||||
className="mt-3 inline-block"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/update")}
|
||||
<Link
|
||||
className="button button-blue mt-3 text-sm inline-block"
|
||||
to="/update"
|
||||
>
|
||||
Update now
|
||||
</Button>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,7 +56,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!
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,31 +3,27 @@
|
||||
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import { useAPI } from "src/api"
|
||||
import { apiFetch } from "src/api"
|
||||
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"
|
||||
import Dialog from "src/ui/dialog"
|
||||
import QuickCopy from "src/ui/quick-copy"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { useLocation } from "wouter"
|
||||
|
||||
export default function DeviceDetailsView({
|
||||
readonly,
|
||||
node,
|
||||
auth,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-10">Device details</h1>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card noPadding className="-mx-5 p-5 details-card">
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1>{node.DeviceName}</h1>
|
||||
@@ -38,16 +34,28 @@ export default function DeviceDetailsView({
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{canEdit("account", auth) && <DisconnectDialog />}
|
||||
<button
|
||||
className={cx(
|
||||
"px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-neutral-800 text-sm font-medium",
|
||||
{ "cursor-not-allowed": readonly }
|
||||
)}
|
||||
onClick={() =>
|
||||
apiFetch("/local/v0/logout", "POST")
|
||||
.then(() => setLocation("/"))
|
||||
.catch((err) => alert("Logout failed: " + err.message))
|
||||
}
|
||||
disabled={readonly}
|
||||
>
|
||||
Disconnect…
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
{node.Features["auto-update"] &&
|
||||
canEdit("account", auth) &&
|
||||
node.ClientVersion &&
|
||||
!node.ClientVersion.RunningLatest && (
|
||||
</div>
|
||||
{node.ClientVersion &&
|
||||
!node.ClientVersion.RunningLatest &&
|
||||
!readonly && (
|
||||
<UpdateAvailableNotification details={node.ClientVersion} />
|
||||
)}
|
||||
<Card noPadding className="-mx-5 p-5 details-card">
|
||||
<div className="card">
|
||||
<h2 className="mb-2">General</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
@@ -61,14 +69,7 @@ export default function DeviceDetailsView({
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Machine name</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.DeviceName}
|
||||
primaryActionSubject="machine name"
|
||||
>
|
||||
{node.DeviceName}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
<td>{node.DeviceName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OS</td>
|
||||
@@ -76,14 +77,7 @@ export default function DeviceDetailsView({
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.ID}
|
||||
primaryActionSubject="ID"
|
||||
>
|
||||
{node.ID}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
<td>{node.ID}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tailscale version</td>
|
||||
@@ -95,155 +89,48 @@ export default function DeviceDetailsView({
|
||||
{node.KeyExpired
|
||||
? "Expired"
|
||||
: // TODO: present as relative expiry (e.g. "5 months from now")
|
||||
node.KeyExpiry
|
||||
? new Date(node.KeyExpiry).toLocaleString()
|
||||
: "No expiry"}
|
||||
new Date(node.KeyExpiry).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
<Card noPadding className="-mx-5 p-5 details-card">
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2 className="mb-2">Addresses</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Tailscale IPv4</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.IPv4}
|
||||
primaryActionSubject="IPv4 address"
|
||||
>
|
||||
{node.IPv4}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
<td>{node.IP}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tailscale IPv6</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.IPv6}
|
||||
primaryActionSubject="IPv6 address"
|
||||
>
|
||||
<NiceIP ip={node.IPv6} />
|
||||
</QuickCopy>
|
||||
</td>
|
||||
<td>{node.IPv6}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Short domain</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.DeviceName}
|
||||
primaryActionSubject="short domain"
|
||||
>
|
||||
{node.DeviceName}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
<td>{node.DeviceName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Full domain</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={`${node.DeviceName}.${node.TailnetName}`}
|
||||
primaryActionSubject="full domain"
|
||||
>
|
||||
{node.DeviceName}.{node.TailnetName}
|
||||
</QuickCopy>
|
||||
{node.DeviceName}.{node.TailnetName}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
<Card noPadding className="-mx-5 p-5 details-card">
|
||||
<h2 className="mb-2">Debug</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>TUN Mode</td>
|
||||
<td>{node.TUNMode ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
{node.IsSynology && (
|
||||
<tr>
|
||||
<td>Synology Version</td>
|
||||
<td>{node.DSMVersion}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
<footer className="text-gray-500 text-sm leading-tight text-center">
|
||||
<Control.AdminContainer node={node}>
|
||||
Want even more details? Visit{" "}
|
||||
<Control.AdminLink node={node} path={`/machines/${node.IPv4}`}>
|
||||
this device’s page
|
||||
</Control.AdminLink>{" "}
|
||||
in the admin console.
|
||||
</Control.AdminContainer>
|
||||
<p className="mt-12">
|
||||
<a
|
||||
className="link"
|
||||
href={node.LicensesURL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Acknowledgements
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a
|
||||
className="link"
|
||||
href="https://tailscale.com/privacy-policy/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a
|
||||
className="link"
|
||||
href="https://tailscale.com/terms/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
</p>
|
||||
<p className="my-2">
|
||||
WireGuard is a registered trademark of Jason A. Donenfeld.
|
||||
</p>
|
||||
<p>
|
||||
© {new Date().getFullYear()} Tailscale Inc. All rights reserved.
|
||||
Tailscale is a registered trademark of Tailscale Inc.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
<Control.AdminContainer
|
||||
className="text-neutral-500 text-sm leading-tight text-center"
|
||||
node={node}
|
||||
>
|
||||
Want even more details? Visit{" "}
|
||||
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
|
||||
this device’s page
|
||||
</Control.AdminLink>{" "}
|
||||
in the admin console.
|
||||
</Control.AdminContainer>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DisconnectDialog() {
|
||||
const api = useAPI()
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="max-w-md"
|
||||
title="Log out"
|
||||
trigger={<Button sizeVariant="small">Log out…</Button>}
|
||||
>
|
||||
<Dialog.Form
|
||||
cancelButton
|
||||
submitButton="Log out"
|
||||
destructive
|
||||
onSubmit={() => {
|
||||
api({ action: "logout" })
|
||||
setLocation("/disconnected")
|
||||
}}
|
||||
>
|
||||
Logging out of this device will disconnect it from your tailnet and
|
||||
expire its node key. You won’t be able to use this web interface until
|
||||
you re-authenticate the device from either the Tailscale app or the
|
||||
Tailscale command line interface.
|
||||
</Dialog.Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
|
||||
|
||||
/**
|
||||
* DisconnectedView is rendered after node logout.
|
||||
*/
|
||||
export default function DisconnectedView() {
|
||||
return (
|
||||
<>
|
||||
<TailscaleIcon className="mx-auto" />
|
||||
<p className="mt-12 text-center text-text-muted">
|
||||
You logged out of this device. To reconnect it you will have to
|
||||
re-authenticate the device from either the Tailscale app or the
|
||||
Tailscale command line interface.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,133 +2,79 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
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 AddressCard from "src/components/address-copy-card"
|
||||
import React from "react"
|
||||
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
|
||||
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"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import { Link } from "wouter"
|
||||
|
||||
export default function HomeView({
|
||||
readonly,
|
||||
node,
|
||||
auth,
|
||||
nodeUpdaters,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
nodeUpdaters: NodeUpdaters
|
||||
}) {
|
||||
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
|
||||
() => [
|
||||
node.AdvertisedRoutes?.length,
|
||||
node.AdvertisedRoutes?.filter((r) => !r.Approved).length,
|
||||
],
|
||||
[node.AdvertisedRoutes]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mb-12 w-full">
|
||||
<h2 className="mb-3">This device</h2>
|
||||
<Card noPadding className="-mx-5 p-5 mb-9">
|
||||
<div className="-mx-5 card mb-9">
|
||||
<div className="flex justify-between items-center text-lg mb-5">
|
||||
<Link className="flex items-center" to="/details">
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-full justify-center items-center inline-flex">
|
||||
<Machine />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ConnectedDeviceIcon />
|
||||
<div className="ml-3">
|
||||
<div className="text-gray-800 text-lg font-medium leading-snug">
|
||||
{node.DeviceName}
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm leading-[18.20px] flex items-center gap-2">
|
||||
<span
|
||||
className={cx("w-2 h-2 inline-block rounded-full", {
|
||||
"bg-green-300": node.Status === "Running",
|
||||
"bg-gray-300": node.Status !== "Running",
|
||||
})}
|
||||
/>
|
||||
{node.Status === "Running" ? "Connected" : "Offline"}
|
||||
</p>
|
||||
<h1>{node.DeviceName}</h1>
|
||||
{/* TODO(sonia): display actual status */}
|
||||
<p className="text-neutral-500 text-sm">Connected</p>
|
||||
</div>
|
||||
</Link>
|
||||
<AddressCard
|
||||
className="-mr-2"
|
||||
triggerClassName="relative text-gray-800 text-lg leading-[25.20px]"
|
||||
v4Address={node.IPv4}
|
||||
v6Address={node.IPv6}
|
||||
shortDomain={node.DeviceName}
|
||||
fullDomain={`${node.DeviceName}.${node.TailnetName}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-neutral-800 text-lg leading-[25.20px]">
|
||||
{node.IP}
|
||||
</p>
|
||||
</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}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<Link
|
||||
className="link font-medium"
|
||||
className="text-indigo-500 font-medium leading-snug"
|
||||
to="/details"
|
||||
onClick={() => apiFetch("/device-details-click", "POST")}
|
||||
>
|
||||
View device details →
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
<h2 className="mb-3">Settings</h2>
|
||||
<div className="grid gap-3">
|
||||
{node.Features["advertise-routes"] && (
|
||||
<SettingsCard
|
||||
link="/subnets"
|
||||
title="Subnet router"
|
||||
body="Add devices to your tailnet without installing Tailscale on them."
|
||||
badge={
|
||||
allSubnetRoutes
|
||||
? {
|
||||
text: `${allSubnetRoutes} ${pluralize(
|
||||
"route",
|
||||
"routes",
|
||||
allSubnetRoutes
|
||||
)}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
footer={
|
||||
pendingSubnetRoutes
|
||||
? `${pendingSubnetRoutes} ${pluralize(
|
||||
"route",
|
||||
"routes",
|
||||
pendingSubnetRoutes
|
||||
)} pending approval`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{node.Features["ssh"] && (
|
||||
<SettingsCard
|
||||
link="/ssh"
|
||||
title="Tailscale SSH server"
|
||||
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
|
||||
badge={
|
||||
node.RunningSSHServer
|
||||
? {
|
||||
text: "Running",
|
||||
icon: <div className="w-2 h-2 bg-green-300 rounded-full" />,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
|
||||
{/* <SettingsCard
|
||||
<SettingsCard
|
||||
link="/subnets"
|
||||
className="mb-3"
|
||||
title="Subnet router"
|
||||
body="Add devices to your tailnet without installing Tailscale on them."
|
||||
/>
|
||||
<SettingsCard
|
||||
link="/ssh"
|
||||
className="mb-3"
|
||||
title="Tailscale SSH server"
|
||||
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
|
||||
badge={
|
||||
node.RunningSSHServer
|
||||
? {
|
||||
text: "Running",
|
||||
icon: <div className="w-2 h-2 bg-emerald-500 rounded-full" />,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
|
||||
{/* <SettingsCard
|
||||
link="/serve"
|
||||
title="Share local content"
|
||||
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -138,7 +84,6 @@ function SettingsCard({
|
||||
link,
|
||||
body,
|
||||
badge,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
@@ -148,42 +93,35 @@ function SettingsCard({
|
||||
text: string
|
||||
icon?: JSX.Element
|
||||
}
|
||||
footer?: string
|
||||
className?: string
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
return (
|
||||
<button onClick={() => setLocation(link)}>
|
||||
<Card noPadding className={cx("-mx-5 p-5", className)}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<p className="text-gray-800 font-medium leading-tight mb-2">
|
||||
{title}
|
||||
</p>
|
||||
{badge && (
|
||||
<div className="h-5 px-2 bg-gray-100 rounded-full flex items-center gap-2">
|
||||
{badge.icon}
|
||||
<div className="text-gray-500 text-xs font-medium">
|
||||
{badge.text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
to={link}
|
||||
className={cx(
|
||||
"-mx-5 card flex justify-between items-center cursor-pointer",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<p className="text-neutral-800 font-medium leading-tight mb-2">
|
||||
{title}
|
||||
</p>
|
||||
{badge && (
|
||||
<div className="h-5 px-2 bg-stone-100 rounded-full flex items-center gap-2">
|
||||
{badge.icon}
|
||||
<div className="text-neutral-500 text-xs font-medium">
|
||||
{badge.text}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm leading-tight">{body}</p>
|
||||
</div>
|
||||
<div>
|
||||
<ArrowRight className="ml-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{footer && (
|
||||
<>
|
||||
<hr className="my-3" />
|
||||
<div className="text-gray-500 text-sm leading-tight">{footer}</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</button>
|
||||
<p className="text-neutral-500 text-sm leading-tight">{body}</p>
|
||||
</div>
|
||||
<div>
|
||||
<ArrowRight className="ml-3" />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React, { useState } from "react"
|
||||
import { useAPI } from "src/api"
|
||||
import TailscaleIcon from "src/assets/icons/tailscale-icon.svg?react"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import Collapsible from "src/ui/collapsible"
|
||||
import Input from "src/ui/input"
|
||||
|
||||
@@ -13,11 +12,23 @@ import Input from "src/ui/input"
|
||||
* LoginView is rendered when the client is not authenticated
|
||||
* to a tailnet.
|
||||
*/
|
||||
export default function LoginView({ data }: { data: NodeData }) {
|
||||
const api = useAPI()
|
||||
export default function LoginView({
|
||||
data,
|
||||
refreshData,
|
||||
}: {
|
||||
data: NodeData
|
||||
refreshData: () => void
|
||||
}) {
|
||||
const [controlURL, setControlURL] = useState<string>("")
|
||||
const [authKey, setAuthKey] = useState<string>("")
|
||||
|
||||
const login = useCallback(
|
||||
(opt: TailscaleUpOptions) => {
|
||||
tailscaleUp(opt).then(refreshData)
|
||||
},
|
||||
[refreshData]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
<TailscaleIcon className="my-2 mb-8" />
|
||||
@@ -29,19 +40,18 @@ export default function LoginView({ data }: { data: NodeData }) {
|
||||
Your device is disconnected from Tailscale.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => api({ action: "up", data: {} })}
|
||||
className="w-full mb-4"
|
||||
intent="primary"
|
||||
<button
|
||||
onClick={() => login({})}
|
||||
className="button button-blue w-full mb-4"
|
||||
>
|
||||
Connect to Tailscale
|
||||
</Button>
|
||||
</button>
|
||||
</>
|
||||
) : data.IPv4 ? (
|
||||
) : data.IP ? (
|
||||
<>
|
||||
<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"
|
||||
@@ -54,15 +64,12 @@ export default function LoginView({ data }: { data: NodeData }) {
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
api({ action: "up", data: { Reauthenticate: true } })
|
||||
}
|
||||
className="w-full mb-4"
|
||||
intent="primary"
|
||||
<button
|
||||
onClick={() => login({ Reauthenticate: true })}
|
||||
className="button button-blue w-full mb-4"
|
||||
>
|
||||
Reauthenticate
|
||||
</Button>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -82,22 +89,18 @@ export default function LoginView({ data }: { data: NodeData }) {
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
<button
|
||||
onClick={() =>
|
||||
api({
|
||||
action: "up",
|
||||
data: {
|
||||
Reauthenticate: true,
|
||||
ControlURL: controlURL,
|
||||
AuthKey: authKey,
|
||||
},
|
||||
login({
|
||||
Reauthenticate: true,
|
||||
ControlURL: controlURL,
|
||||
AuthKey: authKey,
|
||||
})
|
||||
}
|
||||
className="w-full mb-4"
|
||||
intent="primary"
|
||||
className="button button-blue w-full mb-4"
|
||||
>
|
||||
Log In
|
||||
</Button>
|
||||
</button>
|
||||
<Collapsible trigger="Advanced options">
|
||||
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
@@ -131,3 +134,20 @@ export default function LoginView({ data }: { data: NodeData }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TailscaleUpOptions = {
|
||||
Reauthenticate?: boolean // force reauthentication
|
||||
ControlURL?: string
|
||||
AuthKey?: string
|
||||
}
|
||||
|
||||
function tailscaleUp(options: TailscaleUpOptions) {
|
||||
return apiFetch("/up", "POST", options)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
d.url && window.open(d.url, "_blank")
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to login:", e)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import { useAPI } from "src/api"
|
||||
import * as Control from "src/components/control-components"
|
||||
import { NodeData } from "src/types"
|
||||
import Card from "src/ui/card"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import Toggle from "src/ui/toggle"
|
||||
|
||||
export default function SSHView({
|
||||
readonly,
|
||||
node,
|
||||
nodeUpdaters,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
nodeUpdaters: NodeUpdaters
|
||||
}) {
|
||||
const api = useAPI()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-1">Tailscale SSH server</h1>
|
||||
@@ -26,56 +23,38 @@ export default function SSHView({
|
||||
your tailnet to SSH into it.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1193/tailscale-ssh/"
|
||||
className="text-blue-700"
|
||||
className="text-indigo-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
<Card noPadding className="-mx-5 p-5">
|
||||
{!readonly ? (
|
||||
<label className="flex gap-3 items-center">
|
||||
<Toggle
|
||||
checked={node.RunningSSHServer}
|
||||
onChange={() =>
|
||||
api({
|
||||
action: "update-prefs",
|
||||
data: {
|
||||
RunSSHSet: true,
|
||||
RunSSH: !node.RunningSSHServer,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="text-black text-sm font-medium leading-tight">
|
||||
Run Tailscale SSH server
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<span
|
||||
className={cx("w-2 h-2 rounded-full", {
|
||||
"bg-green-300": node.RunningSSHServer,
|
||||
"bg-gray-300": !node.RunningSSHServer,
|
||||
})}
|
||||
/>
|
||||
{node.RunningSSHServer ? "Running" : "Not running"}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{node.RunningSSHServer && (
|
||||
<Control.AdminContainer
|
||||
className="text-gray-500 text-sm leading-tight mt-3"
|
||||
node={node}
|
||||
>
|
||||
Remember to make sure that the{" "}
|
||||
<Control.AdminLink node={node} path="/acls">
|
||||
tailnet policy file
|
||||
</Control.AdminLink>{" "}
|
||||
allows other devices to SSH into this device.
|
||||
</Control.AdminContainer>
|
||||
)}
|
||||
<div className="-mx-5 px-4 py-3 bg-white rounded-lg border border-gray-200 flex gap-2.5 mb-3">
|
||||
<Toggle
|
||||
checked={node.RunningSSHServer}
|
||||
onChange={() =>
|
||||
nodeUpdaters.patchPrefs({
|
||||
RunSSHSet: true,
|
||||
RunSSH: !node.RunningSSHServer,
|
||||
})
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<div className="text-black text-sm font-medium leading-tight">
|
||||
Run Tailscale SSH server
|
||||
</div>
|
||||
</div>
|
||||
<Control.AdminContainer
|
||||
className="text-neutral-500 text-sm leading-tight"
|
||||
node={node}
|
||||
>
|
||||
Remember to make sure that the{" "}
|
||||
<Control.AdminLink node={node} path="/acls">
|
||||
tailnet policy file
|
||||
</Control.AdminLink>{" "}
|
||||
allows other devices to SSH into this device.
|
||||
</Control.AdminContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
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 React, { useMemo, useState } from "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 { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import Button from "src/ui/button"
|
||||
import Card from "src/ui/card"
|
||||
import Dialog from "src/ui/dialog"
|
||||
import EmptyState from "src/ui/empty-state"
|
||||
import Input from "src/ui/input"
|
||||
|
||||
export default function SubnetRouterView({
|
||||
readonly,
|
||||
node,
|
||||
nodeUpdaters,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
nodeUpdaters: NodeUpdaters
|
||||
}) {
|
||||
const api = useAPI()
|
||||
|
||||
const [advertisedRoutes, hasRoutes, hasUnapprovedRoutes] = useMemo(() => {
|
||||
const routes = node.AdvertisedRoutes || []
|
||||
return [routes, routes.length > 0, routes.find((r) => !r.Approved)]
|
||||
}, [node.AdvertisedRoutes])
|
||||
|
||||
const advertisedRoutes = useMemo(
|
||||
() => node.AdvertisedRoutes || [],
|
||||
[node.AdvertisedRoutes]
|
||||
)
|
||||
const [inputOpen, setInputOpen] = useState<boolean>(
|
||||
advertisedRoutes.length === 0 && !readonly
|
||||
)
|
||||
const [inputText, setInputText] = useState<string>("")
|
||||
const [postError, setPostError] = useState<string>()
|
||||
|
||||
const resetInput = useCallback(() => {
|
||||
setInputText("")
|
||||
setPostError("")
|
||||
setInputOpen(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -48,80 +35,59 @@ export default function SubnetRouterView({
|
||||
Add devices to your tailnet without installing Tailscale.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1019/subnets/"
|
||||
className="text-blue-700"
|
||||
className="text-indigo-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
{!readonly &&
|
||||
(inputOpen ? (
|
||||
<Card noPadding className="-mx-5 p-5 !border-0 shadow-popover">
|
||||
<p className="font-medium leading-snug mb-3">
|
||||
Advertise new routes
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
className="text-sm"
|
||||
placeholder="192.168.0.0/24"
|
||||
value={inputText}
|
||||
onChange={(e) => {
|
||||
setPostError("")
|
||||
setInputText(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={cx("my-2 h-6 text-sm leading-tight", {
|
||||
"text-gray-500": !postError,
|
||||
"text-red-400": postError,
|
||||
})}
|
||||
>
|
||||
{postError ||
|
||||
"Add multiple routes by providing a comma-separated list."}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
intent="primary"
|
||||
onClick={() =>
|
||||
api({
|
||||
action: "update-routes",
|
||||
data: [
|
||||
...advertisedRoutes,
|
||||
...inputText
|
||||
.split(",")
|
||||
.map((r) => ({ Route: r, Approved: false })),
|
||||
],
|
||||
})
|
||||
.then(resetInput)
|
||||
.catch((err: Error) => setPostError(err.message))
|
||||
}
|
||||
disabled={!inputText || postError !== ""}
|
||||
>
|
||||
Advertise {hasRoutes && "new "}routes
|
||||
</Button>
|
||||
{hasRoutes && <Button onClick={resetInput}>Cancel</Button>}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
{inputOpen ? (
|
||||
<div className="-mx-5 card shadow">
|
||||
<p className="font-medium leading-snug mb-3">Advertise new routes</p>
|
||||
<Input
|
||||
type="text"
|
||||
className="text-sm"
|
||||
placeholder="192.168.0.0/24"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
/>
|
||||
<p className="my-2 h-6 text-neutral-500 text-sm leading-tight">
|
||||
Add multiple routes by providing a comma-separated list.
|
||||
</p>
|
||||
<Button
|
||||
intent="primary"
|
||||
prefixIcon={<Plus />}
|
||||
onClick={() => setInputOpen(true)}
|
||||
onClick={() =>
|
||||
nodeUpdaters
|
||||
.postSubnetRoutes([
|
||||
...advertisedRoutes.map((r) => r.Route),
|
||||
...inputText.split(","),
|
||||
])
|
||||
.then(() => {
|
||||
setInputText("")
|
||||
setInputOpen(false)
|
||||
})
|
||||
}
|
||||
disabled={readonly || !inputText}
|
||||
>
|
||||
Advertise new routes
|
||||
Advertise routes
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setInputOpen(true)} disabled={readonly}>
|
||||
<Plus />
|
||||
Advertise new route
|
||||
</Button>
|
||||
)}
|
||||
<div className="-mx-5 mt-10">
|
||||
{hasRoutes ? (
|
||||
{advertisedRoutes.length > 0 ? (
|
||||
<>
|
||||
<Card noPadding className="px-5 py-3">
|
||||
<div className="px-5 py-3 bg-white rounded-lg border border-gray-200">
|
||||
{advertisedRoutes.map((r) => (
|
||||
<div
|
||||
className="flex justify-between items-center pb-2.5 mb-2.5 border-b border-b-gray-200 last:pb-0 last:mb-0 last:border-b-0"
|
||||
key={r.Route}
|
||||
>
|
||||
<div className="text-gray-800 leading-snug">{r.Route}</div>
|
||||
<div className="text-neutral-800 leading-snug">{r.Route}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{r.Approved ? (
|
||||
@@ -130,69 +96,50 @@ export default function SubnetRouterView({
|
||||
<Clock className="w-4 h-4" />
|
||||
)}
|
||||
{r.Approved ? (
|
||||
<div className="text-green-500 text-sm leading-tight">
|
||||
<div className="text-emerald-800 text-sm leading-tight">
|
||||
Approved
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm leading-tight">
|
||||
<div className="text-neutral-500 text-sm leading-tight">
|
||||
Pending approval
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<StopAdvertisingDialog
|
||||
onSubmit={() =>
|
||||
api({
|
||||
action: "update-routes",
|
||||
data: advertisedRoutes.filter(
|
||||
(it) => it.Route !== r.Route
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
intent="secondary"
|
||||
className="text-sm font-medium"
|
||||
onClick={() =>
|
||||
nodeUpdaters.postSubnetRoutes(
|
||||
advertisedRoutes
|
||||
.map((it) => it.Route)
|
||||
.filter((it) => it !== r.Route)
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
>
|
||||
Stop advertising…
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
{hasUnapprovedRoutes && (
|
||||
<Control.AdminContainer
|
||||
className="mt-3 w-full text-center text-gray-500 text-sm leading-tight"
|
||||
node={node}
|
||||
>
|
||||
To approve routes, in the admin console go to{" "}
|
||||
<Control.AdminLink node={node} path={`/machines/${node.IPv4}`}>
|
||||
the machine’s route settings
|
||||
</Control.AdminLink>
|
||||
.
|
||||
</Control.AdminContainer>
|
||||
)}
|
||||
</div>
|
||||
<Control.AdminContainer
|
||||
className="mt-3 w-full text-center text-neutral-500 text-sm leading-tight"
|
||||
node={node}
|
||||
>
|
||||
To approve routes, in the admin console go to{" "}
|
||||
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
|
||||
the machine’s route settings
|
||||
</Control.AdminLink>
|
||||
.
|
||||
</Control.AdminContainer>
|
||||
</>
|
||||
) : (
|
||||
<Card empty>
|
||||
<EmptyState description="Not advertising any routes" />
|
||||
</Card>
|
||||
<div className="px-5 py-4 bg-stone-50 rounded-lg border border-gray-200 text-center text-neutral-500">
|
||||
Not advertising any routes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StopAdvertisingDialog({ onSubmit }: { onSubmit: () => void }) {
|
||||
return (
|
||||
<Dialog
|
||||
className="max-w-md"
|
||||
title="Stop advertising route"
|
||||
trigger={<Button sizeVariant="small">Stop advertising…</Button>}
|
||||
>
|
||||
<Dialog.Form
|
||||
cancelButton
|
||||
submitButton="Stop advertising"
|
||||
destructive
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
Any active connections between devices over this route will be broken.
|
||||
</Dialog.Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
// 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"
|
||||
import Button from "src/ui/button"
|
||||
import {
|
||||
UpdateState,
|
||||
useInstallUpdate,
|
||||
VersionInfo,
|
||||
} from "src/hooks/self-update"
|
||||
import Spinner from "src/ui/spinner"
|
||||
import { useLocation } from "wouter"
|
||||
import { Link } from "wouter"
|
||||
|
||||
/**
|
||||
* UpdatingView is rendered when the user initiates a Tailscale update, and
|
||||
@@ -22,7 +24,6 @@ export function UpdatingView({
|
||||
versionInfo?: VersionInfo
|
||||
currentVersion: string
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
const { updateState, updateLog } = useInstallUpdate(
|
||||
currentVersion,
|
||||
versionInfo
|
||||
@@ -35,7 +36,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>
|
||||
</>
|
||||
@@ -50,13 +51,9 @@ export function UpdatingView({
|
||||
: null}
|
||||
. <ChangelogText version={versionInfo?.LatestVersion} />
|
||||
</p>
|
||||
<Button
|
||||
className="m-3"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/")}
|
||||
>
|
||||
<Link className="button button-blue text-sm m-3" to="/">
|
||||
Log in to access
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : updateState === UpdateState.UpToDate ? (
|
||||
<>
|
||||
@@ -66,13 +63,9 @@ export function UpdatingView({
|
||||
You are already running Tailscale {currentVersion}, which is the
|
||||
newest version available.
|
||||
</p>
|
||||
<Button
|
||||
className="m-3"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/")}
|
||||
>
|
||||
<Link className="button button-blue text-sm m-3" to="/">
|
||||
Return
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
/* TODO(naman,sonia): Figure out the body copy and design for this view. */
|
||||
@@ -86,13 +79,9 @@ export function UpdatingView({
|
||||
: null}{" "}
|
||||
failed.
|
||||
</p>
|
||||
<Button
|
||||
className="m-3"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/")}
|
||||
>
|
||||
<Link className="button button-blue text-sm m-3" to="/">
|
||||
Return
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<pre className="h-64 overflow-scroll m-3">
|
||||
|
||||
@@ -4,70 +4,45 @@
|
||||
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
|
||||
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)
|
||||
const [ranSynoAuth, setRanSynoAuth] = useState<boolean>(false)
|
||||
|
||||
const loadAuth = useCallback(() => {
|
||||
setLoading(true)
|
||||
return apiFetch<AuthResponse>("/auth", "GET")
|
||||
return apiFetch("/auth", "GET")
|
||||
.then((r) => r.json())
|
||||
.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 as AuthResponse).authNeeded) {
|
||||
case AuthType.synology:
|
||||
fetch("/webman/login.cgi")
|
||||
.then((r) => r.json())
|
||||
.then((a) => {
|
||||
setSynoToken(a.SynoToken)
|
||||
setLoading(false)
|
||||
})
|
||||
break
|
||||
default:
|
||||
setLoading(false)
|
||||
}
|
||||
return d
|
||||
})
|
||||
@@ -78,16 +53,15 @@ export default function useAuth() {
|
||||
}, [])
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
return apiFetch<{ authUrl?: string }>("/auth/session/new", "GET")
|
||||
return apiFetch("/auth/session/new", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (d.authUrl) {
|
||||
window.open(d.authUrl, "_blank")
|
||||
return apiFetch("/auth/session/wait", "GET")
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
loadAuth()
|
||||
})
|
||||
.then(() => loadAuth())
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
@@ -95,13 +69,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()
|
||||
@@ -110,11 +79,6 @@ export default function useAuth() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuth() // Refresh auth state after syno auth runs
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ranSynoAuth])
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
CityCode,
|
||||
CountryCode,
|
||||
ExitNode,
|
||||
ExitNodeLocation,
|
||||
NodeData,
|
||||
} from "src/types"
|
||||
import useSWR from "swr"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
|
||||
export default function useExitNodes(node: NodeData, filter?: string) {
|
||||
const { data } = useSWR<ExitNode[]>("/exit-nodes")
|
||||
export type ExitNode = {
|
||||
ID: string
|
||||
Name: string
|
||||
Location?: ExitNodeLocation
|
||||
Online?: boolean
|
||||
}
|
||||
|
||||
type ExitNodeLocation = {
|
||||
Country: string
|
||||
CountryCode: CountryCode
|
||||
City: string
|
||||
CityCode: CityCode
|
||||
Priority: number
|
||||
}
|
||||
|
||||
type CountryCode = string
|
||||
type CityCode = string
|
||||
|
||||
export type ExitNodeGroup = {
|
||||
id: string
|
||||
name?: string
|
||||
nodes: ExitNode[]
|
||||
}
|
||||
|
||||
export default function useExitNodes(tailnetName: string, filter?: string) {
|
||||
const [data, setData] = useState<ExitNode[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch("/exit-nodes", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((r) => setData(r))
|
||||
.catch((err) => {
|
||||
alert("Failed operation: " + err.message)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const { tailnetNodesSorted, locationNodesMap } = useMemo(() => {
|
||||
// First going through exit nodes and splitting them into two groups:
|
||||
@@ -21,14 +47,6 @@ export default function useExitNodes(node: NodeData, filter?: string) {
|
||||
let tailnetNodes: ExitNode[] = []
|
||||
const locationNodes = new Map<CountryCode, Map<CityCode, ExitNode[]>>()
|
||||
|
||||
if (!node.Features["use-exit-node"]) {
|
||||
// early-return
|
||||
return {
|
||||
tailnetNodesSorted: tailnetNodes,
|
||||
locationNodesMap: locationNodes,
|
||||
}
|
||||
}
|
||||
|
||||
data?.forEach((n) => {
|
||||
const loc = n.Location
|
||||
if (!loc) {
|
||||
@@ -37,7 +55,7 @@ export default function useExitNodes(node: NodeData, filter?: string) {
|
||||
// Only Mullvad exit nodes have locations filled.
|
||||
tailnetNodes.push({
|
||||
...n,
|
||||
Name: trimDNSSuffix(n.Name, node.TailnetName),
|
||||
Name: trimDNSSuffix(n.Name, tailnetName),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -52,15 +70,12 @@ export default function useExitNodes(node: NodeData, filter?: string) {
|
||||
tailnetNodesSorted: tailnetNodes.sort(compareByName),
|
||||
locationNodesMap: locationNodes,
|
||||
}
|
||||
}, [data, node.Features, node.TailnetName])
|
||||
}, [data, tailnetName])
|
||||
|
||||
const hasFilter = Boolean(filter)
|
||||
|
||||
const mullvadNodesSorted = useMemo(() => {
|
||||
const nodes: ExitNode[] = []
|
||||
if (!node.Features["use-exit-node"]) {
|
||||
return nodes // early-return
|
||||
}
|
||||
|
||||
// addBestMatchNode adds the node with the "higest priority"
|
||||
// match from a list of exit node `options` to `nodes`.
|
||||
@@ -108,27 +123,14 @@ export default function useExitNodes(node: NodeData, filter?: string) {
|
||||
}
|
||||
|
||||
return nodes.sort(compareByName)
|
||||
}, [hasFilter, locationNodesMap, node.Features])
|
||||
}, [hasFilter, locationNodesMap])
|
||||
|
||||
// Ordered and filtered grouping of exit nodes.
|
||||
const exitNodeGroups = useMemo(() => {
|
||||
const filterLower = !filter ? undefined : filter.toLowerCase()
|
||||
|
||||
const selfGroup = {
|
||||
id: "self",
|
||||
name: undefined,
|
||||
nodes: filter
|
||||
? []
|
||||
: !node.Features["advertise-exit-node"]
|
||||
? [noExitNode] // don't show "runAsExitNode" option
|
||||
: [noExitNode, runAsExitNode],
|
||||
}
|
||||
|
||||
if (!node.Features["use-exit-node"]) {
|
||||
return [selfGroup]
|
||||
}
|
||||
return [
|
||||
selfGroup,
|
||||
{ id: "self", nodes: filter ? [] : [noExitNode, runAsExitNode] },
|
||||
{
|
||||
id: "tailnet",
|
||||
nodes: filterLower
|
||||
@@ -147,7 +149,7 @@ export default function useExitNodes(node: NodeData, filter?: string) {
|
||||
: mullvadNodesSorted,
|
||||
},
|
||||
]
|
||||
}, [filter, node.Features, tailnetNodesSorted, mullvadNodesSorted])
|
||||
}, [tailnetNodesSorted, mullvadNodesSorted, filter])
|
||||
|
||||
return { data: exitNodeGroups }
|
||||
}
|
||||
@@ -195,10 +197,8 @@ export function trimDNSSuffix(s: string, tailnetDNSName: string): string {
|
||||
return s
|
||||
}
|
||||
|
||||
// Neither of these are really "online", but setting this makes them selectable.
|
||||
export const noExitNode: ExitNode = { ID: "NONE", Name: "None", Online: true }
|
||||
export const noExitNode: ExitNode = { ID: "NONE", Name: "None" }
|
||||
export const runAsExitNode: ExitNode = {
|
||||
ID: "RUNNING",
|
||||
Name: "Run as exit node",
|
||||
Online: true,
|
||||
Name: "Run as exit node…",
|
||||
}
|
||||
|
||||
209
client/web/src/hooks/node-data.ts
Normal file
209
client/web/src/hooks/node-data.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { apiFetch, setUnraidCsrfToken } from "src/api"
|
||||
import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes"
|
||||
import { VersionInfo } from "src/hooks/self-update"
|
||||
|
||||
export type NodeData = {
|
||||
Profile: UserProfile
|
||||
Status: NodeState
|
||||
DeviceName: string
|
||||
OS: string
|
||||
IP: string
|
||||
IPv6: string
|
||||
ID: string
|
||||
KeyExpiry: string
|
||||
KeyExpired: boolean
|
||||
UsingExitNode?: ExitNode
|
||||
AdvertisingExitNode: boolean
|
||||
AdvertisedRoutes?: SubnetRoute[]
|
||||
TUNMode: boolean
|
||||
IsSynology: boolean
|
||||
DSMVersion: number
|
||||
IsUnraid: boolean
|
||||
UnraidToken: string
|
||||
IPNVersion: string
|
||||
ClientVersion?: VersionInfo
|
||||
URLPrefix: string
|
||||
DomainName: string
|
||||
TailnetName: string
|
||||
IsTagged: boolean
|
||||
Tags: string[]
|
||||
RunningSSHServer: boolean
|
||||
ControlAdminURL: string
|
||||
LicensesURL: string
|
||||
}
|
||||
|
||||
type NodeState =
|
||||
| "NoState"
|
||||
| "NeedsLogin"
|
||||
| "NeedsMachineAuth"
|
||||
| "Stopped"
|
||||
| "Starting"
|
||||
| "Running"
|
||||
|
||||
export type UserProfile = {
|
||||
LoginName: string
|
||||
DisplayName: string
|
||||
ProfilePicURL: string
|
||||
}
|
||||
|
||||
export type SubnetRoute = {
|
||||
Route: string
|
||||
Approved: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* NodeUpdaters provides a set of mutation functions for a node.
|
||||
*
|
||||
* These functions handle both making the requested change, as well as
|
||||
* refreshing the app's node data state upon completion to reflect any
|
||||
* relevant changes in the UI.
|
||||
*/
|
||||
export type NodeUpdaters = {
|
||||
/**
|
||||
* patchPrefs updates node preferences.
|
||||
* Only provided preferences will be updated.
|
||||
* Similar to running the tailscale set command in the CLI.
|
||||
*/
|
||||
patchPrefs: (d: PrefsPATCHData) => Promise<void>
|
||||
/**
|
||||
* postExitNode updates the node's status as either using or
|
||||
* running as an exit node.
|
||||
*/
|
||||
postExitNode: (d: ExitNode) => Promise<void>
|
||||
/**
|
||||
* postSubnetRoutes updates the node's advertised subnet routes.
|
||||
*/
|
||||
postSubnetRoutes: (d: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
type PrefsPATCHData = {
|
||||
RunSSHSet?: boolean
|
||||
RunSSH?: boolean
|
||||
}
|
||||
|
||||
type RoutesPOSTData = {
|
||||
UseExitNode?: string
|
||||
AdvertiseExitNode?: boolean
|
||||
AdvertiseRoutes?: string[]
|
||||
}
|
||||
|
||||
// useNodeData returns basic data about the current node.
|
||||
export default function useNodeData() {
|
||||
const [data, setData] = useState<NodeData>()
|
||||
const [isPosting, setIsPosting] = useState<boolean>(false)
|
||||
|
||||
const refreshData = useCallback(
|
||||
() =>
|
||||
apiFetch("/data", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d: NodeData) => {
|
||||
setData(d)
|
||||
setUnraidCsrfToken(d.IsUnraid ? d.UnraidToken : undefined)
|
||||
})
|
||||
.catch((error) => console.error(error)),
|
||||
[setData]
|
||||
)
|
||||
|
||||
const prefsPATCH = useCallback(
|
||||
(d: PrefsPATCHData) => {
|
||||
setIsPosting(true)
|
||||
if (data) {
|
||||
const optimisticUpdates = data
|
||||
if (d.RunSSHSet) {
|
||||
optimisticUpdates.RunningSSHServer = Boolean(d.RunSSH)
|
||||
}
|
||||
// Reflect the pref change immediatley on the frontend,
|
||||
// then make the prefs PATCH. If the request fails,
|
||||
// data will be updated to it's previous value in
|
||||
// onComplete below.
|
||||
setData(optimisticUpdates)
|
||||
}
|
||||
|
||||
const onComplete = () => {
|
||||
setIsPosting(false)
|
||||
refreshData() // refresh data after PATCH finishes
|
||||
}
|
||||
|
||||
return apiFetch("/local/v0/prefs", "PATCH", d)
|
||||
.then(onComplete)
|
||||
.catch((err) => {
|
||||
onComplete()
|
||||
alert("Failed to update prefs")
|
||||
throw err
|
||||
})
|
||||
},
|
||||
[setIsPosting, refreshData, setData, data]
|
||||
)
|
||||
|
||||
const routesPOST = useCallback(
|
||||
(d: RoutesPOSTData) => {
|
||||
setIsPosting(true)
|
||||
const onComplete = () => {
|
||||
setIsPosting(false)
|
||||
refreshData() // refresh data after POST finishes
|
||||
}
|
||||
|
||||
return apiFetch("/routes", "POST", d)
|
||||
.then(onComplete)
|
||||
.catch((err) => {
|
||||
onComplete()
|
||||
alert("Failed to update routes")
|
||||
throw err
|
||||
})
|
||||
},
|
||||
[setIsPosting, refreshData]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
// Initial data load.
|
||||
refreshData()
|
||||
|
||||
// Refresh on browser tab focus.
|
||||
const onVisibilityChange = () => {
|
||||
document.visibilityState === "visible" && refreshData()
|
||||
}
|
||||
window.addEventListener("visibilitychange", onVisibilityChange)
|
||||
return () => {
|
||||
// Cleanup browser tab listener.
|
||||
window.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
}
|
||||
},
|
||||
// Run once.
|
||||
[refreshData]
|
||||
)
|
||||
|
||||
const nodeUpdaters: NodeUpdaters = useMemo(
|
||||
() => ({
|
||||
patchPrefs: prefsPATCH,
|
||||
postExitNode: (node) =>
|
||||
routesPOST({
|
||||
AdvertiseExitNode: node.ID === runAsExitNode.ID,
|
||||
UseExitNode:
|
||||
node.ID === noExitNode.ID || node.ID === runAsExitNode.ID
|
||||
? undefined
|
||||
: node.ID,
|
||||
AdvertiseRoutes: data?.AdvertisedRoutes?.map((r) => r.Route), // unchanged
|
||||
}),
|
||||
postSubnetRoutes: (routes) =>
|
||||
routesPOST({
|
||||
AdvertiseRoutes: routes,
|
||||
AdvertiseExitNode: data?.AdvertisingExitNode, // unchanged
|
||||
UseExitNode: data?.UsingExitNode?.ID, // unchanged
|
||||
}),
|
||||
}),
|
||||
[
|
||||
data?.AdvertisingExitNode,
|
||||
data?.AdvertisedRoutes,
|
||||
data?.UsingExitNode?.ID,
|
||||
prefsPATCH,
|
||||
routesPOST,
|
||||
]
|
||||
)
|
||||
|
||||
return { data, refreshData, nodeUpdaters, isPosting }
|
||||
}
|
||||
@@ -3,7 +3,13 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { VersionInfo } from "src/types"
|
||||
|
||||
// this type is deserialized from tailcfg.ClientVersion,
|
||||
// so it should not include fields not included in that type.
|
||||
export type VersionInfo = {
|
||||
RunningLatest: boolean
|
||||
LatestVersion?: string
|
||||
}
|
||||
|
||||
// see ipnstate.UpdateProgress
|
||||
export type UpdateProgress = {
|
||||
@@ -52,11 +58,12 @@ export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) {
|
||||
let tsAwayForPolls = 0
|
||||
let updateMessagesRead = 0
|
||||
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
let timer = 0
|
||||
|
||||
function poll() {
|
||||
apiFetch<UpdateProgress[]>("/local/v0/update/progress", "GET")
|
||||
.then((res) => {
|
||||
apiFetch("/local/v0/update/progress", "GET")
|
||||
.then((res) => res.json())
|
||||
.then((res: UpdateProgress[]) => {
|
||||
// res contains a list of UpdateProgresses that is strictly increasing
|
||||
// in size, so updateMessagesRead keeps track (across calls of poll())
|
||||
// of how many of those we have already read. This is why it is not
|
||||
@@ -116,7 +123,7 @@ export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) {
|
||||
// useEffect cleanup function
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
timer = 0
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useRawToasterForHook } from "src/ui/toaster"
|
||||
|
||||
/**
|
||||
* useToaster provides a mechanism to display toasts. It returns an object with
|
||||
* methods to show, dismiss, or clear all toasts:
|
||||
*
|
||||
* const toastKey = toaster.show({ message: "Hello world" })
|
||||
* toaster.dismiss(toastKey)
|
||||
* toaster.clear()
|
||||
*
|
||||
*/
|
||||
const useToaster = useRawToasterForHook
|
||||
|
||||
export default useToaster
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -14,182 +14,38 @@
|
||||
U+FFFD, U+E06B-E080, U+02E2, U+02E2, U+02B0, U+1D34, U+1D57, U+1D40,
|
||||
U+207F, U+1D3A, U+1D48, U+1D30, U+02B3, U+1D3F;
|
||||
}
|
||||
|
||||
html {
|
||||
/**
|
||||
* These lines force the page to occupy the full width of the browser,
|
||||
* ignoring the scrollbar, and prevent horizontal scrolling. This eliminates
|
||||
* shifting when moving between pages with a scrollbar and those without, by
|
||||
* ignoring the width of the scrollbar.
|
||||
*
|
||||
* It also disables horizontal scrolling of the body wholesale, so, as always
|
||||
* avoid content flowing off the page.
|
||||
*/
|
||||
width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-white: 255 255 255;
|
||||
|
||||
--color-gray-0: 250 249 248;
|
||||
--color-gray-50: 249 247 246;
|
||||
--color-gray-100: 247 245 244;
|
||||
--color-gray-200: 238 235 234;
|
||||
--color-gray-300: 218 214 213;
|
||||
--color-gray-400: 175 172 171;
|
||||
--color-gray-500: 112 110 109;
|
||||
--color-gray-600: 68 67 66;
|
||||
--color-gray-700: 46 45 45;
|
||||
--color-gray-800: 35 34 34;
|
||||
--color-gray-900: 31 30 30;
|
||||
|
||||
--color-red-0: 255 246 244;
|
||||
--color-red-50: 255 211 207;
|
||||
--color-red-100: 255 177 171;
|
||||
--color-red-200: 246 143 135;
|
||||
--color-red-300: 228 108 99;
|
||||
--color-red-400: 208 72 65;
|
||||
--color-red-500: 178 45 48;
|
||||
--color-red-600: 148 8 33;
|
||||
--color-red-700: 118 0 18;
|
||||
--color-red-800: 90 0 0;
|
||||
--color-red-900: 66 0 0;
|
||||
|
||||
--color-yellow-0: 252 249 233;
|
||||
--color-yellow-50: 248 229 185;
|
||||
--color-yellow-100: 239 192 120;
|
||||
--color-yellow-200: 229 153 62;
|
||||
--color-yellow-300: 217 121 23;
|
||||
--color-yellow-400: 187 85 4;
|
||||
--color-yellow-500: 152 55 5;
|
||||
--color-yellow-600: 118 43 11;
|
||||
--color-yellow-700: 87 31 13;
|
||||
--color-yellow-800: 58 22 7;
|
||||
--color-yellow-900: 58 22 7;
|
||||
|
||||
--color-orange-0: 255 250 238;
|
||||
--color-orange-50: 254 227 192;
|
||||
--color-orange-100: 248 184 134;
|
||||
--color-orange-200: 245 146 94;
|
||||
--color-orange-300: 229 111 74;
|
||||
--color-orange-400: 196 76 52;
|
||||
--color-orange-500: 158 47 40;
|
||||
--color-orange-600: 126 30 35;
|
||||
--color-orange-700: 93 22 27;
|
||||
--color-orange-800: 66 14 17;
|
||||
--color-orange-900: 66 14 17;
|
||||
|
||||
--color-green-0: 239 255 237;
|
||||
--color-green-50: 203 244 201;
|
||||
--color-green-100: 133 217 150;
|
||||
--color-green-200: 51 194 127;
|
||||
--color-green-300: 30 166 114;
|
||||
--color-green-400: 9 130 93;
|
||||
--color-green-500: 14 98 69;
|
||||
--color-green-600: 13 75 59;
|
||||
--color-green-700: 11 55 51;
|
||||
--color-green-800: 8 36 41;
|
||||
--color-green-900: 8 36 41;
|
||||
|
||||
--color-blue-0: 240 245 255;
|
||||
--color-blue-50: 206 222 253;
|
||||
--color-blue-100: 173 199 252;
|
||||
--color-blue-200: 133 170 245;
|
||||
--color-blue-300: 108 148 236;
|
||||
--color-blue-400: 90 130 222;
|
||||
--color-blue-500: 75 112 204;
|
||||
--color-blue-600: 63 93 179;
|
||||
--color-blue-700: 50 73 148;
|
||||
--color-blue-800: 37 53 112;
|
||||
--color-blue-900: 25 34 74;
|
||||
|
||||
--color-text-base: rgb(var(--color-gray-800) / 1);
|
||||
--color-text-muted: rgb(var(--color-gray-500) / 1);
|
||||
--color-text-disabled: rgb(var(--color-gray-400) / 1);
|
||||
--color-text-primary: rgb(var(--color-blue-600) / 1);
|
||||
--color-text-warning: rgb(var(--color-orange-600) / 1);
|
||||
--color-text-danger: rgb(var(--color-red-600) / 1);
|
||||
|
||||
--color-bg-app: rgb(var(--color-gray-100) / 1);
|
||||
--color-bg-menu-item-hover: rgb(var(--color-gray-100) / 1);
|
||||
|
||||
--color-border-base: rgb(var(--color-gray-200) / 1);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app-root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-text-base font-sans w-full antialiased;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.015em; /* Inter is a little loose by default */
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: rgba(97, 122, 255, 0.2);
|
||||
}
|
||||
|
||||
strong {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
button {
|
||||
text-align: inherit; /* don't center buttons by default */
|
||||
letter-spacing: inherit; /* inherit existing letter spacing, rather than using browser defaults */
|
||||
vertical-align: top; /* fix alignment of display: inline-block buttons */
|
||||
}
|
||||
|
||||
a:focus,
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
a:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-gray-800 text-[22px] font-medium leading-[30.80px];
|
||||
@apply text-neutral-800 text-[22px] font-medium leading-[30.80px];
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-gray-500 text-sm font-medium uppercase leading-tight tracking-wide;
|
||||
@apply text-neutral-500 text-sm font-medium uppercase leading-tight tracking-wide;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.details-card h1 {
|
||||
@apply text-gray-800 text-lg font-medium leading-snug;
|
||||
.card {
|
||||
@apply p-5 bg-white rounded-lg border border-gray-200;
|
||||
}
|
||||
.details-card h2 {
|
||||
@apply text-gray-500 text-xs font-semibold uppercase tracking-wide;
|
||||
.card h1 {
|
||||
@apply text-neutral-800 text-lg font-medium leading-snug;
|
||||
}
|
||||
.details-card table {
|
||||
@apply w-full;
|
||||
.card h2 {
|
||||
@apply text-neutral-500 text-xs font-semibold uppercase tracking-wide;
|
||||
}
|
||||
.details-card tbody {
|
||||
.card tbody {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
.details-card tr {
|
||||
@apply grid grid-flow-col grid-cols-3 gap-2;
|
||||
.card td:first-child {
|
||||
@apply w-40 text-neutral-500 text-sm leading-tight flex-shrink-0;
|
||||
}
|
||||
.details-card td:first-child {
|
||||
@apply text-gray-500 text-sm leading-tight truncate;
|
||||
}
|
||||
.details-card td:last-child {
|
||||
@apply col-span-2 text-gray-800 text-sm leading-tight;
|
||||
.card td:last-child {
|
||||
@apply text-neutral-800 text-sm leading-tight;
|
||||
}
|
||||
|
||||
.description {
|
||||
@apply text-gray-500 leading-snug;
|
||||
@apply text-neutral-500 leading-snug;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,21 +53,21 @@
|
||||
* You can use the -large and -small modifiers for size variants.
|
||||
*/
|
||||
.toggle {
|
||||
@apply appearance-none relative w-10 h-5 rounded-full bg-gray-300 cursor-pointer;
|
||||
@apply appearance-none relative w-10 h-5 rounded-full bg-neutral-300 cursor-pointer;
|
||||
transition: background-color 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.toggle:disabled {
|
||||
@apply bg-gray-200;
|
||||
@apply bg-neutral-200;
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
.toggle:checked {
|
||||
@apply bg-blue-500;
|
||||
@apply bg-indigo-500;
|
||||
}
|
||||
|
||||
.toggle:checked:disabled {
|
||||
@apply bg-blue-300;
|
||||
@apply bg-indigo-300;
|
||||
}
|
||||
|
||||
.toggle:focus {
|
||||
@@ -230,7 +86,7 @@
|
||||
}
|
||||
|
||||
.toggle:checked:disabled::after {
|
||||
@apply bg-blue-50;
|
||||
@apply bg-indigo-50;
|
||||
}
|
||||
|
||||
.toggle:enabled:active::after {
|
||||
@@ -289,39 +145,6 @@
|
||||
@apply w-[0.675rem] translate-x-[0.55rem];
|
||||
}
|
||||
|
||||
/**
|
||||
* .button encapsulates all the base button styles we use across the app.
|
||||
*/
|
||||
|
||||
.button {
|
||||
@apply relative inline-flex flex-nowrap items-center justify-center font-medium py-2 px-4 rounded-md border border-transparent text-center whitespace-nowrap;
|
||||
transition-property: background-color, border-color, color, box-shadow;
|
||||
transition-duration: 120ms;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.button:focus-visible {
|
||||
@apply outline-none ring;
|
||||
}
|
||||
.button:disabled {
|
||||
@apply pointer-events-none select-none;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
|
||||
.button-group .button {
|
||||
@apply min-w-[60px];
|
||||
}
|
||||
|
||||
.button-group .button:not(:first-child) {
|
||||
@apply rounded-l-none;
|
||||
}
|
||||
|
||||
.button-group .button:not(:last-child) {
|
||||
@apply rounded-r-none border-r-0;
|
||||
}
|
||||
|
||||
/**
|
||||
* .input defines default text input field styling. These styles should
|
||||
* correspond to .button, sharing a similar height and rounding, since .input
|
||||
@@ -357,104 +180,6 @@
|
||||
.input-error {
|
||||
@apply border-red-200;
|
||||
}
|
||||
|
||||
/**
|
||||
* .loading-dots creates a set of three dots that pulse for indicating loading
|
||||
* states where a more horizontal appearance is helpful.
|
||||
*/
|
||||
|
||||
.loading-dots {
|
||||
@apply inline-flex items-center;
|
||||
}
|
||||
|
||||
.loading-dots span {
|
||||
@apply inline-block w-[0.35rem] h-[0.35rem] rounded-full bg-current mx-[0.15em];
|
||||
animation-name: loading-dots-blink;
|
||||
animation-duration: 1.4s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(2) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(3) {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
@keyframes loading-dots-blink {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* .spinner creates a circular animated spinner, most often used to indicate a
|
||||
* loading state. The .spinner element must define a width, height, and
|
||||
* border-width for the spinner to apply.
|
||||
*/
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply border-transparent border-t-current border-l-current rounded-full;
|
||||
animation: spin 700ms linear infinite;
|
||||
}
|
||||
|
||||
/**
|
||||
* .link applies standard styling to links across the app. By default we unstyle
|
||||
* all anchor tags. While this might sound crazy for a website, it's _very_
|
||||
* helpful in an app, since anchor tags can be used to wrap buttons, icons,
|
||||
* and all manner of UI component. As a result, all anchor tags intended to look
|
||||
* like links should have a .link class.
|
||||
*/
|
||||
|
||||
.link {
|
||||
@apply text-text-primary;
|
||||
}
|
||||
|
||||
.link:hover,
|
||||
.link:active {
|
||||
@apply text-blue-700;
|
||||
}
|
||||
|
||||
.link-destructive {
|
||||
@apply text-text-danger;
|
||||
}
|
||||
|
||||
.link-destructive:hover,
|
||||
.link-destructive:active {
|
||||
@apply text-red-700;
|
||||
}
|
||||
|
||||
.link-fade {
|
||||
}
|
||||
|
||||
.link-fade:hover {
|
||||
@apply opacity-75;
|
||||
}
|
||||
|
||||
.link-underline {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
.link-underline:hover {
|
||||
@apply opacity-75;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -462,3 +187,150 @@
|
||||
@apply h-[2.375rem];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-Tailwind styles begin here.
|
||||
*/
|
||||
|
||||
.bg-gray-0 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
html {
|
||||
letter-spacing: -0.015em;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.link {
|
||||
--text-opacity: 1;
|
||||
color: #4b70cc;
|
||||
color: rgba(75, 112, 204, var(--text-opacity));
|
||||
}
|
||||
|
||||
.link:hover,
|
||||
.link:active {
|
||||
--text-opacity: 1;
|
||||
color: #19224a;
|
||||
color: rgba(25, 34, 74, var(--text-opacity));
|
||||
}
|
||||
|
||||
.link-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link-underline:hover,
|
||||
.link-underline:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-muted {
|
||||
/* same as text-gray-500 */
|
||||
--tw-text-opacity: 1;
|
||||
color: rgba(112, 110, 109, var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.link-muted:hover,
|
||||
.link-muted:active {
|
||||
/* same as text-gray-500 */
|
||||
--tw-text-opacity: 1;
|
||||
color: rgba(68, 67, 66, var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.button {
|
||||
font-weight: 500;
|
||||
padding-top: 0.45rem;
|
||||
padding-bottom: 0.45rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
border-color: transparent;
|
||||
transition-property: background-color, border-color, color, box-shadow;
|
||||
transition-duration: 120ms;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.button-blue {
|
||||
--bg-opacity: 1;
|
||||
background-color: #4b70cc;
|
||||
background-color: rgba(75, 112, 204, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #4b70cc;
|
||||
border-color: rgba(75, 112, 204, var(--border-opacity));
|
||||
--text-opacity: 1;
|
||||
color: #fff;
|
||||
color: rgba(255, 255, 255, var(--text-opacity));
|
||||
}
|
||||
|
||||
.button-blue:enabled:hover {
|
||||
--bg-opacity: 1;
|
||||
background-color: #3f5db3;
|
||||
background-color: rgba(63, 93, 179, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #3f5db3;
|
||||
border-color: rgba(63, 93, 179, var(--border-opacity));
|
||||
}
|
||||
|
||||
.button-blue:disabled {
|
||||
--text-opacity: 1;
|
||||
color: #cedefd;
|
||||
color: rgba(206, 222, 253, var(--text-opacity));
|
||||
--bg-opacity: 1;
|
||||
background-color: #6c94ec;
|
||||
background-color: rgba(108, 148, 236, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #6c94ec;
|
||||
border-color: rgba(108, 148, 236, var(--border-opacity));
|
||||
}
|
||||
|
||||
.button-red {
|
||||
background-color: #d04841;
|
||||
border-color: #d04841;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-red:enabled:hover {
|
||||
background-color: #b22d30;
|
||||
border-color: #b22d30;
|
||||
}
|
||||
|
||||
/**
|
||||
* .spinner creates a circular animated spinner, most often used to indicate a
|
||||
* loading state. The .spinner element must define a width, height, and
|
||||
* border-width for the spinner to apply.
|
||||
*/
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply border-transparent border-t-current border-l-current rounded-full;
|
||||
animation: spin 700ms linear infinite;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,7 @@
|
||||
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { swrConfig } from "src/api"
|
||||
import App from "src/components/app"
|
||||
import ToastProvider from "src/ui/toaster"
|
||||
import { SWRConfig } from "swr"
|
||||
|
||||
declare var window: any
|
||||
// This is used to determine if the react client is built.
|
||||
@@ -28,10 +25,6 @@ const root = createRoot(rootEl)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<SWRConfig value={swrConfig}>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</SWRConfig>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { assertNever } from "src/utils/util"
|
||||
|
||||
export type NodeData = {
|
||||
Profile: UserProfile
|
||||
Status: NodeState
|
||||
DeviceName: string
|
||||
OS: string
|
||||
IPv4: string
|
||||
IPv6: string
|
||||
ID: string
|
||||
KeyExpiry: string
|
||||
KeyExpired: boolean
|
||||
UsingExitNode?: ExitNode
|
||||
AdvertisingExitNode: boolean
|
||||
AdvertisingExitNodeApproved: boolean
|
||||
AdvertisedRoutes?: SubnetRoute[]
|
||||
TUNMode: boolean
|
||||
IsSynology: boolean
|
||||
DSMVersion: number
|
||||
IsUnraid: boolean
|
||||
UnraidToken: string
|
||||
IPNVersion: string
|
||||
ClientVersion?: VersionInfo
|
||||
URLPrefix: string
|
||||
DomainName: string
|
||||
TailnetName: string
|
||||
IsTagged: boolean
|
||||
Tags: string[]
|
||||
RunningSSHServer: boolean
|
||||
ControlAdminURL: string
|
||||
LicensesURL: string
|
||||
Features: { [key in Feature]: boolean } // value is true if given feature is available on this client
|
||||
ACLAllowsAnyIncomingTraffic: boolean
|
||||
}
|
||||
|
||||
export type NodeState =
|
||||
| "NoState"
|
||||
| "NeedsLogin"
|
||||
| "NeedsMachineAuth"
|
||||
| "Stopped"
|
||||
| "Starting"
|
||||
| "Running"
|
||||
|
||||
export type UserProfile = {
|
||||
LoginName: string
|
||||
DisplayName: string
|
||||
ProfilePicURL: string
|
||||
}
|
||||
|
||||
export type SubnetRoute = {
|
||||
Route: string
|
||||
Approved: boolean
|
||||
}
|
||||
|
||||
export type ExitNode = {
|
||||
ID: string
|
||||
Name: string
|
||||
Location?: ExitNodeLocation
|
||||
Online?: boolean
|
||||
}
|
||||
|
||||
export type ExitNodeLocation = {
|
||||
Country: string
|
||||
CountryCode: CountryCode
|
||||
City: string
|
||||
CityCode: CityCode
|
||||
Priority: number
|
||||
}
|
||||
|
||||
export type CountryCode = string
|
||||
export type CityCode = string
|
||||
|
||||
export type ExitNodeGroup = {
|
||||
id: string
|
||||
name?: string
|
||||
nodes: ExitNode[]
|
||||
}
|
||||
|
||||
export type Feature =
|
||||
| "advertise-exit-node"
|
||||
| "advertise-routes"
|
||||
| "use-exit-node"
|
||||
| "ssh"
|
||||
| "auto-update"
|
||||
|
||||
export const featureDescription = (f: Feature) => {
|
||||
switch (f) {
|
||||
case "advertise-exit-node":
|
||||
return "Advertising as an exit node"
|
||||
case "advertise-routes":
|
||||
return "Advertising subnet routes"
|
||||
case "use-exit-node":
|
||||
return "Using an exit node"
|
||||
case "ssh":
|
||||
return "Running a Tailscale SSH server"
|
||||
case "auto-update":
|
||||
return "Auto updating client versions"
|
||||
default:
|
||||
assertNever(f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VersionInfo type is deserialized from tailcfg.ClientVersion,
|
||||
* so it should not include fields not included in that type.
|
||||
*/
|
||||
export type VersionInfo = {
|
||||
RunningLatest: boolean
|
||||
LatestVersion?: string
|
||||
}
|
||||
@@ -2,148 +2,32 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { HTMLProps } from "react"
|
||||
import LoadingDots from "src/ui/loading-dots"
|
||||
import React, { ButtonHTMLAttributes } from "react"
|
||||
|
||||
type Props = {
|
||||
type?: "button" | "submit" | "reset"
|
||||
sizeVariant?: "input" | "small" | "medium" | "large"
|
||||
/**
|
||||
* variant is the visual style of the button. By default, this is a filled
|
||||
* button. For a less prominent button, use minimal.
|
||||
*/
|
||||
variant?: Variant
|
||||
/**
|
||||
* intent describes the semantic meaning of the button's action. For
|
||||
* dangerous or destructive actions, use danger. For actions that should
|
||||
* be the primary focus, use primary.
|
||||
*/
|
||||
intent?: Intent
|
||||
intent?: "primary" | "secondary"
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
active?: boolean
|
||||
/**
|
||||
* prefixIcon is an icon or piece of content shown at the start of a button.
|
||||
*/
|
||||
prefixIcon?: React.ReactNode
|
||||
/**
|
||||
* suffixIcon is an icon or piece of content shown at the end of a button.
|
||||
*/
|
||||
suffixIcon?: React.ReactNode
|
||||
/**
|
||||
* loading displays a loading indicator inside the button when set to true.
|
||||
* The sizing of the button is not affected by this prop.
|
||||
*/
|
||||
loading?: boolean
|
||||
/**
|
||||
* iconOnly indicates that the button contains only an icon. This is used to
|
||||
* adjust styles to be appropriate for an icon-only button.
|
||||
*/
|
||||
iconOnly?: boolean
|
||||
/**
|
||||
* textAlign align the text center or left. If left aligned, any icons will
|
||||
* move to the sides of the button.
|
||||
*/
|
||||
textAlign?: "center" | "left"
|
||||
} & HTMLProps<HTMLButtonElement>
|
||||
|
||||
export type Variant = "filled" | "minimal"
|
||||
export type Intent = "base" | "primary" | "warning" | "danger" | "black"
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, Props>((props, ref) => {
|
||||
const {
|
||||
className,
|
||||
variant = "filled",
|
||||
intent = "base",
|
||||
sizeVariant = "large",
|
||||
disabled,
|
||||
children,
|
||||
loading,
|
||||
active,
|
||||
iconOnly,
|
||||
prefixIcon,
|
||||
suffixIcon,
|
||||
textAlign,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const hasIcon = Boolean(prefixIcon || suffixIcon)
|
||||
export default function Button(props: Props) {
|
||||
const { intent = "primary", className, disabled, children, ...rest } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cx(
|
||||
"button",
|
||||
"px-3 py-2 rounded shadow justify-center items-center gap-2.5 inline-flex font-medium",
|
||||
{
|
||||
// base filled
|
||||
"bg-gray-0 border-gray-300 enabled:hover:bg-gray-100 enabled:hover:border-gray-300 enabled:hover:text-gray-900 disabled:border-gray-200 disabled:text-gray-400":
|
||||
intent === "base" && variant === "filled",
|
||||
"enabled:bg-gray-200 enabled:border-gray-300":
|
||||
intent === "base" && variant === "filled" && active,
|
||||
// primary filled
|
||||
"bg-blue-500 border-blue-500 text-white enabled:hover:bg-blue-600 enabled:hover:border-blue-600 disabled:text-blue-50 disabled:bg-blue-300 disabled:border-blue-300":
|
||||
intent === "primary" && variant === "filled",
|
||||
// danger filled
|
||||
"bg-red-400 border-red-400 text-white enabled:hover:bg-red-500 enabled:hover:border-red-500 disabled:text-red-50 disabled:bg-red-300 disabled:border-red-300":
|
||||
intent === "danger" && variant === "filled",
|
||||
// warning filled
|
||||
"bg-yellow-300 border-yellow-300 text-white enabled:hover:bg-yellow-400 enabled:hover:border-yellow-400 disabled:text-yellow-50 disabled:bg-yellow-200 disabled:border-yellow-200":
|
||||
intent === "warning" && variant === "filled",
|
||||
// black filled
|
||||
"bg-gray-800 border-gray-800 text-white enabled:hover:bg-gray-900 enabled:hover:border-gray-900 disabled:opacity-75":
|
||||
intent === "black" && variant === "filled",
|
||||
|
||||
// minimal button (base variant, black is also included because its not supported for minimal buttons)
|
||||
"bg-transparent border-transparent shadow-none disabled:border-transparent disabled:text-gray-400":
|
||||
variant === "minimal",
|
||||
"text-gray-700 enabled:focus-visible:bg-gray-100 enabled:hover:bg-gray-100 enabled:hover:text-gray-800":
|
||||
variant === "minimal" && (intent === "base" || intent === "black"),
|
||||
"enabled:bg-gray-200 border-gray-300":
|
||||
variant === "minimal" &&
|
||||
(intent === "base" || intent === "black") &&
|
||||
active,
|
||||
// primary minimal
|
||||
"text-blue-600 enabled:focus-visible:bg-blue-0 enabled:hover:bg-blue-0 enabled:hover:text-blue-800":
|
||||
variant === "minimal" && intent === "primary",
|
||||
// danger minimal
|
||||
"text-red-600 enabled:focus-visible:bg-red-0 enabled:hover:bg-red-0 enabled:hover:text-red-800":
|
||||
variant === "minimal" && intent === "danger",
|
||||
// warning minimal
|
||||
"text-yellow-600 enabled:focus-visible:bg-orange-0 enabled:hover:bg-orange-0 enabled:hover:text-orange-800":
|
||||
variant === "minimal" && intent === "warning",
|
||||
|
||||
// sizeVariants
|
||||
"px-3 py-[0.35rem]": sizeVariant === "medium",
|
||||
"h-input": sizeVariant === "input",
|
||||
"px-3 text-sm py-[0.35rem]": sizeVariant === "small",
|
||||
"button-active relative z-10": active === true,
|
||||
"px-3":
|
||||
iconOnly && (sizeVariant === "large" || sizeVariant === "input"),
|
||||
"px-2":
|
||||
iconOnly && (sizeVariant === "medium" || sizeVariant === "small"),
|
||||
"icon-parent gap-2": hasIcon,
|
||||
"bg-indigo-500 text-white": intent === "primary" && !disabled,
|
||||
"bg-indigo-400 text-indigo-200": intent === "primary" && disabled,
|
||||
"bg-stone-50 shadow border border-stone-200 text-neutral-800":
|
||||
intent === "secondary",
|
||||
"cursor-not-allowed": disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
>
|
||||
{prefixIcon && <span className="flex-shrink-0">{prefixIcon}</span>}
|
||||
{loading && (
|
||||
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-current" />
|
||||
)}
|
||||
{children && (
|
||||
<span
|
||||
className={cx({
|
||||
"text-transparent": loading === true,
|
||||
"text-left flex-1": textAlign === "left",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
{suffixIcon && <span className="flex-shrink-0">{suffixIcon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export default Button
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
elevated?: boolean
|
||||
empty?: boolean
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Card is a box with a border, rounded corners, and some padding. Use it to
|
||||
* group content into a single container and give it more importance. The
|
||||
* elevation prop gives it a box shadow, while the empty prop a light gray
|
||||
* background color.
|
||||
*
|
||||
* <Card>{content}</Card>
|
||||
* <Card elevated>{content}</Card>
|
||||
* <Card empty><EmptyState description="You don't have any keys" /></Card>
|
||||
*
|
||||
*/
|
||||
export default function Card(props: Props) {
|
||||
const { children, className, elevated, empty, noPadding } = props
|
||||
return (
|
||||
<div
|
||||
className={cx("rounded-md border", className, {
|
||||
"shadow-soft": elevated,
|
||||
"bg-gray-0": empty,
|
||||
"bg-white": !empty,
|
||||
"p-6": !noPadding,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -24,7 +24,7 @@ export default function Collapsible(props: CollapsibleProps) {
|
||||
onOpenChange?.(open)
|
||||
}}
|
||||
>
|
||||
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-gray-100 rounded text-sm font-medium pr-3 py-1 transition-colors">
|
||||
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-stone-100 rounded text-sm font-medium pr-3 py-1 transition-colors">
|
||||
<span className="ml-2 mr-1.5 group-hover:text-gray-500 -rotate-90 state-open:rotate-0">
|
||||
<ChevronDown strokeWidth={3} className="stroke-gray-400 w-4" />
|
||||
</span>
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
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 Button from "src/ui/button"
|
||||
import PortalContainerContext from "src/ui/portal-container-context"
|
||||
import { isObject } from "src/utils/util"
|
||||
|
||||
type ButtonProp = boolean | string | Partial<ComponentProps<typeof Button>>
|
||||
|
||||
/**
|
||||
* ControlledDialogProps are common props required for dialog components with
|
||||
* controlled state. Since Dialog components frequently expose these props to
|
||||
* their callers, we've consolidated them here for easy access.
|
||||
*/
|
||||
export type ControlledDialogProps = {
|
||||
/**
|
||||
* open is a boolean that controls whether the dialog is open or not.
|
||||
*/
|
||||
open: boolean
|
||||
/**
|
||||
* onOpenChange is a callback that is called when the open state of the dialog
|
||||
* changes.
|
||||
*/
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type PointerDownOutsideEvent = CustomEvent<{
|
||||
originalEvent: PointerEvent
|
||||
}>
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
/**
|
||||
* title is the title of the dialog, shown at the top.
|
||||
*/
|
||||
title: string
|
||||
/**
|
||||
* titleSuffixDecoration is added to the title, but is not part of the ARIA label for
|
||||
* the dialog. This is useful for adding a badge or other non-semantic
|
||||
* information to the title.
|
||||
*/
|
||||
titleSuffixDecoration?: React.ReactNode
|
||||
/**
|
||||
* trigger is an element to use as a trigger for a dialog. Using trigger is
|
||||
* preferrable to using `open` for managing state, as it allows for better
|
||||
* focus management for screen readers.
|
||||
*/
|
||||
trigger?: React.ReactNode
|
||||
/**
|
||||
* children is the content of the dialog.
|
||||
*/
|
||||
children: React.ReactNode
|
||||
/**
|
||||
* defaultOpen is the default state of the dialog. This is meant to be used for
|
||||
* uncontrolled dialogs, and should not be combined with `open` or
|
||||
* `onOpenChange`.
|
||||
*/
|
||||
defaultOpen?: boolean
|
||||
/**
|
||||
* restoreFocus determines whether the dialog returns focus to the trigger
|
||||
* element or not after closing.
|
||||
*/
|
||||
restoreFocus?: boolean
|
||||
onPointerDownOutside?: (e: PointerDownOutsideEvent) => void
|
||||
} & Partial<ControlledDialogProps>
|
||||
|
||||
const dialogOverlay =
|
||||
"fixed overflow-y-auto inset-0 py-8 z-10 bg-gray-900 bg-opacity-[0.07]"
|
||||
const dialogWindow = cx(
|
||||
"bg-white rounded-lg relative max-w-lg min-w-[19rem] w-[97%] shadow-dialog",
|
||||
"p-4 md:p-6 my-8 mx-auto",
|
||||
// We use `transform-gpu` here to force the browser to put the dialog on its
|
||||
// own layer. This helps fix some weird artifacting bugs in Safari caused by
|
||||
// box-shadows. See: https://github.com/tailscale/corp/issues/12270
|
||||
"transform-gpu"
|
||||
)
|
||||
|
||||
/**
|
||||
* Dialog provides a modal dialog, for prompting a user for input or confirmation
|
||||
* before proceeding.
|
||||
*/
|
||||
export default function Dialog(props: Props) {
|
||||
const {
|
||||
open,
|
||||
className,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
title,
|
||||
titleSuffixDecoration,
|
||||
children,
|
||||
restoreFocus = true,
|
||||
onPointerDownOutside,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root
|
||||
open={open}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
{trigger && (
|
||||
<DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>
|
||||
)}
|
||||
<PortalContainerContext.Consumer>
|
||||
{(portalContainer) => (
|
||||
<DialogPrimitive.Portal container={portalContainer}>
|
||||
<DialogPrimitive.Overlay className={dialogOverlay}>
|
||||
<DialogPrimitive.Content
|
||||
aria-label={title}
|
||||
className={cx(dialogWindow, className)}
|
||||
onCloseAutoFocus={
|
||||
// Cancel the focus restore if `restoreFocus` is set to false
|
||||
restoreFocus === false ? (e) => e.preventDefault() : undefined
|
||||
}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
>
|
||||
<DialogErrorBoundary>
|
||||
<header className="flex items-center justify-between space-x-4 mb-5 mr-8">
|
||||
<div className="font-semibold text-lg truncate">
|
||||
{title}
|
||||
{titleSuffixDecoration}
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button
|
||||
variant="minimal"
|
||||
className="absolute top-5 right-5 px-2 py-2"
|
||||
>
|
||||
<X
|
||||
aria-hidden
|
||||
className="h-[1.25em] w-[1.25em] stroke-current"
|
||||
/>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogErrorBoundary>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Overlay>
|
||||
</DialogPrimitive.Portal>
|
||||
)}
|
||||
</PortalContainerContext.Consumer>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog.Form is a standard way of providing form-based interactions in a
|
||||
* Dialog component. Prefer it to custom form implementations. See each props
|
||||
* documentation for details.
|
||||
*
|
||||
* <Dialog.Form cancelButton submitButton="Save" onSubmit={saveThing}>
|
||||
* <input type="text" value={myValue} onChange={myChangeHandler} />
|
||||
* </Dialog.Form>
|
||||
*/
|
||||
Dialog.Form = DialogForm
|
||||
|
||||
type FormProps = {
|
||||
/**
|
||||
* destructive declares whether the submit button should be styled as a danger
|
||||
* button or not. Prefer `destructive` over passing a props object to
|
||||
* `submitButton`, since objects cause unnecessary re-renders unless they are
|
||||
* moved outside the render function.
|
||||
*/
|
||||
destructive?: boolean
|
||||
/**
|
||||
* children is the content of the dialog form.
|
||||
*/
|
||||
children?: React.ReactNode
|
||||
/**
|
||||
* disabled determines whether the submit button should be disabled. The
|
||||
* cancel button cannot be disabled via this prop.
|
||||
*/
|
||||
disabled?: boolean
|
||||
/**
|
||||
* loading determines whether the submit button should display a loading state
|
||||
* and the cancel button should be disabled.
|
||||
*/
|
||||
loading?: boolean
|
||||
/**
|
||||
* cancelButton determines how the cancel button looks. You can pass `true`,
|
||||
* which adds a default button, pass a string which changes the button label,
|
||||
* or pass an object, which is a set of props to pass to a `Button` component.
|
||||
* Any unspecified props will fall back to default values.
|
||||
*
|
||||
* <Dialog.Form cancelButton />
|
||||
* <Dialog.Form cancelButton="Done" />
|
||||
* <Dialog.Form cancelButton={{ children: "Back", variant: "primary" }} />
|
||||
*/
|
||||
cancelButton?: ButtonProp
|
||||
/**
|
||||
* submitButton determines how the submit button looks. You can pass `true`,
|
||||
* which adds a default button, pass a string which changes the button label,
|
||||
* or pass an object, which is a set of props to pass to a `Button` component.
|
||||
* Any unspecified props will fall back to default values.
|
||||
*
|
||||
* <Dialog.Form submitButton />
|
||||
* <Dialog.Form submitButton="Save" />
|
||||
* <Dialog.Form submitButton="Delete" destructive />
|
||||
* <Dialog.Form submitButton={{ children: "Banana", className: "bg-yellow-500" }} />
|
||||
*/
|
||||
submitButton?: ButtonProp
|
||||
|
||||
/**
|
||||
* onSubmit is the callback to use when the form is submitted. Using `onSubmit`
|
||||
* is preferrable to a `onClick` handler on `submitButton`, which doesn't get
|
||||
* triggered on keyboard events.
|
||||
*/
|
||||
onSubmit?: () => void
|
||||
|
||||
/**
|
||||
* autoFocus makes it easy to focus a particular action button without
|
||||
* overriding the button props.
|
||||
*/
|
||||
autoFocus?: "submit" | "cancel"
|
||||
}
|
||||
|
||||
function DialogForm(props: FormProps) {
|
||||
const {
|
||||
children,
|
||||
disabled = false,
|
||||
destructive = false,
|
||||
loading = false,
|
||||
autoFocus = "submit",
|
||||
cancelButton,
|
||||
submitButton,
|
||||
onSubmit,
|
||||
} = props
|
||||
|
||||
const hasFooter = Boolean(cancelButton || submitButton)
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit?.()
|
||||
}
|
||||
|
||||
const cancelAutoFocus = Boolean(
|
||||
cancelButton && !loading && autoFocus === "cancel"
|
||||
)
|
||||
const submitAutoFocus = Boolean(
|
||||
submitButton && !loading && !disabled && autoFocus === "submit"
|
||||
)
|
||||
const submitIntent = destructive ? "danger" : "primary"
|
||||
|
||||
let cancelButtonEl = null
|
||||
|
||||
if (cancelButton) {
|
||||
cancelButtonEl =
|
||||
cancelButton === true ? (
|
||||
<Button
|
||||
{...cancelButtonDefaultProps}
|
||||
autoFocus={cancelAutoFocus}
|
||||
disabled={loading}
|
||||
/>
|
||||
) : typeof cancelButton === "string" ? (
|
||||
<Button
|
||||
{...cancelButtonDefaultProps}
|
||||
autoFocus={cancelAutoFocus}
|
||||
children={cancelButton}
|
||||
disabled={loading}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
{...cancelButtonDefaultProps}
|
||||
autoFocus={cancelAutoFocus}
|
||||
disabled={loading}
|
||||
{...cancelButton}
|
||||
/>
|
||||
)
|
||||
|
||||
const hasCustomCancelAction =
|
||||
isObject(cancelButton) && cancelButton.onClick !== undefined
|
||||
if (!hasCustomCancelAction) {
|
||||
cancelButtonEl = (
|
||||
<DialogPrimitive.Close asChild>{cancelButtonEl}</DialogPrimitive.Close>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{children}
|
||||
{hasFooter && (
|
||||
<footer className="flex mt-10 justify-end space-x-4">
|
||||
{cancelButtonEl}
|
||||
{submitButton && (
|
||||
<>
|
||||
{submitButton === true ? (
|
||||
<Button
|
||||
{...submitButtonDefaultProps}
|
||||
intent={submitIntent}
|
||||
autoFocus={submitAutoFocus}
|
||||
disabled={loading || disabled}
|
||||
/>
|
||||
) : typeof submitButton === "string" ? (
|
||||
<Button
|
||||
{...submitButtonDefaultProps}
|
||||
intent={submitIntent}
|
||||
children={submitButton}
|
||||
autoFocus={submitAutoFocus}
|
||||
disabled={loading || disabled}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
{...submitButtonDefaultProps}
|
||||
intent={submitIntent}
|
||||
autoFocus={submitAutoFocus}
|
||||
disabled={loading || disabled}
|
||||
{...submitButton}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const cancelButtonDefaultProps: Pick<
|
||||
ComponentProps<typeof Button>,
|
||||
"type" | "intent" | "sizeVariant" | "children"
|
||||
> = {
|
||||
type: "button",
|
||||
intent: "base",
|
||||
sizeVariant: "medium",
|
||||
children: "Cancel",
|
||||
}
|
||||
|
||||
const submitButtonDefaultProps: Pick<
|
||||
ComponentProps<typeof Button>,
|
||||
"type" | "sizeVariant" | "children" | "autoFocus"
|
||||
> = {
|
||||
type: "submit",
|
||||
sizeVariant: "medium",
|
||||
children: "Submit",
|
||||
}
|
||||
|
||||
type DialogErrorBoundaryProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
class DialogErrorBoundary extends Component<
|
||||
DialogErrorBoundaryProps,
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: DialogErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.log(error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return <div className="font-semibold text-lg">Something went wrong.</div>
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { cloneElement } from "react"
|
||||
|
||||
type Props = {
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
description: string
|
||||
icon?: React.ReactElement
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyState shows some text and an optional action when some area that can
|
||||
* house content is empty (eg. no search results, empty tables).
|
||||
*/
|
||||
export default function EmptyState(props: Props) {
|
||||
const { action, className, description, icon, title } = props
|
||||
const iconColor = "text-gray-500"
|
||||
const iconComponent = getIcon(icon, iconColor)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx("flex justify-center", className, {
|
||||
"flex-col items-center": action || icon || title,
|
||||
})}
|
||||
>
|
||||
{icon && <div className="mb-2">{iconComponent}</div>}
|
||||
{title && (
|
||||
<h3 className="text-xl font-medium text-center mb-2">{title}</h3>
|
||||
)}
|
||||
<div className="w-full text-center max-w-xl text-gray-500">
|
||||
{description}
|
||||
</div>
|
||||
{action && <div className="mt-3.5">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getIcon(icon: React.ReactElement | undefined, iconColor: string) {
|
||||
return icon ? cloneElement(icon, { className: iconColor }) : null
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { HTMLAttributes } from "react"
|
||||
|
||||
type Props = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
/**
|
||||
* LoadingDots provides a set of horizontal dots to indicate a loading state.
|
||||
* These dots are helpful in horizontal contexts (like buttons) where a spinner
|
||||
* doesn't fit as well.
|
||||
*/
|
||||
export default function LoadingDots(props: Props) {
|
||||
const { className, ...rest } = props
|
||||
return (
|
||||
<div className={cx(className, "loading-dots")} {...rest}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import cx from "classnames"
|
||||
import React, { ReactNode } from "react"
|
||||
import PortalContainerContext from "src/ui/portal-container-context"
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
@@ -104,3 +103,7 @@ export default function Popover(props: Props) {
|
||||
Popover.defaultProps = {
|
||||
sideOffset: 10,
|
||||
}
|
||||
|
||||
const PortalContainerContext = React.createContext<HTMLElement | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
|
||||
const PortalContainerContext = React.createContext<HTMLElement | undefined>(
|
||||
undefined
|
||||
)
|
||||
export default PortalContainerContext
|
||||
@@ -1,160 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import useToaster from "src/hooks/toaster"
|
||||
import { copyText } from "src/utils/clipboard"
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
hideAffordance?: boolean
|
||||
/**
|
||||
* primaryActionSubject is the subject of the toast confirmation message
|
||||
* "Copied <subject> to clipboard"
|
||||
*/
|
||||
primaryActionSubject: string
|
||||
primaryActionValue: string
|
||||
secondaryActionName?: string
|
||||
secondaryActionValue?: string
|
||||
/**
|
||||
* secondaryActionSubject is the subject of the toast confirmation message
|
||||
* prompted by the secondary action "Copied <subject> to clipboard"
|
||||
*/
|
||||
secondaryActionSubject?: string
|
||||
children?: React.ReactNode
|
||||
|
||||
/**
|
||||
* onSecondaryAction is used to trigger events when the secondary copy
|
||||
* function is used. It is not used when the secondary action is hidden.
|
||||
*/
|
||||
onSecondaryAction?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* QuickCopy is a UI component that allows for copying textual content in one click.
|
||||
*/
|
||||
export default function QuickCopy(props: Props) {
|
||||
const {
|
||||
className,
|
||||
hideAffordance,
|
||||
primaryActionSubject,
|
||||
primaryActionValue,
|
||||
secondaryActionValue,
|
||||
secondaryActionName,
|
||||
secondaryActionSubject,
|
||||
onSecondaryAction,
|
||||
children,
|
||||
} = props
|
||||
const toaster = useToaster()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRef = useRef<HTMLDivElement>(null)
|
||||
const [showButton, setShowButton] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showButton) {
|
||||
return
|
||||
}
|
||||
if (!containerRef.current || !buttonRef.current) {
|
||||
return
|
||||
}
|
||||
// We don't need to watch any `resize` event because it's pretty unlikely
|
||||
// the browser will resize while their cursor is over one of these items.
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const maximumPossibleWidth = window.innerWidth - rect.left + 4
|
||||
|
||||
// We add the border-width (1px * 2 sides) and the padding (0.5rem * 2 sides)
|
||||
// and add 1px for rounding up the calculation in order to get the final
|
||||
// maxWidth value. This should be kept in sync with the CSS classes below.
|
||||
buttonRef.current.style.maxWidth = `${maximumPossibleWidth}px`
|
||||
buttonRef.current.style.visibility = "visible"
|
||||
}, [showButton])
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
copyText(primaryActionValue)
|
||||
toaster.show({
|
||||
message: `Copied ${primaryActionSubject} to the clipboard`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSecondaryAction = () => {
|
||||
if (!secondaryActionValue) {
|
||||
return
|
||||
}
|
||||
copyText(secondaryActionValue)
|
||||
toaster.show({
|
||||
message: `Copied ${
|
||||
secondaryActionSubject || secondaryActionName
|
||||
} to the clipboard`,
|
||||
})
|
||||
onSecondaryAction?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex relative min-w-0"
|
||||
ref={containerRef}
|
||||
// Since the affordance is a child of this element, we assign both event
|
||||
// handlers here.
|
||||
onMouseLeave={() => setShowButton(false)}
|
||||
>
|
||||
<div
|
||||
onMouseEnter={() => setShowButton(true)}
|
||||
className={cx("truncate", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{!hideAffordance && (
|
||||
<button
|
||||
onMouseEnter={() => setShowButton(true)}
|
||||
onClick={handlePrimaryAction}
|
||||
className={cx("cursor-pointer text-blue-500", { "ml-2": children })}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showButton && (
|
||||
<div
|
||||
className="absolute -mt-1 -ml-2 -top-px -left-px
|
||||
shadow-md cursor-pointer rounded-md active:shadow-sm
|
||||
transition-shadow duration-100 ease-in-out z-50"
|
||||
style={{ visibility: "hidden" }}
|
||||
ref={buttonRef}
|
||||
>
|
||||
<div className="flex border rounded-md button-outline bg-white">
|
||||
<div
|
||||
className={cx("flex min-w-0 py-1 px-2 hover:bg-gray-0", {
|
||||
"rounded-md": !secondaryActionValue,
|
||||
"rounded-l-md": secondaryActionValue,
|
||||
})}
|
||||
onClick={handlePrimaryAction}
|
||||
>
|
||||
<span
|
||||
className={cx(className, "inline-block select-none truncate")}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<button
|
||||
className={cx("cursor-pointer text-blue-500", {
|
||||
"ml-2": children,
|
||||
})}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{secondaryActionValue && (
|
||||
<div
|
||||
className="text-blue-500 py-1 px-2 border-l hover:bg-gray-100 rounded-r-md"
|
||||
onClick={handleSecondaryAction}
|
||||
>
|
||||
{secondaryActionName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -17,10 +17,10 @@ const SearchInput = forwardRef<HTMLInputElement, Props>((props, ref) => {
|
||||
const { className, inputClassName, ...rest } = props
|
||||
return (
|
||||
<div className={cx("relative", className)}>
|
||||
<Search className="absolute text-gray-400 w-[1.25em] h-full ml-2" />
|
||||
<Search className="absolute w-[1.25em] h-full ml-2" />
|
||||
<input
|
||||
type="text"
|
||||
className={cx("input pl-9 pr-8", inputClassName)}
|
||||
className={cx("input px-8", inputClassName)}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import X from "src/assets/icons/x.svg?react"
|
||||
import { noop } from "src/utils/util"
|
||||
import { create } from "zustand"
|
||||
import { shallow } from "zustand/shallow"
|
||||
|
||||
// Set up root element on the document body for toasts to render into.
|
||||
const root = document.createElement("div")
|
||||
root.id = "toast-root"
|
||||
root.classList.add("relative", "z-20")
|
||||
document.body.append(root)
|
||||
|
||||
const toastSpacing = remToPixels(1)
|
||||
|
||||
export type Toaster = {
|
||||
clear: () => void
|
||||
dismiss: (key: string) => void
|
||||
show: (props: Toast) => string
|
||||
}
|
||||
|
||||
type Toast = {
|
||||
key?: string // key is a unique string value that ensures only one toast with a given key is shown at a time.
|
||||
className?: string
|
||||
variant?: "danger" // styling for the toast, undefined is neutral, danger is for failed requests
|
||||
message: React.ReactNode
|
||||
timeout?: number
|
||||
added?: number // timestamp of when the toast was added
|
||||
}
|
||||
|
||||
type ToastWithKey = Toast & { key: string }
|
||||
|
||||
type State = {
|
||||
toasts: ToastWithKey[]
|
||||
maxToasts: number
|
||||
clear: () => void
|
||||
dismiss: (key: string) => void
|
||||
show: (props: Toast) => string
|
||||
}
|
||||
|
||||
const useToasterState = create<State>((set, get) => ({
|
||||
toasts: [],
|
||||
maxToasts: 5,
|
||||
clear: () => {
|
||||
set({ toasts: [] })
|
||||
},
|
||||
dismiss: (key: string) => {
|
||||
set((prev) => ({
|
||||
toasts: prev.toasts.filter((t) => t.key !== key),
|
||||
}))
|
||||
},
|
||||
show: (props: Toast) => {
|
||||
const { toasts: prevToasts, maxToasts } = get()
|
||||
|
||||
const propsWithKey = {
|
||||
key: Date.now().toString(),
|
||||
...props,
|
||||
}
|
||||
const prevIdx = prevToasts.findIndex((t) => t.key === propsWithKey.key)
|
||||
|
||||
// If the toast already exists, update it. Otherwise, append it.
|
||||
const nextToasts =
|
||||
prevIdx !== -1
|
||||
? [
|
||||
...prevToasts.slice(0, prevIdx),
|
||||
propsWithKey,
|
||||
...prevToasts.slice(prevIdx + 1),
|
||||
]
|
||||
: [...prevToasts, propsWithKey]
|
||||
|
||||
set({
|
||||
// Get the last `maxToasts` toasts of the set.
|
||||
toasts: nextToasts.slice(-maxToasts),
|
||||
})
|
||||
return propsWithKey.key
|
||||
},
|
||||
}))
|
||||
|
||||
const clearSelector = (state: State) => state.clear
|
||||
|
||||
const toasterSelector = (state: State) => ({
|
||||
show: state.show,
|
||||
dismiss: state.dismiss,
|
||||
clear: state.clear,
|
||||
})
|
||||
|
||||
/**
|
||||
* useRawToasterForHook is meant to supply the hook function for hooks/toaster.
|
||||
* Use hooks/toaster instead.
|
||||
*/
|
||||
export const useRawToasterForHook = () =>
|
||||
useToasterState(toasterSelector, shallow)
|
||||
|
||||
type ToastProviderProps = {
|
||||
children: React.ReactNode
|
||||
canEscapeKeyClear?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ToastProvider is the top-level toaster component. It stores the toast state.
|
||||
*/
|
||||
export default function ToastProvider(props: ToastProviderProps) {
|
||||
const { children, canEscapeKeyClear = true } = props
|
||||
const clear = useToasterState(clearSelector)
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (!canEscapeKeyClear) {
|
||||
return
|
||||
}
|
||||
if (e.key === "Esc" || e.key === "Escape") {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [canEscapeKeyClear, clear])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const toastContainerSelector = (state: State) => ({
|
||||
toasts: state.toasts,
|
||||
dismiss: state.dismiss,
|
||||
})
|
||||
|
||||
/**
|
||||
* ToastContainer manages the positioning and animation for all currently
|
||||
* displayed toasts. It should only be used by ToastProvider.
|
||||
*/
|
||||
function ToastContainer() {
|
||||
const { toasts, dismiss } = useToasterState(toastContainerSelector, shallow)
|
||||
|
||||
const [prevToasts, setPrevToasts] = useState<ToastWithKey[]>(toasts)
|
||||
useEffect(() => setPrevToasts(toasts), [toasts])
|
||||
|
||||
const [refMap] = useState(() => new Map<string, HTMLDivElement>())
|
||||
const getOffsetForToast = useCallback(
|
||||
(key: string) => {
|
||||
let offset = 0
|
||||
|
||||
let arr = toasts
|
||||
let index = arr.findIndex((t) => t.key === key)
|
||||
if (index === -1) {
|
||||
arr = prevToasts
|
||||
index = arr.findIndex((t) => t.key === key)
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
return offset
|
||||
}
|
||||
|
||||
for (let i = arr.length; i > index; i--) {
|
||||
if (!arr[i]) {
|
||||
continue
|
||||
}
|
||||
const ref = refMap.get(arr[i].key)
|
||||
if (!ref) {
|
||||
continue
|
||||
}
|
||||
offset -= ref.offsetHeight
|
||||
offset -= toastSpacing
|
||||
}
|
||||
return offset
|
||||
},
|
||||
[refMap, prevToasts, toasts]
|
||||
)
|
||||
|
||||
const toastsWithStyles = useMemo(
|
||||
() =>
|
||||
toasts.map((toast) => ({
|
||||
toast: toast,
|
||||
style: {
|
||||
transform: `translateY(${getOffsetForToast(toast.key)}px) scale(1.0)`,
|
||||
},
|
||||
})),
|
||||
[getOffsetForToast, toasts]
|
||||
)
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Could not find toast root") // should never happen
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed bottom-6 right-6 z-[99]">
|
||||
{toastsWithStyles.map(({ toast, style }) => (
|
||||
<ToastBlock
|
||||
key={toast.key}
|
||||
ref={(ref) => ref && refMap.set(toast.key, ref)}
|
||||
toast={toast}
|
||||
onDismiss={dismiss}
|
||||
style={style}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
root
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ToastBlock is the display of an individual toast, and also manages timeout
|
||||
* settings for a particular toast.
|
||||
*/
|
||||
const ToastBlock = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
toast: ToastWithKey
|
||||
onDismiss?: (key: string) => void
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
>(({ toast, onDismiss = noop, style }, ref) => {
|
||||
const { message, key, timeout = 5000, variant } = toast
|
||||
|
||||
const [focused, setFocused] = useState(false)
|
||||
const dismiss = useCallback(() => onDismiss(key), [onDismiss, key])
|
||||
const onFocus = useCallback(() => setFocused(true), [])
|
||||
const onBlur = useCallback(() => setFocused(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (timeout <= 0 || focused) {
|
||||
return
|
||||
}
|
||||
const timerId = setTimeout(() => dismiss(), timeout)
|
||||
return () => clearTimeout(timerId)
|
||||
}, [dismiss, timeout, focused])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"transition ease-in-out animate-scale-in",
|
||||
"bottom-0 right-0 z-[99] w-[85vw] origin-bottom",
|
||||
"sm:min-w-[400px] sm:max-w-[500px]",
|
||||
"absolute shadow-sm rounded-md text-md flex items-center justify-between",
|
||||
{
|
||||
"text-white bg-gray-700": variant === undefined,
|
||||
"text-white bg-orange-400": variant === "danger",
|
||||
}
|
||||
)}
|
||||
aria-live="polite"
|
||||
ref={ref}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
onMouseEnter={onFocus}
|
||||
onMouseLeave={onBlur}
|
||||
tabIndex={0}
|
||||
style={style}
|
||||
>
|
||||
<span className="pl-4 py-3 pr-2">{message}</span>
|
||||
<button
|
||||
className="cursor-pointer opacity-75 hover:opacity-50 transition-opacity py-3 px-3"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<X className="w-[1em] h-[1em] stroke-current" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function remToPixels(rem: number) {
|
||||
return (
|
||||
rem * Number.parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { isPromise } from "src/utils/util"
|
||||
|
||||
/**
|
||||
* copyText copies text to the clipboard, handling cross-browser compatibility
|
||||
* issues with different clipboard APIs.
|
||||
*
|
||||
* To support copying after running a network request (eg. generating an invite),
|
||||
* pass a promise that resolves to the text to copy.
|
||||
*
|
||||
* @example
|
||||
* copyText("Hello, world!")
|
||||
* copyText(generateInvite().then(res => res.data.inviteCode))
|
||||
*/
|
||||
export function copyText(text: string | Promise<string | void>) {
|
||||
if (!navigator.clipboard) {
|
||||
if (isPromise(text)) {
|
||||
return text.then((val) => fallbackCopy(validateString(val)))
|
||||
}
|
||||
return fallbackCopy(text)
|
||||
}
|
||||
if (isPromise(text)) {
|
||||
if (typeof ClipboardItem === "undefined") {
|
||||
return text.then((val) =>
|
||||
navigator.clipboard.writeText(validateString(val))
|
||||
)
|
||||
}
|
||||
return navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/plain": text.then(
|
||||
(val) => new Blob([validateString(val)], { type: "text/plain" })
|
||||
),
|
||||
}),
|
||||
])
|
||||
}
|
||||
return navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
function validateString(val: unknown): string {
|
||||
if (typeof val !== "string" || val.length === 0) {
|
||||
throw new TypeError("Expected string, got " + typeof val)
|
||||
}
|
||||
if (val.length === 0) {
|
||||
throw new TypeError("Expected non-empty string")
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
function fallbackCopy(text: string) {
|
||||
const el = document.createElement("textarea")
|
||||
el.value = text
|
||||
el.setAttribute("readonly", "")
|
||||
el.className = "absolute opacity-0 pointer-events-none"
|
||||
document.body.append(el)
|
||||
|
||||
// Check if text is currently selected
|
||||
let selection = document.getSelection()
|
||||
const selected =
|
||||
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : false
|
||||
|
||||
el.select()
|
||||
document.execCommand("copy")
|
||||
el.remove()
|
||||
|
||||
// Restore selection
|
||||
if (selected) {
|
||||
selection = document.getSelection()
|
||||
if (selection) {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(selected)
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { isTailscaleIPv6, pluralize } from "src/utils/util"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
describe("pluralize", () => {
|
||||
it("test routes", () => {
|
||||
expect(pluralize("route", "routes", 1)).toBe("route")
|
||||
expect(pluralize("route", "routes", 2)).toBe("routes")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isTailscaleIPv6", () => {
|
||||
it("test ips", () => {
|
||||
expect(isTailscaleIPv6("100.101.102.103")).toBeFalsy()
|
||||
expect(
|
||||
isTailscaleIPv6("fd7a:115c:a1e0:ab11:1111:cd11:111e:f11g")
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
/**
|
||||
* assertNever ensures a branch of code can never be reached,
|
||||
* resulting in a Typescript error if it can.
|
||||
*/
|
||||
export function assertNever(a: never): never {
|
||||
return a
|
||||
}
|
||||
|
||||
/**
|
||||
* noop is an empty function for use as a default value.
|
||||
*/
|
||||
export function noop() {}
|
||||
|
||||
/**
|
||||
* isObject checks if a value is an object.
|
||||
*/
|
||||
export function isObject(val: unknown): val is object {
|
||||
return Boolean(val && typeof val === "object" && val.constructor === Object)
|
||||
}
|
||||
|
||||
/**
|
||||
* pluralize is a very simple function that returns either
|
||||
* the singular or plural form of a string based on the given
|
||||
* quantity.
|
||||
*
|
||||
* TODO: Ideally this would use a localized pluralization.
|
||||
*/
|
||||
export function pluralize(signular: string, plural: string, qty: number) {
|
||||
return qty === 1 ? signular : plural
|
||||
}
|
||||
|
||||
/**
|
||||
* isTailscaleIPv6 returns true when the ip matches
|
||||
* Tailnet's IPv6 format.
|
||||
*/
|
||||
export function isTailscaleIPv6(ip: string): boolean {
|
||||
return ip.startsWith("fd7a:115c:a1e0")
|
||||
}
|
||||
|
||||
/**
|
||||
* isPromise returns whether the current value is a promise.
|
||||
*/
|
||||
export function isPromise<T = unknown>(val: unknown): val is Promise<T> {
|
||||
if (!val) {
|
||||
return false
|
||||
}
|
||||
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,85 +0,0 @@
|
||||
{
|
||||
"colors": {
|
||||
"transparent": "transparent",
|
||||
"current": "currentColor",
|
||||
"white": "rgb(var(--color-white) / <alpha-value>)",
|
||||
"gray": {
|
||||
"0": "rgb(var(--color-gray-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-gray-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-gray-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-gray-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-gray-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-gray-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-gray-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-gray-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-gray-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-gray-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-gray-900) / <alpha-value>)"
|
||||
},
|
||||
"blue": {
|
||||
"0": "rgb(var(--color-blue-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-blue-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-blue-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-blue-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-blue-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-blue-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-blue-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-blue-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-blue-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-blue-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-blue-900) / <alpha-value>)"
|
||||
},
|
||||
"green": {
|
||||
"0": "rgb(var(--color-green-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-green-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-green-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-green-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-green-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-green-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-green-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-green-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-green-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-green-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-green-900) / <alpha-value>)"
|
||||
},
|
||||
"red": {
|
||||
"0": "rgb(var(--color-red-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-red-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-red-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-red-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-red-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-red-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-red-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-red-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-red-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-red-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-red-900) / <alpha-value>)"
|
||||
},
|
||||
"yellow": {
|
||||
"0": "rgb(var(--color-yellow-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-yellow-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-yellow-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-yellow-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-yellow-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-yellow-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-yellow-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-yellow-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-yellow-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-yellow-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-yellow-900) / <alpha-value>)"
|
||||
},
|
||||
"orange": {
|
||||
"0": "rgb(var(--color-orange-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-orange-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-orange-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-orange-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-orange-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-orange-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-orange-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-orange-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-orange-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-orange-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-orange-900) / <alpha-value>)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import plugin from "tailwindcss/plugin"
|
||||
import styles from "./styles.json"
|
||||
const plugin = require("tailwindcss/plugin")
|
||||
|
||||
const config = {
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
screens: {
|
||||
sm: "420px",
|
||||
md: "768px",
|
||||
lg: "1024px",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Inter",
|
||||
@@ -33,85 +29,18 @@ const config = {
|
||||
semibold: "600",
|
||||
bold: "700",
|
||||
},
|
||||
colors: styles.colors,
|
||||
extend: {
|
||||
colors: {
|
||||
...styles.colors,
|
||||
"bg-app": "var(--color-bg-app)",
|
||||
"bg-menu-item-hover": "var(--color-bg-menu-item-hover)",
|
||||
|
||||
"border-base": "var(--color-border-base)",
|
||||
|
||||
"text-base": "var(--color-text-base)",
|
||||
"text-muted": "var(--color-text-muted)",
|
||||
"text-disabled": "var(--color-text-disabled)",
|
||||
"text-primary": "var(--color-text-primary)",
|
||||
"text-warning": "var(--color-text-warning)",
|
||||
"text-danger": "var(--color-text-danger)",
|
||||
},
|
||||
borderColor: {
|
||||
DEFAULT: "var(--color-border-base)",
|
||||
},
|
||||
boxShadow: {
|
||||
dialog: "0 10px 40px rgba(0,0,0,0.12), 0 0 16px rgba(0,0,0,0.08)",
|
||||
form: "0 1px 1px rgba(0, 0, 0, 0.04)",
|
||||
soft: "0 4px 12px 0 rgba(0, 0, 0, 0.03)",
|
||||
popover:
|
||||
"0 0 0 1px rgba(136, 152, 170, 0.1), 0 15px 35px 0 rgba(49, 49, 93, 0.1), 0 5px 15px 0 rgba(0, 0, 0, 0.08)",
|
||||
},
|
||||
animation: {
|
||||
"scale-in": "scale-in 120ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
"scale-out": "scale-out 120ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
},
|
||||
transformOrigin: {
|
||||
"radix-hovercard": "var(--radix-hover-card-content-transform-origin)",
|
||||
"radix-popover": "var(--radix-popover-content-transform-origin)",
|
||||
"radix-tooltip": "var(--radix-tooltip-content-transform-origin)",
|
||||
},
|
||||
keyframes: {
|
||||
"scale-in": {
|
||||
"0%": {
|
||||
transform: "scale(0.94)",
|
||||
opacity: "0",
|
||||
},
|
||||
"100%": {
|
||||
transform: "scale(1)",
|
||||
opacity: "1",
|
||||
},
|
||||
},
|
||||
"scale-out": {
|
||||
"0%": {
|
||||
transform: "scale(1)",
|
||||
opacity: "1",
|
||||
},
|
||||
"100%": {
|
||||
transform: "scale(0.94)",
|
||||
opacity: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
extend: {},
|
||||
},
|
||||
|
||||
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”] &",
|
||||
])
|
||||
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",
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -24,10 +24,7 @@ import (
|
||||
"github.com/gorilla/csrf"
|
||||
"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"
|
||||
"tailscale.com/licenses"
|
||||
@@ -35,9 +32,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -96,14 +91,6 @@ const (
|
||||
// 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.
|
||||
//
|
||||
@@ -114,6 +101,11 @@ const (
|
||||
ManageServerMode ServerMode = "manage"
|
||||
)
|
||||
|
||||
var (
|
||||
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
||||
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
|
||||
)
|
||||
|
||||
// ServerOpts contains options for constructing a new Server.
|
||||
type ServerOpts struct {
|
||||
// Mode specifies the mode of web client being constructed.
|
||||
@@ -158,7 +150,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")
|
||||
@@ -179,14 +171,6 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
newAuthURL: opts.NewAuthURL,
|
||||
waitAuthURL: opts.WaitAuthURL,
|
||||
}
|
||||
if opts.PathPrefix != "" {
|
||||
// Enforce that path prefix always has a single leading '/'
|
||||
// so that it is treated as a relative URL path.
|
||||
// We strip multiple leading '/' to prevent schema-less offsite URLs like "//example.com".
|
||||
//
|
||||
// See https://github.com/tailscale/corp/issues/16268.
|
||||
s.pathPrefix = "/" + strings.TrimLeft(path.Clean(opts.PathPrefix), "/\\")
|
||||
}
|
||||
if s.mode == ManageServerMode {
|
||||
if opts.NewAuthURL == nil {
|
||||
return nil, fmt.Errorf("must provide a NewAuthURL implementation")
|
||||
@@ -211,14 +195,10 @@ 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))
|
||||
switch s.mode {
|
||||
case LoginServerMode:
|
||||
if s.mode == LoginServerMode {
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
||||
metric = "web_login_client_initialization"
|
||||
case ReadOnlyServerMode:
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
||||
metric = "web_readonly_client_initialization"
|
||||
case ManageServerMode:
|
||||
} else {
|
||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
|
||||
metric = "web_client_initialization"
|
||||
}
|
||||
@@ -246,7 +226,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
handler := s.serve
|
||||
|
||||
// if path prefix is defined, strip it from requests.
|
||||
if s.cgiMode && s.pathPrefix != "" {
|
||||
if s.pathPrefix != "" {
|
||||
handler = enforcePrefix(s.pathPrefix, handler)
|
||||
}
|
||||
|
||||
@@ -268,23 +248,13 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if !s.devMode {
|
||||
// This hash corresponds to the inline script in index.html that runs when the react app is unavailable.
|
||||
// It was generated from https://csplite.com/csp/sha/.
|
||||
// If the contents of the script are changed, this hash must be updated.
|
||||
const indexScriptHash = "sha384-CW2AYVfS14P7QHZN27thEkMLKiCj3YNURPoLc1elwiEkMVHeuYTWkJOEki1F3nZc"
|
||||
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src * data:; script-src 'self' '"+indexScriptHash+"'")
|
||||
// TODO: use CSP nonce or hash to eliminate need for unsafe-inline
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; img-src * data:")
|
||||
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
@@ -305,6 +275,9 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
s.apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !s.devMode {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_page_load", 1)
|
||||
}
|
||||
s.assetsHandler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -329,61 +302,22 @@ func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (han
|
||||
return true
|
||||
}
|
||||
|
||||
ipv4, ipv6 := s.selfNodeAddresses(r, st)
|
||||
if r.Host == fmt.Sprintf("%s:%d", ipv4.String(), ListenPort) {
|
||||
return false // already accessing over Tailscale IP
|
||||
}
|
||||
if r.Host == fmt.Sprintf("[%s]:%d", ipv6.String(), ListenPort) {
|
||||
return false // already accessing over Tailscale IP
|
||||
}
|
||||
|
||||
// Not currently accessing via Tailscale IP,
|
||||
// redirect them.
|
||||
|
||||
var preferV6 bool
|
||||
if ap, err := netip.ParseAddrPort(r.Host); err == nil {
|
||||
// If Host was already ipv6, keep them on same protocol.
|
||||
preferV6 = ap.Addr().Is6()
|
||||
}
|
||||
|
||||
newURL := *r.URL
|
||||
if (preferV6 && ipv6.IsValid()) || !ipv4.IsValid() {
|
||||
newURL.Host = fmt.Sprintf("[%s]:%d", ipv6.String(), ListenPort)
|
||||
} else {
|
||||
newURL.Host = fmt.Sprintf("%s:%d", ipv4.String(), ListenPort)
|
||||
}
|
||||
http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently)
|
||||
return true
|
||||
}
|
||||
|
||||
// selfNodeAddresses return the Tailscale IPv4 and IPv6 addresses for the self node.
|
||||
// st is expected to be a status with peers included.
|
||||
func (s *Server) selfNodeAddresses(r *http.Request, st *ipnstate.Status) (ipv4, ipv6 netip.Addr) {
|
||||
var ipv4 string // store the first IPv4 address we see for redirect later
|
||||
for _, ip := range st.Self.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
ipv4 = ip
|
||||
} else if ip.Is6() {
|
||||
ipv6 = ip
|
||||
if r.Host == fmt.Sprintf("%s:%d", ip, ListenPort) {
|
||||
return false
|
||||
}
|
||||
ipv4 = ip.String()
|
||||
}
|
||||
if ipv4.IsValid() && ipv6.IsValid() {
|
||||
break // found both IPs
|
||||
if ip.Is6() && r.Host == fmt.Sprintf("[%s]:%d", ip, ListenPort) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil {
|
||||
// The source peer connecting to this node may know it by a different
|
||||
// IP than the node knows itself as. Specifically, this may be the case
|
||||
// if the peer is coming from a different tailnet (sharee node), as IPs
|
||||
// are specific to each tailnet.
|
||||
// Here, we check if the source peer knows the node by a different IP,
|
||||
// and return the peer's version if so.
|
||||
if knownIPv4 := whois.Node.SelfNodeV4MasqAddrForThisPeer; knownIPv4 != nil {
|
||||
ipv4 = *knownIPv4
|
||||
}
|
||||
if knownIPv6 := whois.Node.SelfNodeV6MasqAddrForThisPeer; knownIPv6 != nil {
|
||||
ipv6 = *knownIPv6
|
||||
}
|
||||
}
|
||||
return ipv4, ipv6
|
||||
newURL := *r.URL
|
||||
newURL.Host = fmt.Sprintf("%s:%d", ipv4, ListenPort)
|
||||
http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently)
|
||||
return true
|
||||
}
|
||||
|
||||
// authorizeRequest reports whether the request from the web client
|
||||
@@ -393,7 +327,7 @@ func (s *Server) selfNodeAddresses(r *http.Request, st *ipnstate.Status) (ipv4,
|
||||
// errors to the ResponseWriter itself.
|
||||
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
if s.mode == ManageServerMode { // client using tailscale auth
|
||||
session, _, _, err := s.getSession(r)
|
||||
session, _, err := s.getSession(r)
|
||||
switch {
|
||||
case errors.Is(err, errNotUsingTailscale):
|
||||
// All requests must be made over tailscale.
|
||||
@@ -402,9 +336,6 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
|
||||
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
||||
// Readonly endpoint allowed without valid browser session.
|
||||
return true
|
||||
case r.URL.Path == "/api/device-details-click" && r.Method == httpm.POST:
|
||||
// Special case metric endpoint that is allowed without a browser session.
|
||||
return true
|
||||
case strings.HasPrefix(r.URL.Path, "/api/"):
|
||||
// All other /api/ endpoints require a valid browser session.
|
||||
if err != nil || !session.isAuthorized(s.timeNow()) {
|
||||
@@ -440,236 +371,42 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.serveGetNodeData(w, r)
|
||||
case r.URL.Path == "/api/up" && r.Method == httpm.POST:
|
||||
s.serveTailscaleUp(w, r)
|
||||
case r.URL.Path == "/api/device-details-click" && r.Method == httpm.POST:
|
||||
s.serveDeviceDetailsClick(w, r)
|
||||
default:
|
||||
http.Error(w, "invalid endpoint or method", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
// and returns an authResponse indicating the current auth state and any steps the user needs to take.
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// First verify platform auth.
|
||||
// If platform auth is needed, this should happen first.
|
||||
if s.mode == LoginServerMode || s.mode == ReadOnlyServerMode {
|
||||
session, whois, err := s.getSession(r)
|
||||
switch {
|
||||
case err != nil && errors.Is(err, errNotUsingTailscale):
|
||||
// not using tailscale, so perform platform auth
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
authorized, err := authorizeSynology(r)
|
||||
@@ -678,9 +415,7 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !authorized {
|
||||
resp.NeedsSynoAuth = true
|
||||
writeJSON(w, resp)
|
||||
return
|
||||
resp.AuthNeeded = synoAuth
|
||||
}
|
||||
case distro.QNAP:
|
||||
if _, err := authorizeQNAP(r); err != nil {
|
||||
@@ -690,53 +425,34 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
default:
|
||||
// no additional auth for this distro
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
|
||||
resp.Authorized = false // restricted to the readonly view
|
||||
case sErr != nil && errors.Is(sErr, errNotOwner):
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1)
|
||||
resp.Authorized = false // restricted to the readonly view
|
||||
case sErr != nil && errors.Is(sErr, errTaggedLocalSource):
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1)
|
||||
resp.Authorized = false // restricted to the readonly view
|
||||
case sErr != nil && errors.Is(sErr, errTaggedRemoteSource):
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1)
|
||||
resp.Authorized = false // restricted to the readonly view
|
||||
case sErr != nil && !errors.Is(sErr, errNoSession):
|
||||
case err != nil && (errors.Is(err, errNotOwner) ||
|
||||
errors.Is(err, errNotUsingTailscale) ||
|
||||
errors.Is(err, errTaggedLocalSource) ||
|
||||
errors.Is(err, errTaggedRemoteSource)):
|
||||
// These cases are all restricted to the readonly view.
|
||||
// No auth action to take.
|
||||
resp.AuthNeeded = ""
|
||||
case err != nil && !errors.Is(err, errNoSession):
|
||||
// Any other error.
|
||||
http.Error(w, sErr.Error(), http.StatusInternalServerError)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
case session.isAuthorized(s.timeNow()):
|
||||
if whois.Node.StableID == status.Self.ID {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_managing_local", 1)
|
||||
} 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:
|
||||
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
|
||||
}
|
||||
|
||||
if whois != nil {
|
||||
resp.ViewerIdentity = &viewerIdentity{
|
||||
LoginName: whois.UserProfile.LoginName,
|
||||
NodeName: whois.Node.Name,
|
||||
ProfilePicURL: whois.UserProfile.ProfilePicURL,
|
||||
}
|
||||
if addrs := whois.Node.Addresses; len(addrs) > 0 {
|
||||
resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()
|
||||
}
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
@@ -746,7 +462,7 @@ type newSessionAuthResponse struct {
|
||||
|
||||
// serveAPIAuthSessionNew handles requests to the /api/auth/session/new endpoint.
|
||||
func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request) {
|
||||
session, whois, _, err := s.getSession(r)
|
||||
session, whois, err := s.getSession(r)
|
||||
if err != nil && !errors.Is(err, errNoSession) {
|
||||
// Source associated with request not allowed to create
|
||||
// a session for this web client.
|
||||
@@ -763,19 +479,11 @@ func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
// Set the cookie on browser.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: session.ID,
|
||||
Raw: session.ID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: session.expires(),
|
||||
// We can't set Secure to true because we serve over HTTP
|
||||
// (but only on Tailscale IPs, hence over encrypted
|
||||
// connections that a LAN-local attacker cannot sniff).
|
||||
// In the future, we could support HTTPS requests using
|
||||
// the full MagicDNS hostname, and could set this.
|
||||
// Secure: true,
|
||||
Name: sessionCookieName,
|
||||
Value: session.ID,
|
||||
Raw: session.ID,
|
||||
Path: "/",
|
||||
Expires: session.expires(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -784,7 +492,7 @@ func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// serveAPIAuthSessionWait handles requests to the /api/auth/session/wait endpoint.
|
||||
func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request) {
|
||||
session, _, _, err := s.getSession(r)
|
||||
session, _, err := s.getSession(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
@@ -798,13 +506,36 @@ 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 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 string
|
||||
IP string // IPv4
|
||||
IPv6 string
|
||||
OS string
|
||||
IPNVersion string
|
||||
@@ -823,28 +554,15 @@ type nodeData struct {
|
||||
UnraidToken string
|
||||
URLPrefix string // if set, the URL prefix the client is served behind
|
||||
|
||||
UsingExitNode *exitNode
|
||||
AdvertisingExitNode bool
|
||||
AdvertisingExitNodeApproved bool // whether running this node as an exit node has been approved by an admin
|
||||
AdvertisedRoutes []subnetRoute // excludes exit node routes
|
||||
RunningSSHServer bool
|
||||
UsingExitNode *exitNode
|
||||
AdvertisingExitNode bool
|
||||
AdvertisedRoutes []subnetRoute // excludes exit node routes
|
||||
RunningSSHServer bool
|
||||
|
||||
ClientVersion *tailcfg.ClientVersion
|
||||
|
||||
// whether tailnet ACLs allow access to port 5252 on this device
|
||||
ACLAllowsAnyIncomingTraffic bool
|
||||
|
||||
ControlAdminURL string
|
||||
LicensesURL string
|
||||
|
||||
// Features is the set of available features for use on the
|
||||
// current platform. e.g. "ssh", "advertise-exit-node", etc.
|
||||
// Map value is true if the given feature key is available.
|
||||
//
|
||||
// See web.availableFeatures func for population of this field.
|
||||
// Contents are expected to match values defined in node-data.ts
|
||||
// on the frontend.
|
||||
Features map[string]bool
|
||||
}
|
||||
|
||||
type subnetRoute struct {
|
||||
@@ -863,7 +581,6 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
filterRules, _ := s.lc.DebugPacketFilterRules(r.Context())
|
||||
data := &nodeData{
|
||||
ID: st.Self.ID,
|
||||
Status: st.BackendState,
|
||||
@@ -882,19 +599,6 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
|
||||
ControlAdminURL: prefs.AdminPageURL(),
|
||||
LicensesURL: licenses.LicensesURL(),
|
||||
Features: availableFeatures(),
|
||||
|
||||
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
|
||||
data.URLPrefix = r.Header.Get("X-Ingress-Path")
|
||||
}
|
||||
|
||||
cv, err := s.lc.CheckUpdate(r.Context())
|
||||
@@ -903,7 +607,16 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
data.ClientVersion = cv
|
||||
}
|
||||
|
||||
for _, ip := range st.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
data.IP = ip.String()
|
||||
} else if ip.Is6() {
|
||||
data.IPv6 = ip.String()
|
||||
}
|
||||
if data.IP != "" && data.IPv6 != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if st.CurrentTailnet != nil {
|
||||
data.TailnetName = st.CurrentTailnet.MagicDNSSuffix
|
||||
data.DomainName = st.CurrentTailnet.Name
|
||||
@@ -923,10 +636,8 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
return p == route
|
||||
})
|
||||
}
|
||||
data.AdvertisingExitNodeApproved = routeApproved(tsaddr.AllIPv4()) || routeApproved(tsaddr.AllIPv6())
|
||||
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if tsaddr.IsExitRoute(r) {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
data.AdvertisingExitNode = true
|
||||
} else {
|
||||
data.AdvertisedRoutes = append(data.AdvertisedRoutes, subnetRoute{
|
||||
@@ -960,31 +671,6 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, *data)
|
||||
}
|
||||
|
||||
func availableFeatures() map[string]bool {
|
||||
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,
|
||||
"auto-update": version.IsUnstableBuild() && clientupdate.CanAutoUpdate(),
|
||||
}
|
||||
return features
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *Server) aclsAllowAccess(rules []tailcfg.FilterRule) bool {
|
||||
for _, rule := range rules {
|
||||
for _, dp := range rule.DstPorts {
|
||||
if dp.Ports.Contains(ListenPort) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type exitNode struct {
|
||||
ID tailcfg.StableNodeID
|
||||
Name string
|
||||
@@ -1007,68 +693,48 @@ func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
|
||||
ID: ps.ID,
|
||||
Name: ps.DNSName,
|
||||
Location: ps.Location,
|
||||
Online: ps.Online,
|
||||
})
|
||||
}
|
||||
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
|
||||
UseExitNode tailcfg.StableNodeID
|
||||
AdvertiseExitNode bool
|
||||
AdvertiseRoutes []string
|
||||
AdvertiseExitNode bool
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
oldPrefs, err := s.lc.GetPrefs(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var currNonExitRoutes []string
|
||||
var currAdvertisingExitNode bool
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if tsaddr.IsExitRoute(r) {
|
||||
currAdvertisingExitNode = true
|
||||
continue
|
||||
}
|
||||
currNonExitRoutes = append(currNonExitRoutes, r.String())
|
||||
}
|
||||
// Set non-edited fields to their current values.
|
||||
if data.SetExitNode {
|
||||
data.AdvertiseRoutes = currNonExitRoutes
|
||||
} else if data.SetRoutes {
|
||||
data.AdvertiseExitNode = currAdvertisingExitNode
|
||||
data.UseExitNode = prefs.ExitNodeID
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate routes.
|
||||
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.
|
||||
@@ -1080,8 +746,21 @@ 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
|
||||
}
|
||||
|
||||
// Report metrics.
|
||||
if data.AdvertiseExitNode != hasExitNodeRoute(oldPrefs.AdvertiseRoutes) {
|
||||
if data.AdvertiseExitNode {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_enable", 1)
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_disable", 1)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// tailscaleUp starts the daemon with the provided options.
|
||||
@@ -1126,15 +805,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)
|
||||
@@ -1209,31 +880,23 @@ func (s *Server) serveTailscaleUp(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// serveDeviceDetailsClick increments the web_client_device_details_click metric
|
||||
// by one.
|
||||
//
|
||||
// Metric logging from the frontend typically is proxied to the localapi. This event
|
||||
// has been special cased as access to the localapi is gated upon having a valid
|
||||
// session which is not always the case when we want to be logging this metric (e.g.,
|
||||
// when in readonly mode).
|
||||
//
|
||||
// Other metrics should not be logged in this way without a good reason.
|
||||
func (s *Server) serveDeviceDetailsClick(w http.ResponseWriter, r *http.Request) {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_device_details_click", 1)
|
||||
|
||||
io.WriteString(w, "{}")
|
||||
}
|
||||
|
||||
// proxyRequestToLocalAPI proxies the web API request to the localapi.
|
||||
//
|
||||
// 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 !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)
|
||||
@@ -1258,6 +921,20 @@ 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",
|
||||
}
|
||||
|
||||
// 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"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -87,172 +85,60 @@ 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: &tailscale.LocalClient{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
|
||||
reqMethod string
|
||||
reqContentType string
|
||||
reqBody string
|
||||
tests []requestTest
|
||||
name string
|
||||
reqPath string
|
||||
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",
|
||||
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",
|
||||
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,
|
||||
}},
|
||||
}, {
|
||||
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",
|
||||
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,
|
||||
}},
|
||||
name: "in_localapi_allowlist",
|
||||
reqPath: "/local/v0/logout",
|
||||
wantResp: "success", // Successfully allowed to hit localapi.
|
||||
wantStatus: http.StatusOK,
|
||||
}}
|
||||
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("POST", "/api"+tt.reqPath, nil)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +168,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil, nil)
|
||||
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
@@ -419,7 +305,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
if tt.cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
}
|
||||
session, _, _, err := s.getSession(r)
|
||||
session, _, err := s.getSession(r)
|
||||
if !errors.Is(err, tt.wantError) {
|
||||
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
|
||||
}
|
||||
@@ -450,7 +336,6 @@ func TestAuthorizeRequest(t *testing.T) {
|
||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
@@ -548,7 +433,6 @@ func TestServeAuth(t *testing.T) {
|
||||
NodeName: remoteNode.Node.Name,
|
||||
NodeIP: remoteIP,
|
||||
ProfilePicURL: user.ProfilePicURL,
|
||||
Capabilities: peerCapabilities{capFeatureAll: true},
|
||||
}
|
||||
|
||||
testControlURL := &defaultControlURL
|
||||
@@ -561,7 +445,6 @@ func TestServeAuth(t *testing.T) {
|
||||
func() *ipn.Prefs {
|
||||
return &ipn.Prefs{ControlURL: *testControlURL}
|
||||
},
|
||||
nil,
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
@@ -622,7 +505,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},
|
||||
wantNewCookie: false,
|
||||
wantSession: nil,
|
||||
},
|
||||
@@ -647,7 +530,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},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
@@ -695,7 +578,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},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
@@ -830,286 +713,6 @@ func TestServeAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeAPIAuthMetricLogging specifically tests metric logging in the serveAPIAuth function.
|
||||
// For each given test case, we assert that the local API received a request to log the expected metric.
|
||||
func TestServeAPIAuthMetricLogging(t *testing.T) {
|
||||
user := &tailcfg.UserProfile{LoginName: "user@example.com", ID: tailcfg.UserID(1)}
|
||||
otherUser := &tailcfg.UserProfile{LoginName: "user2@example.com", ID: tailcfg.UserID(2)}
|
||||
self := &ipnstate.PeerStatus{
|
||||
ID: "self",
|
||||
UserID: user.ID,
|
||||
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
|
||||
}
|
||||
remoteIP := "100.100.100.101"
|
||||
remoteNode := &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{
|
||||
Name: "remote-managed",
|
||||
ID: 1,
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")},
|
||||
},
|
||||
UserProfile: user,
|
||||
}
|
||||
remoteTaggedIP := "100.123.100.213"
|
||||
remoteTaggedNode := &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{
|
||||
Name: "remote-tagged",
|
||||
ID: 2,
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix(remoteTaggedIP + "/32")},
|
||||
Tags: []string{"dev-machine"},
|
||||
},
|
||||
UserProfile: user,
|
||||
}
|
||||
localIP := "100.1.2.3"
|
||||
localNode := &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{
|
||||
Name: "local-managed",
|
||||
ID: 3,
|
||||
StableID: "self",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix(localIP + "/32")},
|
||||
},
|
||||
UserProfile: user,
|
||||
}
|
||||
localTaggedIP := "100.1.2.133"
|
||||
localTaggedNode := &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{
|
||||
Name: "local-tagged",
|
||||
ID: 4,
|
||||
StableID: "self",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix(localTaggedIP + "/32")},
|
||||
Tags: []string{"prod-machine"},
|
||||
},
|
||||
UserProfile: user,
|
||||
}
|
||||
otherIP := "100.100.2.3"
|
||||
otherNode := &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{
|
||||
Name: "other-node",
|
||||
ID: 5,
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix(otherIP + "/32")},
|
||||
},
|
||||
UserProfile: otherUser,
|
||||
}
|
||||
nonTailscaleIP := "10.100.2.3"
|
||||
|
||||
testControlURL := &defaultControlURL
|
||||
var loggedMetrics []string
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t,
|
||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode, localIP: localNode, otherIP: otherNode, localTaggedIP: localTaggedNode, remoteTaggedIP: remoteTaggedNode},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
func() *ipn.Prefs {
|
||||
return &ipn.Prefs{ControlURL: *testControlURL}
|
||||
},
|
||||
func(metricName string) {
|
||||
loggedMetrics = append(loggedMetrics, metricName)
|
||||
},
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
timeNow := time.Now()
|
||||
oneHourAgo := timeNow.Add(-time.Hour)
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: func() time.Time { return timeNow },
|
||||
newAuthURL: mockNewAuthURL,
|
||||
waitAuthURL: mockWaitAuthURL,
|
||||
}
|
||||
|
||||
authenticatedRemoteNodeCookie := "ts-cookie-remote-node-authenticated"
|
||||
s.browserSessions.Store(authenticatedRemoteNodeCookie, &browserSession{
|
||||
ID: authenticatedRemoteNodeCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: *testControlURL + testAuthPathSuccess,
|
||||
Authenticated: true,
|
||||
})
|
||||
authenticatedLocalNodeCookie := "ts-cookie-local-node-authenticated"
|
||||
s.browserSessions.Store(authenticatedLocalNodeCookie, &browserSession{
|
||||
ID: authenticatedLocalNodeCookie,
|
||||
SrcNode: localNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: *testControlURL + testAuthPathSuccess,
|
||||
Authenticated: true,
|
||||
})
|
||||
unauthenticatedRemoteNodeCookie := "ts-cookie-remote-node-unauthenticated"
|
||||
s.browserSessions.Store(unauthenticatedRemoteNodeCookie, &browserSession{
|
||||
ID: unauthenticatedRemoteNodeCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: *testControlURL + testAuthPathSuccess,
|
||||
Authenticated: false,
|
||||
})
|
||||
unauthenticatedLocalNodeCookie := "ts-cookie-local-node-unauthenticated"
|
||||
s.browserSessions.Store(unauthenticatedLocalNodeCookie, &browserSession{
|
||||
ID: unauthenticatedLocalNodeCookie,
|
||||
SrcNode: localNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: *testControlURL + testAuthPathSuccess,
|
||||
Authenticated: false,
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cookie string // cookie attached to request
|
||||
remoteAddr string // remote address to hit
|
||||
|
||||
wantLoggedMetric string // expected metric to be logged
|
||||
}{
|
||||
{
|
||||
name: "managing-remote",
|
||||
cookie: authenticatedRemoteNodeCookie,
|
||||
remoteAddr: remoteIP,
|
||||
wantLoggedMetric: "web_client_managing_remote",
|
||||
},
|
||||
{
|
||||
name: "managing-local",
|
||||
cookie: authenticatedLocalNodeCookie,
|
||||
remoteAddr: localIP,
|
||||
wantLoggedMetric: "web_client_managing_local",
|
||||
},
|
||||
{
|
||||
name: "viewing-not-owner",
|
||||
cookie: authenticatedRemoteNodeCookie,
|
||||
remoteAddr: otherIP,
|
||||
wantLoggedMetric: "web_client_viewing_not_owner",
|
||||
},
|
||||
{
|
||||
name: "viewing-local-tagged",
|
||||
cookie: authenticatedLocalNodeCookie,
|
||||
remoteAddr: localTaggedIP,
|
||||
wantLoggedMetric: "web_client_viewing_local_tag",
|
||||
},
|
||||
{
|
||||
name: "viewing-remote-tagged",
|
||||
cookie: authenticatedRemoteNodeCookie,
|
||||
remoteAddr: remoteTaggedIP,
|
||||
wantLoggedMetric: "web_client_viewing_remote_tag",
|
||||
},
|
||||
{
|
||||
name: "viewing-local-non-tailscale",
|
||||
cookie: authenticatedLocalNodeCookie,
|
||||
remoteAddr: nonTailscaleIP,
|
||||
wantLoggedMetric: "web_client_viewing_local",
|
||||
},
|
||||
{
|
||||
name: "viewing-local-unauthenticated",
|
||||
cookie: unauthenticatedLocalNodeCookie,
|
||||
remoteAddr: localIP,
|
||||
wantLoggedMetric: "web_client_viewing_local",
|
||||
},
|
||||
{
|
||||
name: "viewing-remote-unauthenticated",
|
||||
cookie: unauthenticatedRemoteNodeCookie,
|
||||
remoteAddr: remoteIP,
|
||||
wantLoggedMetric: "web_client_viewing_remote",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testControlURL = &defaultControlURL
|
||||
|
||||
r := httptest.NewRequest("GET", "http://100.1.2.3:5252/api/auth", nil)
|
||||
r.RemoteAddr = tt.remoteAddr
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
w := httptest.NewRecorder()
|
||||
s.serveAPIAuth(w, r)
|
||||
|
||||
if !slices.Contains(loggedMetrics, tt.wantLoggedMetric) {
|
||||
t.Errorf("expected logged metrics to contain: '%s' but was: '%v'", tt.wantLoggedMetric, loggedMetrics)
|
||||
}
|
||||
loggedMetrics = []string{}
|
||||
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathPrefix tests that the provided path prefix is normalized correctly.
|
||||
// If a leading '/' is missing, one should be added.
|
||||
// If multiple leading '/' are present, they should be collapsed to one.
|
||||
// Additionally verify that this prevents open redirects when enforcing the path prefix.
|
||||
func TestPathPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
wantPrefix string
|
||||
wantLocation string
|
||||
}{
|
||||
{
|
||||
name: "no-leading-slash",
|
||||
prefix: "javascript:alert(1)",
|
||||
wantPrefix: "/javascript:alert(1)",
|
||||
wantLocation: "/javascript:alert(1)/",
|
||||
},
|
||||
{
|
||||
name: "2-slashes",
|
||||
prefix: "//evil.example.com/goat",
|
||||
// We must also get the trailing slash added:
|
||||
wantPrefix: "/evil.example.com/goat",
|
||||
wantLocation: "/evil.example.com/goat/",
|
||||
},
|
||||
{
|
||||
name: "absolute-url",
|
||||
prefix: "http://evil.example.com",
|
||||
// We must also get the trailing slash added:
|
||||
wantPrefix: "/http:/evil.example.com",
|
||||
wantLocation: "/http:/evil.example.com/",
|
||||
},
|
||||
{
|
||||
name: "double-dot",
|
||||
prefix: "/../.././etc/passwd",
|
||||
// We must also get the trailing slash added:
|
||||
wantPrefix: "/etc/passwd",
|
||||
wantLocation: "/etc/passwd/",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
options := ServerOpts{
|
||||
Mode: LoginServerMode,
|
||||
PathPrefix: tt.prefix,
|
||||
CGIMode: true,
|
||||
}
|
||||
s, err := NewServer(options)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// verify provided prefix was normalized correctly
|
||||
if s.pathPrefix != tt.wantPrefix {
|
||||
t.Errorf("prefix was not normalized correctly; want=%q, got=%q", tt.wantPrefix, s.pathPrefix)
|
||||
}
|
||||
|
||||
s.logf = t.Logf
|
||||
r := httptest.NewRequest(httpm.GET, "http://localhost/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
if location != tt.wantLocation {
|
||||
t.Errorf("request got wrong location; want=%q, got=%q", tt.wantLocation, location)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireTailscaleIP(t *testing.T) {
|
||||
self := &ipnstate.PeerStatus{
|
||||
TailscaleIPs: []netip.Addr{
|
||||
@@ -1120,7 +723,7 @@ func TestRequireTailscaleIP(t *testing.T) {
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil, nil)
|
||||
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
@@ -1178,7 +781,7 @@ func TestRequireTailscaleIP(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Run(tt.target, func(t *testing.T) {
|
||||
s.logf = t.Logf
|
||||
r := httptest.NewRequest(httpm.GET, tt.target, nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1196,217 +799,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"
|
||||
@@ -1420,7 +812,7 @@ var (
|
||||
// self accepts a function that resolves to a self node status,
|
||||
// so that tests may swap out the /localapi/v0/status response
|
||||
// as desired.
|
||||
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs, metricCapture func(string)) *http.Server {
|
||||
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs) *http.Server {
|
||||
return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/localapi/v0/whois":
|
||||
@@ -1440,22 +832,6 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
|
||||
case "/localapi/v0/prefs":
|
||||
writeJSON(w, prefs())
|
||||
return
|
||||
case "/localapi/v0/upload-client-metrics":
|
||||
type metricName struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var metricNames []metricName
|
||||
if err := json.NewDecoder(r.Body).Decode(&metricNames); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
1874
client/web/yarn.lock
1874
client/web/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -27,25 +27,21 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"tailscale.com/clientupdate/distsign"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/cmpver"
|
||||
"tailscale.com/util/winutil"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -67,19 +63,16 @@ func versionToTrack(v string) (string, error) {
|
||||
|
||||
// Arguments contains arguments needed to run an update.
|
||||
type Arguments struct {
|
||||
// Version is the specific version to install.
|
||||
// Mutually exclusive with Track.
|
||||
Version string
|
||||
// Track is the release track to use:
|
||||
// Version can be a specific version number or one of the predefined track
|
||||
// constants:
|
||||
//
|
||||
// - CurrentTrack will use the latest version from the same track as the
|
||||
// running binary
|
||||
// - StableTrack and UnstableTrack will use the latest versions of the
|
||||
// corresponding tracks
|
||||
//
|
||||
// Leaving this empty will use Version or fall back to CurrentTrack if both
|
||||
// Track and Version are empty.
|
||||
Track string
|
||||
// Leaving this empty is the same as using CurrentTrack.
|
||||
Version string
|
||||
// Logf is a logger for update progress messages.
|
||||
Logf logger.Logf
|
||||
// Stdout and Stderr should be used for output instead of os.Stdout and
|
||||
@@ -106,34 +99,20 @@ func (args Arguments) validate() error {
|
||||
if args.Logf == nil {
|
||||
return errors.New("missing Logf callback in Arguments")
|
||||
}
|
||||
if args.Version != "" && args.Track != "" {
|
||||
return fmt.Errorf("only one of Version(%q) or Track(%q) can be set", args.Version, args.Track)
|
||||
}
|
||||
switch args.Track {
|
||||
case StableTrack, UnstableTrack, "":
|
||||
// All valid values.
|
||||
default:
|
||||
return fmt.Errorf("unsupported track %q", args.Track)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Updater struct {
|
||||
Arguments
|
||||
track string
|
||||
// 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
|
||||
@@ -149,15 +128,20 @@ func NewUpdater(args Arguments) (*Updater, error) {
|
||||
if args.ForAutoUpdate && !canAutoUpdate {
|
||||
return nil, errors.ErrUnsupported
|
||||
}
|
||||
if up.Track == "" {
|
||||
if up.Version != "" {
|
||||
var err error
|
||||
up.Track, err = versionToTrack(args.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch up.Version {
|
||||
case StableTrack, UnstableTrack:
|
||||
up.track = up.Version
|
||||
case CurrentTrack:
|
||||
if version.IsUnstableBuild() {
|
||||
up.track = UnstableTrack
|
||||
} else {
|
||||
up.Track = CurrentTrack
|
||||
up.track = StableTrack
|
||||
}
|
||||
default:
|
||||
var err error
|
||||
up.track, err = versionToTrack(args.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if up.Arguments.PkgsAddr == "" {
|
||||
@@ -174,10 +158,6 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
|
||||
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
|
||||
@@ -242,18 +222,6 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Update runs a single update attempt using the platform-specific mechanism.
|
||||
//
|
||||
// On Windows, this copies the calling binary and re-executes it to apply the
|
||||
@@ -271,16 +239,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)
|
||||
@@ -305,7 +270,7 @@ func (up *Updater) updateSynology() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest, err := latestPackages(up.Track)
|
||||
latest, err := latestPackages(up.track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -324,7 +289,7 @@ func (up *Updater) updateSynology() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkgsPath := fmt.Sprintf("%s/%s", up.Track, spkName)
|
||||
pkgsPath := fmt.Sprintf("%s/%s", up.track, spkName)
|
||||
spkPath := filepath.Join(spkDir, path.Base(pkgsPath))
|
||||
if err := up.downloadURLToFile(pkgsPath, spkPath); err != nil {
|
||||
return err
|
||||
@@ -423,7 +388,7 @@ func (up *Updater) updateDebLike() error {
|
||||
// instead.
|
||||
return up.updateLinuxBinary()
|
||||
}
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.Track)
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -431,10 +396,10 @@ func (up *Updater) updateDebLike() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if updated, err := updateDebianAptSourcesList(up.Track); err != nil {
|
||||
if updated, err := updateDebianAptSourcesList(up.track); err != nil {
|
||||
return err
|
||||
} else if updated {
|
||||
up.Logf("Updated %s to use the %s track", aptSourcesFile, up.Track)
|
||||
up.Logf("Updated %s to use the %s track", aptSourcesFile, up.track)
|
||||
}
|
||||
|
||||
cmd := exec.Command("apt-get", "update",
|
||||
@@ -451,7 +416,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`)) {
|
||||
@@ -541,13 +506,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,
|
||||
@@ -569,7 +527,7 @@ func (up *Updater) updateFedoraLike(packageManager string) func() error {
|
||||
}
|
||||
}()
|
||||
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.Track)
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -577,10 +535,10 @@ func (up *Updater) updateFedoraLike(packageManager string) func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.Track); err != nil {
|
||||
if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.track); err != nil {
|
||||
return err
|
||||
} else if updated {
|
||||
up.Logf("Updated %s to use the %s track", yumRepoConfigFile, up.Track)
|
||||
up.Logf("Updated %s to use the %s track", yumRepoConfigFile, up.track)
|
||||
}
|
||||
|
||||
cmd := exec.Command(packageManager, "install", "--assumeyes", fmt.Sprintf("tailscale-%s-1", ver))
|
||||
@@ -666,9 +624,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
|
||||
}
|
||||
|
||||
@@ -683,7 +638,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:
|
||||
@@ -695,48 +649,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")
|
||||
}
|
||||
@@ -753,6 +670,176 @@ 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 // or nil on non-Windows
|
||||
markTempFileFunc func(string) error // or nil on non-Windows
|
||||
launchTailscaleAsWinGUIUser func(string) error // or nil on non-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("relaunching tailscale-ipn.exe...")
|
||||
exePath := os.Getenv(winExePathEnv)
|
||||
if exePath == "" {
|
||||
up.Logf("env var %q not passed to installer binary copy", winExePathEnv)
|
||||
return fmt.Errorf("env var %q not passed to installer binary copy", winExePathEnv)
|
||||
}
|
||||
if err := launchTailscaleAsWinGUIUser(exePath); err != nil {
|
||||
up.Logf("Failed to re-launch tailscale after update: %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...")
|
||||
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++ {
|
||||
// TS_NOLAUNCH: don't automatically launch the app after install.
|
||||
// We will launch it explicitly as the current GUI user afterwards.
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn", "TS_NOLAUNCH=true")
|
||||
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.
|
||||
@@ -777,6 +864,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")
|
||||
@@ -829,7 +963,7 @@ func (up *Updater) updateLinuxBinary() error {
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.Track)
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -861,20 +995,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 {
|
||||
@@ -884,7 +1004,7 @@ func (up *Updater) downloadLinuxTarball(ver string) (string, error) {
|
||||
if err := os.MkdirAll(dlDir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
pkgsPath := fmt.Sprintf("%s/tailscale_%s_%s.tgz", up.Track, ver, runtime.GOARCH)
|
||||
pkgsPath := fmt.Sprintf("%s/tailscale_%s_%s.tgz", up.track, ver, runtime.GOARCH)
|
||||
dlPath := filepath.Join(dlDir, path.Base(pkgsPath))
|
||||
if err := up.downloadURLToFile(pkgsPath, dlPath); err != nil {
|
||||
return "", err
|
||||
@@ -1141,31 +1261,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,55 +9,20 @@ package clientupdate
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"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
|
||||
launchTailscaleAsWinGUIUser = launchTailscaleAsGUIUser
|
||||
}
|
||||
|
||||
func markTempFileWindows(name string) error {
|
||||
@@ -67,159 +32,53 @@ 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()
|
||||
func launchTailscaleAsGUIUser(exePath string) error {
|
||||
exePath = filepath.Join(filepath.Dir(exePath), "tailscale-ipn.exe")
|
||||
|
||||
var token windows.Token
|
||||
if u, err := user.Current(); err == nil && u.Name == "SYSTEM" {
|
||||
sessionID, err := wtsGetActiveSessionID()
|
||||
if err != nil {
|
||||
up.Logf("failed to create log file for installation: %v; proceeding with existing outputs", err)
|
||||
} else {
|
||||
defer close.Close()
|
||||
return fmt.Errorf("wtsGetActiveSessionID(): %w", err)
|
||||
}
|
||||
|
||||
up.Logf("installing %v ...", msi)
|
||||
if err := up.installMSI(msi); err != nil {
|
||||
up.Logf("MSI install failed: %v", err)
|
||||
return err
|
||||
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
|
||||
return fmt.Errorf("WTSQueryUserToken (0x%x): %w", sessionID, err)
|
||||
}
|
||||
|
||||
up.Logf("success.")
|
||||
return nil
|
||||
defer token.Close()
|
||||
}
|
||||
|
||||
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`)
|
||||
cmd := exec.Command(exePath)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Token: syscall.Token(token),
|
||||
HideWindow: true,
|
||||
}
|
||||
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")
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
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
|
||||
func wtsGetActiveSessionID() (uint32, error) {
|
||||
var (
|
||||
sessionInfo *windows.WTS_SESSION_INFO
|
||||
count uint32 = 0
|
||||
)
|
||||
|
||||
const WTS_CURRENT_SERVER_HANDLE = 0
|
||||
if err := windows.WTSEnumerateSessions(WTS_CURRENT_SERVER_HANDLE, 0, 1, &sessionInfo, &count); err != nil {
|
||||
return 0, fmt.Errorf("WTSEnumerateSessions: %w", err)
|
||||
}
|
||||
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessionInfo)))
|
||||
|
||||
current := unsafe.Pointer(sessionInfo)
|
||||
for i := uint32(0); i < count; i++ {
|
||||
session := (*windows.WTS_SESSION_INFO)(current)
|
||||
if session.State == windows.WTSActive {
|
||||
return session.SessionID, nil
|
||||
}
|
||||
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)
|
||||
current = unsafe.Add(current, unsafe.Sizeof(windows.WTS_SESSION_INFO{}))
|
||||
}
|
||||
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
|
||||
|
||||
return 0, errors.New("no active desktop sessions found")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user