Compare commits
1 Commits
jonathan/s
...
bradfitz/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a794630f60 |
28
.github/workflows/checklocks.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: checklocks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- '.github/workflows/checklocks.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
checklocks:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
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: remove || true once we have applied checklocks annotations everywhere.
|
||||
run: ./tool/go vet -vettool=/tmp/checklocks ./... || true
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
@@ -47,12 +47,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Install a more recent Go that understands modern go.mod content.
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
|
||||
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
|
||||
4
.github/workflows/golangci-lint.yml
vendored
@@ -31,10 +31,10 @@ jobs:
|
||||
cache: false
|
||||
|
||||
- name: golangci-lint
|
||||
# Note: this is the 'v3' tag as of 2023-08-14
|
||||
# Note: this is the 'v3' tag as of 2023-04-17
|
||||
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
|
||||
with:
|
||||
version: v1.56
|
||||
version: v1.52.2
|
||||
|
||||
# Show only new issues if it's a pull request.
|
||||
only-new-issues: true
|
||||
|
||||
35
.github/workflows/govulncheck.yml
vendored
@@ -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@v1.24.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'
|
||||
|
||||
6
.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"
|
||||
@@ -90,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@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: run installer
|
||||
run: scripts/installer.sh
|
||||
# Package installation can fail in docker because systemd is not running
|
||||
|
||||
31
.github/workflows/kubemanifests.yaml
vendored
@@ -1,31 +0,0 @@
|
||||
name: "Kubernetes manifests"
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cmd/k8s-operator/**'
|
||||
- 'k8s-operator/**'
|
||||
- '.github/workflows/kubemanifests.yaml'
|
||||
|
||||
# Cancel workflow run if there is a newer push to the same PR for which it is
|
||||
# running
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
testchart:
|
||||
runs-on: [ ubuntu-latest ]
|
||||
steps:
|
||||
- name: Check out code
|
||||
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)
|
||||
117
.github/workflows/test.yml
vendored
@@ -22,7 +22,8 @@ on:
|
||||
- "main"
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
# all PRs on all branches
|
||||
branches:
|
||||
- "*"
|
||||
merge_group:
|
||||
branches:
|
||||
- "main"
|
||||
@@ -38,42 +39,14 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
race-root-integration:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
- shard: '1/4'
|
||||
- shard: '2/4'
|
||||
- shard: '3/4'
|
||||
- shard: '4/4'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: build test wrapper
|
||||
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
|
||||
- name: integration tests as root
|
||||
run: PATH=$PWD/tool:$PATH /tmp/testwrapper -exec "sudo -E" -race ./tstest/integration/
|
||||
env:
|
||||
TS_TEST_SHARD: ${{ matrix.shard }}
|
||||
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
- goarch: amd64
|
||||
coverflags: "-coverprofile=/tmp/coverage.out"
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '1/3'
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '2/3'
|
||||
- goarch: amd64
|
||||
buildflags: "-race"
|
||||
shard: '3/3'
|
||||
- goarch: "386" # thanks yaml
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
@@ -97,7 +70,6 @@ jobs:
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-2-
|
||||
- name: build all
|
||||
if: matrix.buildflags == '' # skip on race builder
|
||||
run: ./tool/go build ${{matrix.buildflags}} ./...
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
@@ -119,15 +91,9 @@ jobs:
|
||||
- name: build test wrapper
|
||||
run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper
|
||||
- name: test all
|
||||
run: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ${{matrix.coverflags}} ./... ${{matrix.buildflags}}
|
||||
run: PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}}
|
||||
env:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
TS_TEST_SHARD: ${{ matrix.shard }}
|
||||
- name: Publish to coveralls.io
|
||||
if: matrix.coverflags != '' # only publish results if we've tracked coverage
|
||||
uses: shogo82148/actions-goveralls@v1
|
||||
with:
|
||||
path-to-profile: /tmp/coverage.out
|
||||
- name: bench all
|
||||
run: ./tool/go test ${{matrix.buildflags}} -bench=. -benchtime=1x -run=^$ $(for x in $(git grep -l "^func Benchmark" | xargs dirname | sort | uniq); do echo "./$x"; done)
|
||||
env:
|
||||
@@ -183,19 +149,6 @@ 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@v4
|
||||
- name: chown
|
||||
run: chown -R $(id -u):$(id -g) $PWD
|
||||
- name: privileged tests
|
||||
run: ./tool/go test ./util/linuxfw
|
||||
|
||||
vm:
|
||||
runs-on: ["self-hosted", "linux", "vm"]
|
||||
# VM tests run with some privileges, don't let them run on 3p PRs.
|
||||
@@ -206,20 +159,10 @@ jobs:
|
||||
- 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"
|
||||
|
||||
race-build:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: build all
|
||||
run: ./tool/go install -race ./cmd/...
|
||||
- name: build tests
|
||||
run: ./tool/go test -race -exec=true ./...
|
||||
|
||||
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
cross: # cross-compile checks, build only.
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
@@ -254,6 +197,9 @@ jobs:
|
||||
goarch: amd64
|
||||
- goos: openbsd
|
||||
goarch: amd64
|
||||
# Plan9
|
||||
- goos: plan9
|
||||
goarch: amd64
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
@@ -302,47 +248,6 @@ jobs:
|
||||
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@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod/cache
|
||||
~\AppData\Local\go-build
|
||||
# The -2- here should be incremented when the scheme of data to be
|
||||
# cached changes (e.g. path above changes).
|
||||
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-
|
||||
- name: build 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,
|
||||
@@ -356,7 +261,7 @@ jobs:
|
||||
# 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
|
||||
@@ -480,7 +385,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go generate' is clean
|
||||
run: |
|
||||
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator')
|
||||
pkgs=$(./tool/go list ./... | grep -v dnsfallback)
|
||||
./tool/go generate $pkgs
|
||||
echo
|
||||
echo
|
||||
|
||||
52
.github/workflows/update-webclient-prebuilt.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: update-webclient-prebuilt
|
||||
|
||||
on:
|
||||
# manually triggered
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-webclient-prebuilt:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run go get
|
||||
run: |
|
||||
./tool/go version # build gocross if needed using regular GOPROXY
|
||||
GOPROXY=direct ./tool/go get github.com/tailscale/web-client-prebuilt
|
||||
./tool/go mod tidy
|
||||
|
||||
- name: Get access token
|
||||
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_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@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: OSS Updater <noreply+oss-updater@tailscale.com>
|
||||
committer: OSS Updater <noreply+oss-updater@tailscale.com>
|
||||
branch: actions/update-webclient-prebuilt
|
||||
commit-message: "go.mod: update web-client-prebuilt module"
|
||||
title: "go.mod: update web-client-prebuilt module"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
reviewers: ${{ github.triggering_actor }}
|
||||
|
||||
- name: Summary
|
||||
if: ${{ steps.pull-request.outputs.pull-request-number }}
|
||||
run: echo "${{ steps.pull-request.outputs.pull-request-operation}} ${{ steps.pull-request.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY
|
||||
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@v4
|
||||
- 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)
|
||||
1
.gitignore
vendored
@@ -9,7 +9,6 @@
|
||||
|
||||
cmd/tailscale/tailscale
|
||||
cmd/tailscaled/tailscaled
|
||||
ssh/tailssh/testcontainers/tailscaled
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
@@ -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
|
||||
@@ -31,7 +31,7 @@
|
||||
# $ docker exec tailscaled tailscale status
|
||||
|
||||
|
||||
FROM golang:1.22-alpine AS build-env
|
||||
FROM golang:1.21-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src/tailscale
|
||||
|
||||
@@ -66,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
|
||||
|
||||
49
Makefile
@@ -1,27 +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/stund
|
||||
tailscale.com/cmd/derper
|
||||
|
||||
depaware: ## Run depaware checks
|
||||
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
|
||||
@@ -29,8 +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/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
|
||||
@@ -58,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}
|
||||
|
||||
@@ -90,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)
|
||||
@@ -98,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=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 go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
|
||||
GOOS=linux GOARCH=amd64 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:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers
|
||||
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.22. (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.67.0
|
||||
1.51.0
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package appc implements App Connectors.
|
||||
// An AppConnector provides DNS domain oriented routing of traffic. An App
|
||||
// Connector becomes a DNS server for a peer, authoritative for the set of
|
||||
// configured domains. DNS resolution of the target domain triggers dynamic
|
||||
// publication of routes to ensure that traffic to the domain is routed through
|
||||
// the App Connector.
|
||||
package appc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/execqueue"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/slicesx"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// RouteInfo is a data structure used to persist the in memory state of an AppConnector
|
||||
// so that we can know, even after a restart, which routes came from ACLs and which were
|
||||
// learned from domains.
|
||||
type RouteInfo struct {
|
||||
// Control is the routes from the 'routes' section of an app connector acl.
|
||||
Control []netip.Prefix `json:",omitempty"`
|
||||
// Domains are the routes discovered by observing DNS lookups for configured domains.
|
||||
Domains map[string][]netip.Addr `json:",omitempty"`
|
||||
// Wildcards are the configured DNS lookup domains to observe. When a DNS query matches Wildcards,
|
||||
// its result is added to Domains.
|
||||
Wildcards []string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// AppConnector is an implementation of an AppConnector that performs
|
||||
// its function as a subsystem inside of a tailscale node. At the control plane
|
||||
// side App Connector routing is configured in terms of domains rather than IP
|
||||
// addresses.
|
||||
// The AppConnectors responsibility inside tailscaled is to apply the routing
|
||||
// and domain configuration as supplied in the map response.
|
||||
// DNS requests for configured domains are observed. If the domains resolve to
|
||||
// routes not yet served by the AppConnector the local node configuration is
|
||||
// updated to advertise the new route.
|
||||
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 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
|
||||
}
|
||||
|
||||
// NewAppConnector creates a new AppConnector.
|
||||
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser, routeInfo *RouteInfo, storeRoutesFunc func(*RouteInfo) error) *AppConnector {
|
||||
ac := &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
|
||||
}
|
||||
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
|
||||
}
|
||||
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.
|
||||
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()
|
||||
|
||||
var oldDomains map[string][]netip.Addr
|
||||
oldDomains, e.domains = e.domains, make(map[string][]netip.Addr, len(domains))
|
||||
e.wildcards = e.wildcards[:0]
|
||||
for _, d := range domains {
|
||||
d = strings.ToLower(d)
|
||||
if len(d) == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(d, "*.") {
|
||||
e.wildcards = append(e.wildcards, d[2:])
|
||||
continue
|
||||
}
|
||||
e.domains[d] = oldDomains[d]
|
||||
delete(oldDomains, d)
|
||||
}
|
||||
|
||||
// Ensure that still-live wildcards addresses are preserved as well.
|
||||
for d, addrs := range oldDomains {
|
||||
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()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
return views.SliceOf(xmaps.Keys(e.domains))
|
||||
}
|
||||
|
||||
// DomainRoutes returns a map of domains to resolved IP
|
||||
// addresses.
|
||||
func (e *AppConnector) DomainRoutes() map[string][]netip.Addr {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
drCopy := make(map[string][]netip.Addr)
|
||||
for k, v := range e.domains {
|
||||
drCopy[k] = append(drCopy[k], v...)
|
||||
}
|
||||
|
||||
return drCopy
|
||||
}
|
||||
|
||||
// ObserveDNSResponse is a callback invoked by the DNS resolver when a DNS
|
||||
// response is being returned over the PeerAPI. The response is parsed and
|
||||
// matched against the configured domains, if matched the routeAdvertiser is
|
||||
// advised to advertise the discovered route.
|
||||
func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
var p dnsmessage.Parser
|
||||
if _, err := p.Start(res); err != nil {
|
||||
return
|
||||
}
|
||||
if err := p.SkipAllQuestions(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// cnameChain tracks a chain of CNAMEs for a given query in order to reverse
|
||||
// a CNAME chain back to the original query for flattening. The keys are
|
||||
// CNAME record targets, and the value is the name the record answers, so
|
||||
// for www.example.com CNAME example.com, the map would contain
|
||||
// ["example.com"] = "www.example.com".
|
||||
var cnameChain map[string]string
|
||||
|
||||
// addressRecords is a list of address records found in the response.
|
||||
var addressRecords map[string][]netip.Addr
|
||||
|
||||
for {
|
||||
h, err := p.AnswerHeader()
|
||||
if err == dnsmessage.ErrSectionDone {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if h.Class != dnsmessage.ClassINET {
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch h.Type {
|
||||
case dnsmessage.TypeCNAME, dnsmessage.TypeA, dnsmessage.TypeAAAA:
|
||||
default:
|
||||
if err := p.SkipAnswer(); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
|
||||
}
|
||||
|
||||
domain := strings.TrimSuffix(strings.ToLower(h.Name.String()), ".")
|
||||
if len(domain) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if h.Type == dnsmessage.TypeCNAME {
|
||||
res, err := p.CNAMEResource()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cname := strings.TrimSuffix(strings.ToLower(res.CNAME.String()), ".")
|
||||
if len(cname) == 0 {
|
||||
continue
|
||||
}
|
||||
mak.Set(&cnameChain, cname, domain)
|
||||
continue
|
||||
}
|
||||
|
||||
switch h.Type {
|
||||
case dnsmessage.TypeA:
|
||||
r, err := p.AResource()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
addr := netip.AddrFrom4(r.A)
|
||||
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
|
||||
case dnsmessage.TypeAAAA:
|
||||
r, err := p.AAAAResource()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
addr := netip.AddrFrom16(r.AAAA)
|
||||
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
|
||||
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 {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return slicesx.Filter(make([]netip.Prefix, 0, len(a)), a, func(p netip.Prefix) bool {
|
||||
return !m[p]
|
||||
})
|
||||
}
|
||||
@@ -1,522 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package appc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/appc/appctest"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"})
|
||||
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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")}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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", "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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dnsResponse is a test helper that creates a DNS response buffer for the given domain and address
|
||||
func dnsResponse(domain, address string) []byte {
|
||||
addr := netip.MustParseAddr(address)
|
||||
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
|
||||
b.EnableCompression()
|
||||
b.StartAnswers()
|
||||
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())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -20,19 +20,18 @@
|
||||
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"
|
||||
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
|
||||
|
||||
case "$TARGET" in
|
||||
client)
|
||||
@@ -49,10 +48,8 @@ case "$TARGET" in
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--gotags="ts_kube" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
/usr/local/bin/containerboot
|
||||
;;
|
||||
operator)
|
||||
@@ -68,27 +65,10 @@ case "$TARGET" in
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
/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}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
/usr/local/bin/k8s-nameserver
|
||||
;;
|
||||
*)
|
||||
echo "unknown target: $TARGET"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
esac
|
||||
@@ -270,14 +270,6 @@ type UserRuleMatch struct {
|
||||
Users []string `json:"users"`
|
||||
Ports []string `json:"ports"`
|
||||
LineNumber int `json:"lineNumber"`
|
||||
|
||||
// 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
|
||||
@@ -285,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
|
||||
@@ -298,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) {
|
||||
@@ -361,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
|
||||
}
|
||||
|
||||
@@ -390,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
|
||||
}
|
||||
|
||||
@@ -416,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
|
||||
}
|
||||
|
||||
@@ -442,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
|
||||
}
|
||||
|
||||
|
||||
@@ -40,20 +40,3 @@ type SetPushDeviceTokenRequest struct {
|
||||
// PushDeviceToken is the iOS/macOS APNs device token (and any future Android equivalent).
|
||||
PushDeviceToken string
|
||||
}
|
||||
|
||||
// ReloadConfigResponse is the response to a LocalAPI reload-config request.
|
||||
//
|
||||
// There are three possible outcomes: (false, "") if no config mode in use,
|
||||
// (true, "") on success, or (false, "error message") on failure.
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -71,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.
|
||||
|
||||
@@ -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"
|
||||
@@ -39,6 +37,7 @@ import (
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/tkatype"
|
||||
"tailscale.com/util/cmpx"
|
||||
)
|
||||
|
||||
// defaultLocalClient is the default LocalClient when using the legacy
|
||||
@@ -103,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.Connect(lc.socket())
|
||||
s := safesocket.DefaultConnectionStrategy(lc.socket())
|
||||
return safesocket.Connect(s)
|
||||
}
|
||||
|
||||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||||
@@ -480,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))
|
||||
|
||||
@@ -679,26 +679,6 @@ func (lc *LocalClient) CheckIPForwarding(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckUDPGROForwarding asks the local Tailscale daemon whether it looks like
|
||||
// the machine is optimally configured to forward UDP packets as a subnet router
|
||||
// or exit node.
|
||||
func (lc *LocalClient) CheckUDPGROForwarding(ctx context.Context) error {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/check-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 check-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
|
||||
@@ -778,17 +758,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) {
|
||||
@@ -801,11 +770,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 {
|
||||
@@ -1276,22 +1244,6 @@ func (lc *LocalClient) ProfileStatus(ctx context.Context) (current ipn.LoginProf
|
||||
return current, all, err
|
||||
}
|
||||
|
||||
// ReloadConfig reloads the config file, if possible.
|
||||
func (lc *LocalClient) ReloadConfig(ctx context.Context) (ok bool, err error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/reload-config", 200, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res, err := decodeJSON[apitype.ReloadConfigResponse](body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if res.Err != "" {
|
||||
return false, errors.New(res.Err)
|
||||
}
|
||||
return res.Reloaded, nil
|
||||
}
|
||||
|
||||
// SwitchToEmptyProfile creates and switches to a new unnamed profile. The new
|
||||
// profile is not assigned an ID until it is persisted after a successful login.
|
||||
// In order to login to the new profile, the user must call LoginInteractive.
|
||||
@@ -1344,15 +1296,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.
|
||||
@@ -1415,81 +1358,6 @@ func (lc *LocalClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckUpdate returns a tailcfg.ClientVersion indicating whether or not an update is available
|
||||
// to be installed via the LocalAPI. In case the LocalAPI can't install updates, it returns a
|
||||
// ClientVersion that says that we are up to date.
|
||||
func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/update/check")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cv, err := decodeJSON[tailcfg.ClientVersion](body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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.
|
||||
//
|
||||
@@ -1526,12 +1394,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,11 +5,7 @@
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest/deptest"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
sc, err := getServeConfigFromJSON([]byte("null"))
|
||||
@@ -29,15 +25,3 @@ func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
t.Errorf("want non-nil TCP for object")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -14,61 +12,17 @@ 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.
|
||||
cleanup := startDevServer()
|
||||
return devServerProxy(), cleanup
|
||||
}
|
||||
|
||||
fsys := prebuilt.FS()
|
||||
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
|
||||
// the frontend router handle it.
|
||||
r = r.Clone(r.Context())
|
||||
path = "index.html"
|
||||
f, err = openPrecompressedFile(w, r, path, fsys)
|
||||
}
|
||||
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)
|
||||
}), 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
|
||||
return http.FileServer(http.FS(prebuilt.FS())), nil
|
||||
}
|
||||
|
||||
// startDevServer starts the JS dev server that does on-demand rebuilding
|
||||
@@ -81,7 +35,7 @@ func startDevServer() (cleanup func()) {
|
||||
node := filepath.Join(root, "tool", "node")
|
||||
vite := filepath.Join(webClientPath, "node_modules", ".bin", "vite")
|
||||
|
||||
log.Printf("installing JavaScript deps using %s...", yarn)
|
||||
log.Printf("installing JavaScript deps using %s... (might take ~30s)", yarn)
|
||||
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webClientPath, "install").CombinedOutput()
|
||||
if err != nil {
|
||||
log.Fatalf("error running tailscale web's yarn install: %v, %s", err, out)
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "TS-Web-Session"
|
||||
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
|
||||
)
|
||||
|
||||
// browserSession holds data about a user's browser session
|
||||
// on the full management web client.
|
||||
type browserSession struct {
|
||||
// ID is the unique identifier for the session.
|
||||
// It is passed in the user's "TS-Web-Session" browser cookie.
|
||||
ID string
|
||||
SrcNode tailcfg.NodeID
|
||||
SrcUser tailcfg.UserID
|
||||
AuthID string // from tailcfg.WebClientAuthResponse
|
||||
AuthURL string // from tailcfg.WebClientAuthResponse
|
||||
Created time.Time
|
||||
Authenticated bool
|
||||
}
|
||||
|
||||
// isAuthorized reports true if the given session is authorized
|
||||
// to be used by its associated user to access the full management
|
||||
// web client.
|
||||
//
|
||||
// isAuthorized is true only when s.Authenticated is true (i.e.
|
||||
// the user has authenticated the session) and the session is not
|
||||
// expired.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isAuthorized(now time.Time) bool {
|
||||
switch {
|
||||
case s == nil:
|
||||
return false
|
||||
case !s.Authenticated:
|
||||
return false // awaiting auth
|
||||
case s.isExpired(now):
|
||||
return false // expired
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isExpired reports true if s is expired.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isExpired(now time.Time) bool {
|
||||
return !s.Created.IsZero() && now.After(s.expires())
|
||||
}
|
||||
|
||||
// expires reports when the given session expires.
|
||||
func (s *browserSession) expires() time.Time {
|
||||
return s.Created.Add(sessionCookieExpiry)
|
||||
}
|
||||
|
||||
var (
|
||||
errNoSession = errors.New("no-browser-session")
|
||||
errNotUsingTailscale = errors.New("not-using-tailscale")
|
||||
errTaggedRemoteSource = errors.New("tagged-remote-source")
|
||||
errTaggedLocalSource = errors.New("tagged-local-source")
|
||||
errNotOwner = errors.New("not-owner")
|
||||
)
|
||||
|
||||
// getSession retrieves the browser session associated with the request,
|
||||
// if one exists.
|
||||
//
|
||||
// An error is returned in any of the following cases:
|
||||
//
|
||||
// - (errNotUsingTailscale) The request was not made over tailscale.
|
||||
//
|
||||
// - (errNoSession) The request does not have a session.
|
||||
//
|
||||
// - (errTaggedRemoteSource) The source is remote (another node) and tagged.
|
||||
// Users must use their own user-owned devices to manage other nodes'
|
||||
// web clients.
|
||||
//
|
||||
// - (errTaggedLocalSource) The source is local (the same node) and tagged.
|
||||
// Tagged nodes can only be remotely managed, allowing ACLs to dictate
|
||||
// access to web clients.
|
||||
//
|
||||
// - (errNotOwner) The source is not the owner of this client (if the
|
||||
// client is user-owned). Only the owner is allowed to manage the
|
||||
// node via the web client.
|
||||
//
|
||||
// If no error is returned, the browserSession is always non-nil.
|
||||
// getTailscaleBrowserSession does not check whether the session has been
|
||||
// authorized by the user. Callers can use browserSession.isAuthorized.
|
||||
//
|
||||
// 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) {
|
||||
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
|
||||
case statusErr != nil:
|
||||
return nil, whoIs, nil, statusErr
|
||||
case status.Self == nil:
|
||||
return nil, whoIs, status, errors.New("missing self node in tailscale status")
|
||||
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
|
||||
return nil, whoIs, status, errTaggedLocalSource
|
||||
case whoIs.Node.IsTagged():
|
||||
return nil, whoIs, status, errTaggedRemoteSource
|
||||
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
|
||||
return nil, whoIs, status, 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
|
||||
} else if err != nil {
|
||||
return nil, whoIs, status, err
|
||||
}
|
||||
v, ok := s.browserSessions.Load(cookie.Value)
|
||||
if !ok {
|
||||
return nil, whoIs, status, 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
|
||||
} 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 session, whoIs, status, nil
|
||||
}
|
||||
|
||||
// newSession creates a new session associated with the given source user/node,
|
||||
// and stores it back to the session cache. Creating of a new session includes
|
||||
// generating a new auth URL from the control server.
|
||||
func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*browserSession, error) {
|
||||
sid, err := s.newSessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session := &browserSession{
|
||||
ID: sid,
|
||||
SrcNode: src.Node.ID,
|
||||
SrcUser: src.UserProfile.ID,
|
||||
Created: s.timeNow(),
|
||||
}
|
||||
|
||||
if s.controlSupportsCheckMode(ctx) {
|
||||
// control supports check mode, so get a new auth URL and return.
|
||||
a, err := s.newAuthURL(ctx, src.Node.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session.AuthID = a.ID
|
||||
session.AuthURL = a.URL
|
||||
} else {
|
||||
// control does not support check mode, so there is no additional auth we can do.
|
||||
session.Authenticated = true
|
||||
}
|
||||
|
||||
s.browserSessions.Store(sid, session)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// controlSupportsCheckMode returns whether the current control server supports web client check mode, to verify a user's identity.
|
||||
// We assume that only "tailscale.com" control servers support check mode.
|
||||
// This allows the web client to be used with non-standard control servers.
|
||||
// If an error occurs getting the control URL, this method returns true to fail closed.
|
||||
//
|
||||
// TODO(juanfont/headscale#1623): adjust or remove this when headscale supports check mode.
|
||||
func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
|
||||
prefs, err := s.lc.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
controlURL, err := url.Parse(prefs.ControlURLOrDefault())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(controlURL.Host, ".tailscale.com")
|
||||
}
|
||||
|
||||
// awaitUserAuth blocks until the given session auth has been completed
|
||||
// by the user on the control server, then updates the session cache upon
|
||||
// completion. An error is returned if control auth failed for any reason.
|
||||
func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) error {
|
||||
if session.isAuthorized(s.timeNow()) {
|
||||
return nil // already authorized
|
||||
}
|
||||
a, err := s.waitAuthURL(ctx, session.AuthID, session.SrcNode)
|
||||
if err != nil {
|
||||
// Clean up the session. Doing this on any error from control
|
||||
// server to avoid the user getting stuck with a bad session
|
||||
// cookie.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return err
|
||||
}
|
||||
if a.Complete {
|
||||
session.Authenticated = a.Complete
|
||||
s.browserSessions.Store(session.ID, session)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) newSessionID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
for range 5 {
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
|
||||
if _, ok := s.browserSessions.Load(cookie); !ok {
|
||||
return cookie, nil
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -6,23 +6,21 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
|
||||
<link rel="stylesheet" type="text/css" href="/src/index.css" />
|
||||
<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>
|
||||
|
||||
@@ -6,76 +6,39 @@
|
||||
"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"
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.16.1",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@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",
|
||||
"postcss": "^8.4.27",
|
||||
"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",
|
||||
"start": "vite",
|
||||
"lint": "tsc --noEmit && eslint 'src/**/*.{ts,tsx,js,jsx}'",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx}'",
|
||||
"format-check": "prettier --check 'src/**/*.{ts,tsx}'"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"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"
|
||||
},
|
||||
"settings": {
|
||||
"projectRoot": "client/web/package.json"
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 80
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"tailwindcss": {},
|
||||
"autoprefixer": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
client/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -9,7 +9,6 @@ package web
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -19,17 +18,21 @@ import (
|
||||
|
||||
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
|
||||
// are authorized to use the web client.
|
||||
// If the user is not authorized to use the client, an error is returned.
|
||||
func authorizeQNAP(r *http.Request) (authorized bool, err error) {
|
||||
// It reports true if the request is authorized to continue, and false otherwise.
|
||||
// authorizeQNAP manages writing out any relevant authorization errors to the
|
||||
// ResponseWriter itself.
|
||||
func authorizeQNAP(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
_, resp, err := qnapAuthn(r)
|
||||
if err != nil {
|
||||
return false, err
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
if resp.IsAdmin == 0 {
|
||||
return false, errors.New("user is not an admin")
|
||||
http.Error(w, "user is not an admin", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
return true, nil
|
||||
return true
|
||||
}
|
||||
|
||||
type qnapAuthResponse struct {
|
||||
|
||||
@@ -1,280 +1,23 @@
|
||||
// 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> {
|
||||
method: "GET" | "POST",
|
||||
body?: any,
|
||||
params?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const nextParams = new URLSearchParams()
|
||||
if (synoToken) {
|
||||
nextParams.set("SynoToken", synoToken)
|
||||
} else {
|
||||
const token = urlParams.get("SynoToken")
|
||||
if (token) {
|
||||
nextParams.set("SynoToken", token)
|
||||
}
|
||||
const nextParams = new URLSearchParams(params)
|
||||
const token = urlParams.get("SynoToken")
|
||||
if (token) {
|
||||
nextParams.set("SynoToken", token)
|
||||
}
|
||||
const search = nextParams.toString()
|
||||
const url = `api${endpoint}${search ? `?${search}` : ""}`
|
||||
@@ -300,26 +43,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) {
|
||||
@@ -329,49 +62,6 @@ function updateCsrfToken(r: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12.5H19" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 5.5L19 12.5L12 19.5" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 324 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 12L12 8L8 12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 16V8" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 522 B |
@@ -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="M22 11.08V12C21.9988 14.1564 21.3005 16.2547 20.0093 17.9818C18.7182 19.709 16.9033 20.9725 14.8354 21.5839C12.7674 22.1953 10.5573 22.1219 8.53447 21.3746C6.51168 20.6273 4.78465 19.2461 3.61096 17.4371C2.43727 15.628 1.87979 13.4881 2.02168 11.3363C2.16356 9.18455 2.99721 7.13631 4.39828 5.49706C5.79935 3.85781 7.69279 2.71537 9.79619 2.24013C11.8996 1.7649 14.1003 1.98232 16.07 2.85999" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M22 4L12 14.01L9 11.01" stroke="#1EA672" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 704 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.6673 5L7.50065 14.1667L3.33398 10" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 236 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 203 B |
@@ -1,11 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_14876_118476)">
|
||||
<path d="M8.00065 14.6667C11.6825 14.6667 14.6673 11.6819 14.6673 8.00004C14.6673 4.31814 11.6825 1.33337 8.00065 1.33337C4.31875 1.33337 1.33398 4.31814 1.33398 8.00004C1.33398 11.6819 4.31875 14.6667 8.00065 14.6667Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 4V8L10.6667 9.33333" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_14876_118476">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 678 B |
@@ -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,11 +0,0 @@
|
||||
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_15367_14595)">
|
||||
<path d="M0.625 8C0.625 8 3.125 3 7.5 3C11.875 3 14.375 8 14.375 8C14.375 8 11.875 13 7.5 13C3.125 13 0.625 8 0.625 8Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 9.875C8.53553 9.875 9.375 9.03553 9.375 8C9.375 6.96447 8.53553 6.125 7.5 6.125C6.46447 6.125 5.625 6.96447 5.625 8C5.625 9.03553 6.46447 9.875 7.5 9.875Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_15367_14595">
|
||||
<rect width="15" height="15" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 738 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="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 4.16663V15.8333" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.16602 10H15.8327" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 329 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17.5 17.5L13.875 13.875" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 500 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 13.625V12.375C12.5 11.712 12.2366 11.0761 11.7678 10.6072C11.2989 10.1384 10.663 9.875 10 9.875H5C4.33696 9.875 3.70107 10.1384 3.23223 10.6072C2.76339 11.0761 2.5 11.712 2.5 12.375V13.625" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.5 7.375C8.88071 7.375 10 6.25571 10 4.875C10 3.49429 8.88071 2.375 7.5 2.375C6.11929 2.375 5 3.49429 5 4.875C5 6.25571 6.11929 7.375 7.5 7.375Z" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 635 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15 9L9 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 9L15 15" stroke="red" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 506 B |
@@ -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,28 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import Badge from "src/ui/badge"
|
||||
|
||||
/**
|
||||
* ACLTag handles the display of an ACL tag.
|
||||
*/
|
||||
export default function ACLTag({
|
||||
tag,
|
||||
className,
|
||||
}: {
|
||||
tag: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant="status"
|
||||
color="outline"
|
||||
className={cx("flex text-xs items-center", className)}
|
||||
>
|
||||
<span className="font-medium">tag:</span>
|
||||
<span className="text-gray-500">{tag.replace("tag:", "")}</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -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,172 +1,124 @@
|
||||
// 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 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 { Link, Route, Router, Switch, useLocation } from "wouter"
|
||||
import { Footer, Header, IP, State } from "src/components/legacy"
|
||||
import useNodeData, { NodeData } from "src/hooks/node-data"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
||||
import { ReactComponent as TailscaleLogo } from "src/icons/tailscale-logo.svg"
|
||||
|
||||
export default function App() {
|
||||
const { data: auth, loading: loadingAuth, newSession } = useAuth()
|
||||
// TODO(sonia): use isPosting value from useNodeData
|
||||
// to fill loading states.
|
||||
const { data, refreshData, updateNode } = useNodeData()
|
||||
|
||||
return (
|
||||
<main className="min-w-sm max-w-lg mx-auto py-4 sm:py-14 px-5">
|
||||
{loadingAuth || !auth ? (
|
||||
<LoadingView />
|
||||
) : (
|
||||
<WebClient auth={auth} newSession={newSession} />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function WebClient({
|
||||
auth,
|
||||
newSession,
|
||||
}: {
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const { data: node } = useSWR<NodeData>("/data")
|
||||
|
||||
return !node ? (
|
||||
<LoadingView />
|
||||
) : node.Status === "NeedsLogin" ||
|
||||
node.Status === "NoState" ||
|
||||
node.Status === "Stopped" ? (
|
||||
// Client not on a tailnet, render login.
|
||||
<LoginView data={node} />
|
||||
) : (
|
||||
// Otherwise render the new web client.
|
||||
<>
|
||||
<Router base={node.URLPrefix}>
|
||||
<Header node={node} auth={auth} newSession={newSession} />
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<HomeView node={node} auth={auth} />
|
||||
</Route>
|
||||
<Route path="/details">
|
||||
<DeviceDetailsView node={node} auth={auth} />
|
||||
</Route>
|
||||
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
|
||||
<SubnetRouterView
|
||||
readonly={!canEdit("subnets", auth)}
|
||||
node={node}
|
||||
/>
|
||||
</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}>
|
||||
<UpdatingView
|
||||
versionInfo={node.ClientVersion}
|
||||
currentVersion={node.IPNVersion}
|
||||
/>
|
||||
</FeatureRoute>
|
||||
<Route path="/disconnected">
|
||||
<DisconnectedView />
|
||||
</Route>
|
||||
<Route>
|
||||
<Card className="mt-8">
|
||||
<EmptyState description="Page not found" />
|
||||
</Card>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
newSession,
|
||||
}: {
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const [loc] = useLocation()
|
||||
|
||||
if (loc === "/disconnected") {
|
||||
// No header on view presented after logout.
|
||||
return null
|
||||
if (!data) {
|
||||
// TODO(sonia): add a loading view
|
||||
return <div className="text-center py-14">Loading...</div>
|
||||
}
|
||||
|
||||
const needsLogin = data?.Status === "NeedsLogin" || data?.Status === "NoState"
|
||||
|
||||
return !needsLogin &&
|
||||
(data.DebugMode === "login" || data.DebugMode === "full") ? (
|
||||
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-10">
|
||||
{data.DebugMode === "login" ? (
|
||||
<LoginView {...data} />
|
||||
) : (
|
||||
<ManageView {...data} />
|
||||
)}
|
||||
<Footer className="mt-20" licensesURL={data.LicensesURL} />
|
||||
</div>
|
||||
) : (
|
||||
// Legacy client UI
|
||||
<div className="py-14">
|
||||
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
<Header data={data} refreshData={refreshData} updateNode={updateNode} />
|
||||
<IP data={data} />
|
||||
<State data={data} updateNode={updateNode} />
|
||||
</main>
|
||||
<Footer licensesURL={data.LicensesURL} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginView(props: NodeData) {
|
||||
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">
|
||||
<TailscaleIcon />
|
||||
<div className="inline text-gray-800 text-lg font-medium leading-snug truncate">
|
||||
{node.DomainName}
|
||||
</div>
|
||||
</Link>
|
||||
<LoginToggle node={node} auth={auth} newSession={newSession} />
|
||||
<div className="pb-52 mx-auto">
|
||||
<TailscaleLogo />
|
||||
</div>
|
||||
<div className="w-full p-4 bg-stone-50 rounded-3xl border border-gray-200 flex flex-col gap-4">
|
||||
<div className="flex gap-2.5">
|
||||
<ProfilePic url={props.Profile.ProfilePicURL} />
|
||||
<div className="font-medium">
|
||||
<div className="text-neutral-500 text-xs uppercase tracking-wide">
|
||||
Owned by
|
||||
</div>
|
||||
<div className="text-neutral-800 text-sm leading-tight">
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
{props.Profile.LoginName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 bg-white rounded-lg border border-gray-200 justify-between items-center flex">
|
||||
<div className="flex gap-3">
|
||||
<ConnectedDeviceIcon />
|
||||
<div className="text-neutral-800">
|
||||
<div className="text-lg font-medium leading-[25.20px]">
|
||||
{props.DeviceName}
|
||||
</div>
|
||||
<div className="text-sm leading-tight">{props.IP}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="button button-blue ml-6">Access</button>
|
||||
</div>
|
||||
</div>
|
||||
{loc !== "/" && loc !== "/update" && (
|
||||
<Link to="/" className="link font-medium block mb-2">
|
||||
← Back to {node.DeviceName}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LoadingView fills its container with small animated loading dots
|
||||
* in the center.
|
||||
*/
|
||||
export function LoadingView() {
|
||||
function ManageView(props: NodeData) {
|
||||
return (
|
||||
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
<div className="px-5">
|
||||
<div className="flex justify-between mb-12">
|
||||
<TailscaleIcon />
|
||||
<div className="flex">
|
||||
<p className="mr-2">{props.Profile.LoginName}</p>
|
||||
{/* TODO(sonia): support tagged node profile view more eloquently */}
|
||||
<ProfilePic url={props.Profile.ProfilePicURL} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="tracking-wide uppercase text-gray-600 pb-3">This device</p>
|
||||
<div className="-mx-5 border rounded-md px-5 py-4 bg-white">
|
||||
<div className="flex justify-between items-center text-lg">
|
||||
<div className="flex items-center">
|
||||
<ConnectedDeviceIcon />
|
||||
<p className="font-medium ml-3">{props.DeviceName}</p>
|
||||
</div>
|
||||
<p className="tracking-widest">{props.IP}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-500 pt-2">
|
||||
Tailscale is up and running. You can connect to this device from devices
|
||||
in your tailnet by using its name or IP address.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfilePic({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
|
||||
{url ? (
|
||||
<div
|
||||
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
|
||||
style={{
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import { NodeData } from "src/types"
|
||||
|
||||
/**
|
||||
* AdminContainer renders its contents only if the node's control
|
||||
* server has an admin panel.
|
||||
*
|
||||
* TODO(sonia,will): Similarly, this could also hide the contents
|
||||
* if the viewing user is a non-admin.
|
||||
*/
|
||||
export function AdminContainer({
|
||||
node,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
node: NodeData
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
if (!node.ControlAdminURL.includes("tailscale.com")) {
|
||||
// Admin panel only exists on Tailscale control servers.
|
||||
return null
|
||||
}
|
||||
return <div className={className}>{children}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminLink renders its contents wrapped in a link to the node's control
|
||||
* server admin panel.
|
||||
*
|
||||
* AdminLink is meant for use only inside of a AdminContainer component,
|
||||
* to avoid rendering a link when the node's control server does not have
|
||||
* an admin panel.
|
||||
*/
|
||||
export function AdminLink({
|
||||
node,
|
||||
children,
|
||||
path,
|
||||
}: {
|
||||
node: NodeData
|
||||
children: React.ReactNode
|
||||
path: string // admin path, e.g. "/settings/webhooks"
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={`${node.ControlAdminURL}${path}`}
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,584 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// 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 useExitNodes, {
|
||||
noExitNode,
|
||||
runAsExitNode,
|
||||
trimDNSSuffix,
|
||||
} from "src/hooks/exit-nodes"
|
||||
import { ExitNode, NodeData } from "src/types"
|
||||
import Popover from "src/ui/popover"
|
||||
import SearchInput from "src/ui/search-input"
|
||||
import { useSWRConfig } from "swr"
|
||||
|
||||
export default function ExitNodeSelector({
|
||||
className,
|
||||
node,
|
||||
disabled,
|
||||
}: {
|
||||
className?: string
|
||||
node: NodeData
|
||||
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) => {
|
||||
setOpen(false)
|
||||
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)
|
||||
},
|
||||
[api, mutate, selected.ID]
|
||||
)
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"rounded-md",
|
||||
{
|
||||
"bg-red-600": offline,
|
||||
"bg-yellow-400": pending,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<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,
|
||||
})}
|
||||
>
|
||||
<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": 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-gray-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
|
||||
{ "opacity-70 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,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Popover>
|
||||
{!disabled && (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,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleSelect(noExitNode)
|
||||
}}
|
||||
>
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function toSelectedExitNode(data: NodeData): ExitNode {
|
||||
if (data.AdvertisingExitNode) {
|
||||
return runAsExitNode
|
||||
}
|
||||
if (data.UsingExitNode) {
|
||||
// TODO(sonia): also use online status
|
||||
const node = { ...data.UsingExitNode }
|
||||
if (node.Location) {
|
||||
// For mullvad nodes, use location as name.
|
||||
node.Name = `${node.Location.Country}: ${node.Location.City}`
|
||||
} else {
|
||||
// Otherwise use node name w/o DNS suffix.
|
||||
node.Name = trimDNSSuffix(node.Name, data.TailnetName)
|
||||
}
|
||||
return node
|
||||
}
|
||||
return noExitNode
|
||||
}
|
||||
|
||||
function ExitNodeSelectorInner({
|
||||
node,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
node: NodeData
|
||||
selected: ExitNode
|
||||
onSelect: (node: ExitNode) => void
|
||||
}) {
|
||||
const [filter, setFilter] = useState<string>("")
|
||||
const { data: exitNodes } = useExitNodes(node, filter)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const hasNodes = useMemo(
|
||||
() => exitNodes.find((n) => n.nodes.length > 0),
|
||||
[exitNodes]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-[var(--radix-popover-trigger-width)]">
|
||||
<SearchInput
|
||||
name="exit-node-search"
|
||||
className="px-2"
|
||||
inputClassName="w-full py-3 !h-auto border-none rounded-b-none !ring-0"
|
||||
autoFocus
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
placeholder="Search exit nodes…"
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
// Jump list to top when search value changes.
|
||||
listRef.current?.scrollTo(0, 0)
|
||||
setFilter(e.target.value)
|
||||
}}
|
||||
/>
|
||||
{/* 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"
|
||||
>
|
||||
{hasNodes ? (
|
||||
exitNodes.map(
|
||||
(group) =>
|
||||
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"
|
||||
>
|
||||
{group.name && (
|
||||
<div className="px-4 py-2 text-gray-500 text-xs font-medium uppercase tracking-wide">
|
||||
{group.name}
|
||||
</div>
|
||||
)}
|
||||
{group.nodes.map((n) => (
|
||||
<ExitNodeSelectorItem
|
||||
key={`${n.ID}-${n.Name}`}
|
||||
node={n}
|
||||
onSelect={() => onSelect(n)}
|
||||
isSelected={selected.ID === n.ID}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<div className="text-center truncate text-gray-500 p-5">
|
||||
{filter
|
||||
? `No exit nodes matching “${filter}”`
|
||||
: "No exit nodes available"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExitNodeSelectorItem({
|
||||
node,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
node: ExitNode
|
||||
isSelected: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
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,
|
||||
}
|
||||
)}
|
||||
onClick={onSelect}
|
||||
disabled={!node.Online}
|
||||
>
|
||||
<div className="w-full">
|
||||
{node.Location && (
|
||||
<>
|
||||
<CountryFlag code={node.Location.CountryCode} />{" "}
|
||||
</>
|
||||
)}
|
||||
<span className="leading-snug">{node.Name}</span>
|
||||
</div>
|
||||
{node.Online || <span className="leading-snug">Offline</span>}
|
||||
{isSelected && <Check className="ml-1" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CountryFlag({ code }: { code: string }) {
|
||||
return (
|
||||
<>{countryFlags[code.toLowerCase()]}</> || (
|
||||
<span className="font-medium text-gray-500 text-xs">
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const countryFlags: { [countryCode: string]: string } = {
|
||||
ad: "🇦🇩",
|
||||
ae: "🇦🇪",
|
||||
af: "🇦🇫",
|
||||
ag: "🇦🇬",
|
||||
ai: "🇦🇮",
|
||||
al: "🇦🇱",
|
||||
am: "🇦🇲",
|
||||
ao: "🇦🇴",
|
||||
aq: "🇦🇶",
|
||||
ar: "🇦🇷",
|
||||
as: "🇦🇸",
|
||||
at: "🇦🇹",
|
||||
au: "🇦🇺",
|
||||
aw: "🇦🇼",
|
||||
ax: "🇦🇽",
|
||||
az: "🇦🇿",
|
||||
ba: "🇧🇦",
|
||||
bb: "🇧🇧",
|
||||
bd: "🇧🇩",
|
||||
be: "🇧🇪",
|
||||
bf: "🇧🇫",
|
||||
bg: "🇧🇬",
|
||||
bh: "🇧🇭",
|
||||
bi: "🇧🇮",
|
||||
bj: "🇧🇯",
|
||||
bl: "🇧🇱",
|
||||
bm: "🇧🇲",
|
||||
bn: "🇧🇳",
|
||||
bo: "🇧🇴",
|
||||
bq: "🇧🇶",
|
||||
br: "🇧🇷",
|
||||
bs: "🇧🇸",
|
||||
bt: "🇧🇹",
|
||||
bv: "🇧🇻",
|
||||
bw: "🇧🇼",
|
||||
by: "🇧🇾",
|
||||
bz: "🇧🇿",
|
||||
ca: "🇨🇦",
|
||||
cc: "🇨🇨",
|
||||
cd: "🇨🇩",
|
||||
cf: "🇨🇫",
|
||||
cg: "🇨🇬",
|
||||
ch: "🇨🇭",
|
||||
ci: "🇨🇮",
|
||||
ck: "🇨🇰",
|
||||
cl: "🇨🇱",
|
||||
cm: "🇨🇲",
|
||||
cn: "🇨🇳",
|
||||
co: "🇨🇴",
|
||||
cr: "🇨🇷",
|
||||
cu: "🇨🇺",
|
||||
cv: "🇨🇻",
|
||||
cw: "🇨🇼",
|
||||
cx: "🇨🇽",
|
||||
cy: "🇨🇾",
|
||||
cz: "🇨🇿",
|
||||
de: "🇩🇪",
|
||||
dj: "🇩🇯",
|
||||
dk: "🇩🇰",
|
||||
dm: "🇩🇲",
|
||||
do: "🇩🇴",
|
||||
dz: "🇩🇿",
|
||||
ec: "🇪🇨",
|
||||
ee: "🇪🇪",
|
||||
eg: "🇪🇬",
|
||||
eh: "🇪🇭",
|
||||
er: "🇪🇷",
|
||||
es: "🇪🇸",
|
||||
et: "🇪🇹",
|
||||
eu: "🇪🇺",
|
||||
fi: "🇫🇮",
|
||||
fj: "🇫🇯",
|
||||
fk: "🇫🇰",
|
||||
fm: "🇫🇲",
|
||||
fo: "🇫🇴",
|
||||
fr: "🇫🇷",
|
||||
ga: "🇬🇦",
|
||||
gb: "🇬🇧",
|
||||
gd: "🇬🇩",
|
||||
ge: "🇬🇪",
|
||||
gf: "🇬🇫",
|
||||
gg: "🇬🇬",
|
||||
gh: "🇬🇭",
|
||||
gi: "🇬🇮",
|
||||
gl: "🇬🇱",
|
||||
gm: "🇬🇲",
|
||||
gn: "🇬🇳",
|
||||
gp: "🇬🇵",
|
||||
gq: "🇬🇶",
|
||||
gr: "🇬🇷",
|
||||
gs: "🇬🇸",
|
||||
gt: "🇬🇹",
|
||||
gu: "🇬🇺",
|
||||
gw: "🇬🇼",
|
||||
gy: "🇬🇾",
|
||||
hk: "🇭🇰",
|
||||
hm: "🇭🇲",
|
||||
hn: "🇭🇳",
|
||||
hr: "🇭🇷",
|
||||
ht: "🇭🇹",
|
||||
hu: "🇭🇺",
|
||||
id: "🇮🇩",
|
||||
ie: "🇮🇪",
|
||||
il: "🇮🇱",
|
||||
im: "🇮🇲",
|
||||
in: "🇮🇳",
|
||||
io: "🇮🇴",
|
||||
iq: "🇮🇶",
|
||||
ir: "🇮🇷",
|
||||
is: "🇮🇸",
|
||||
it: "🇮🇹",
|
||||
je: "🇯🇪",
|
||||
jm: "🇯🇲",
|
||||
jo: "🇯🇴",
|
||||
jp: "🇯🇵",
|
||||
ke: "🇰🇪",
|
||||
kg: "🇰🇬",
|
||||
kh: "🇰🇭",
|
||||
ki: "🇰🇮",
|
||||
km: "🇰🇲",
|
||||
kn: "🇰🇳",
|
||||
kp: "🇰🇵",
|
||||
kr: "🇰🇷",
|
||||
kw: "🇰🇼",
|
||||
ky: "🇰🇾",
|
||||
kz: "🇰🇿",
|
||||
la: "🇱🇦",
|
||||
lb: "🇱🇧",
|
||||
lc: "🇱🇨",
|
||||
li: "🇱🇮",
|
||||
lk: "🇱🇰",
|
||||
lr: "🇱🇷",
|
||||
ls: "🇱🇸",
|
||||
lt: "🇱🇹",
|
||||
lu: "🇱🇺",
|
||||
lv: "🇱🇻",
|
||||
ly: "🇱🇾",
|
||||
ma: "🇲🇦",
|
||||
mc: "🇲🇨",
|
||||
md: "🇲🇩",
|
||||
me: "🇲🇪",
|
||||
mf: "🇲🇫",
|
||||
mg: "🇲🇬",
|
||||
mh: "🇲🇭",
|
||||
mk: "🇲🇰",
|
||||
ml: "🇲🇱",
|
||||
mm: "🇲🇲",
|
||||
mn: "🇲🇳",
|
||||
mo: "🇲🇴",
|
||||
mp: "🇲🇵",
|
||||
mq: "🇲🇶",
|
||||
mr: "🇲🇷",
|
||||
ms: "🇲🇸",
|
||||
mt: "🇲🇹",
|
||||
mu: "🇲🇺",
|
||||
mv: "🇲🇻",
|
||||
mw: "🇲🇼",
|
||||
mx: "🇲🇽",
|
||||
my: "🇲🇾",
|
||||
mz: "🇲🇿",
|
||||
na: "🇳🇦",
|
||||
nc: "🇳🇨",
|
||||
ne: "🇳🇪",
|
||||
nf: "🇳🇫",
|
||||
ng: "🇳🇬",
|
||||
ni: "🇳🇮",
|
||||
nl: "🇳🇱",
|
||||
no: "🇳🇴",
|
||||
np: "🇳🇵",
|
||||
nr: "🇳🇷",
|
||||
nu: "🇳🇺",
|
||||
nz: "🇳🇿",
|
||||
om: "🇴🇲",
|
||||
pa: "🇵🇦",
|
||||
pe: "🇵🇪",
|
||||
pf: "🇵🇫",
|
||||
pg: "🇵🇬",
|
||||
ph: "🇵🇭",
|
||||
pk: "🇵🇰",
|
||||
pl: "🇵🇱",
|
||||
pm: "🇵🇲",
|
||||
pn: "🇵🇳",
|
||||
pr: "🇵🇷",
|
||||
ps: "🇵🇸",
|
||||
pt: "🇵🇹",
|
||||
pw: "🇵🇼",
|
||||
py: "🇵🇾",
|
||||
qa: "🇶🇦",
|
||||
re: "🇷🇪",
|
||||
ro: "🇷🇴",
|
||||
rs: "🇷🇸",
|
||||
ru: "🇷🇺",
|
||||
rw: "🇷🇼",
|
||||
sa: "🇸🇦",
|
||||
sb: "🇸🇧",
|
||||
sc: "🇸🇨",
|
||||
sd: "🇸🇩",
|
||||
se: "🇸🇪",
|
||||
sg: "🇸🇬",
|
||||
sh: "🇸🇭",
|
||||
si: "🇸🇮",
|
||||
sj: "🇸🇯",
|
||||
sk: "🇸🇰",
|
||||
sl: "🇸🇱",
|
||||
sm: "🇸🇲",
|
||||
sn: "🇸🇳",
|
||||
so: "🇸🇴",
|
||||
sr: "🇸🇷",
|
||||
ss: "🇸🇸",
|
||||
st: "🇸🇹",
|
||||
sv: "🇸🇻",
|
||||
sx: "🇸🇽",
|
||||
sy: "🇸🇾",
|
||||
sz: "🇸🇿",
|
||||
tc: "🇹🇨",
|
||||
td: "🇹🇩",
|
||||
tf: "🇹🇫",
|
||||
tg: "🇹🇬",
|
||||
th: "🇹🇭",
|
||||
tj: "🇹🇯",
|
||||
tk: "🇹🇰",
|
||||
tl: "🇹🇱",
|
||||
tm: "🇹🇲",
|
||||
tn: "🇹🇳",
|
||||
to: "🇹🇴",
|
||||
tr: "🇹🇷",
|
||||
tt: "🇹🇹",
|
||||
tv: "🇹🇻",
|
||||
tw: "🇹🇼",
|
||||
tz: "🇹🇿",
|
||||
ua: "🇺🇦",
|
||||
ug: "🇺🇬",
|
||||
um: "🇺🇲",
|
||||
us: "🇺🇸",
|
||||
uy: "🇺🇾",
|
||||
uz: "🇺🇿",
|
||||
va: "🇻🇦",
|
||||
vc: "🇻🇨",
|
||||
ve: "🇻🇪",
|
||||
vg: "🇻🇬",
|
||||
vi: "🇻🇮",
|
||||
vn: "🇻🇳",
|
||||
vu: "🇻🇺",
|
||||
wf: "🇼🇫",
|
||||
ws: "🇼🇸",
|
||||
xk: "🇽🇰",
|
||||
ye: "🇾🇪",
|
||||
yt: "🇾🇹",
|
||||
za: "🇿🇦",
|
||||
zm: "🇿🇲",
|
||||
zw: "🇿🇼",
|
||||
}
|
||||
298
client/web/src/components/legacy.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { NodeData, NodeUpdate } from "src/hooks/node-data"
|
||||
|
||||
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
|
||||
// that (crudely) implement the pre-2023 web client. These are implemented
|
||||
// purely to ease migration to the new React-based web client, and will
|
||||
// eventually be completely removed.
|
||||
|
||||
export function Header({
|
||||
data,
|
||||
refreshData,
|
||||
updateNode,
|
||||
}: {
|
||||
data: NodeData
|
||||
refreshData: () => void
|
||||
updateNode: (update: NodeUpdate) => void
|
||||
}) {
|
||||
return (
|
||||
<header className="flex justify-between items-center min-width-0 py-2 mb-8">
|
||||
<svg
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 23 23"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="flex-shrink-0 mr-4"
|
||||
>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="3.4"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="3.4"
|
||||
cy="19.5"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="11.5"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="19.5"
|
||||
cy="3.25"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
|
||||
<circle
|
||||
opacity="0.2"
|
||||
cx="19.5"
|
||||
cy="19.5"
|
||||
r="2.7"
|
||||
fill="currentColor"
|
||||
></circle>
|
||||
</svg>
|
||||
<div className="flex items-center justify-end space-x-2 w-2/3">
|
||||
{data.Profile &&
|
||||
data.Status !== "NoState" &&
|
||||
data.Status !== "NeedsLogin" && (
|
||||
<>
|
||||
<div className="text-right w-full leading-4">
|
||||
<h4 className="truncate leading-normal">
|
||||
{data.Profile.LoginName}
|
||||
</h4>
|
||||
<div className="text-xs text-gray-500 text-right">
|
||||
<button
|
||||
onClick={() => updateNode({ Reauthenticate: true })}
|
||||
className="hover:text-gray-700"
|
||||
>
|
||||
Switch account
|
||||
</button>{" "}
|
||||
|{" "}
|
||||
<button
|
||||
onClick={() => updateNode({ Reauthenticate: true })}
|
||||
className="hover:text-gray-700"
|
||||
>
|
||||
Reauthenticate
|
||||
</button>{" "}
|
||||
|{" "}
|
||||
<button
|
||||
onClick={() =>
|
||||
apiFetch("/local/v0/logout", "POST")
|
||||
.then(refreshData)
|
||||
.catch((err) => alert("Logout failed: " + err.message))
|
||||
}
|
||||
className="hover:text-gray-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
|
||||
{data.Profile.ProfilePicURL ? (
|
||||
<div
|
||||
className="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
|
||||
style={{
|
||||
backgroundImage: `url(${data.Profile.ProfilePicURL})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function IP(props: { data: NodeData }) {
|
||||
const { data } = props
|
||||
|
||||
if (!data.IP) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border border-gray-200 bg-gray-50 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between">
|
||||
<div className="flex items-center min-width-0">
|
||||
<svg
|
||||
className="flex-shrink-0 text-gray-600 mr-3 ml-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
</svg>
|
||||
<h4 className="font-semibold truncate mr-2">
|
||||
{data.DeviceName || "Your device"}
|
||||
</h4>
|
||||
</div>
|
||||
<h5>{data.IP}</h5>
|
||||
</div>
|
||||
<p className="mt-1 ml-1 mb-6 text-xs text-gray-600">
|
||||
Debug info: Tailscale {data.IPNVersion}, tun={data.TUNMode.toString()}
|
||||
{data.IsSynology && (
|
||||
<>
|
||||
, DSM{data.DSMVersion}
|
||||
{data.TUNMode || (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<a
|
||||
href="https://tailscale.com/kb/1152/synology-outbound/"
|
||||
className="link-underline text-gray-600"
|
||||
target="_blank"
|
||||
aria-label="Configure outbound synology traffic"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
outgoing access not configured
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function State({
|
||||
data,
|
||||
updateNode,
|
||||
}: {
|
||||
data: NodeData
|
||||
updateNode: (update: NodeUpdate) => void
|
||||
}) {
|
||||
switch (data.Status) {
|
||||
case "NeedsLogin":
|
||||
case "NoState":
|
||||
if (data.IP) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700">
|
||||
Your device's key has expired. Reauthenticate this device by
|
||||
logging in again, or{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1028/key-expiry"
|
||||
className="link"
|
||||
target="_blank"
|
||||
>
|
||||
learn more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateNode({ Reauthenticate: true })}
|
||||
className="button button-blue w-full mb-4"
|
||||
>
|
||||
Reauthenticate
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
|
||||
<p className="text-gray-700">
|
||||
Get started by logging in to your Tailscale network.
|
||||
Or, learn more at{" "}
|
||||
<a
|
||||
href="https://tailscale.com/"
|
||||
className="link"
|
||||
target="_blank"
|
||||
>
|
||||
tailscale.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateNode({ Reauthenticate: true })}
|
||||
className="button button-blue w-full mb-4"
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
case "NeedsMachineAuth":
|
||||
return (
|
||||
<div className="mb-4">
|
||||
This device is authorized, but needs approval from a network admin
|
||||
before it can connect to the network.
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p>
|
||||
You are connected! Access this device over Tailscale using the
|
||||
device name or IP address above.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className={cx("button button-medium mb-4", {
|
||||
"button-red": data.AdvertiseExitNode,
|
||||
"button-blue": !data.AdvertiseExitNode,
|
||||
})}
|
||||
id="enabled"
|
||||
onClick={() =>
|
||||
updateNode({ AdvertiseExitNode: !data.AdvertiseExitNode })
|
||||
}
|
||||
>
|
||||
{data.AdvertiseExitNode
|
||||
? "Stop advertising Exit Node"
|
||||
: "Advertise as Exit Node"}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function Footer(props: { licensesURL: string; className?: string }) {
|
||||
return (
|
||||
<footer
|
||||
className={cx("container max-w-lg mx-auto text-center", props.className)}
|
||||
>
|
||||
<a
|
||||
className="text-xs text-gray-500 hover:text-gray-600"
|
||||
href={props.licensesURL}
|
||||
>
|
||||
Open Source Licenses
|
||||
</a>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// 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 Popover from "src/ui/popover"
|
||||
import ProfilePic from "src/ui/profile-pic"
|
||||
import { assertNever, isHTTPS } from "src/utils/util"
|
||||
|
||||
export default function LoginToggle({
|
||||
node,
|
||||
auth,
|
||||
newSession,
|
||||
}: {
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
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]"
|
||||
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)
|
||||
)
|
||||
}
|
||||
side="bottom"
|
||||
align="end"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
asChild
|
||||
>
|
||||
<div>
|
||||
{auth.authorized ? (
|
||||
<TriggerWhenManaging auth={auth} open={open} setOpen={setOpen} />
|
||||
) : (
|
||||
<TriggerWhenReading auth={auth} open={open} setOpen={setOpen} />
|
||||
)}
|
||||
</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({
|
||||
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
|
||||
}) {
|
||||
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])
|
||||
|
||||
const hasAnyPermissions = useMemo(() => hasAnyEditCapabilities(auth), [auth])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoverContentHeader auth={auth} />
|
||||
{!auth.authorized &&
|
||||
(hasAnyPermissions ? (
|
||||
// User is connected over Tailscale, but needs to complete check mode.
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
To make changes, sign in to confirm your identity. This extra step
|
||||
helps us keep your device secure.
|
||||
</p>
|
||||
<SignInButton auth={auth} onClick={handleLogin} />
|
||||
</>
|
||||
) : (
|
||||
// User is connected over tailscale, but doesn't have permission to manage.
|
||||
<p className="text-gray-500 text-xs">
|
||||
You don’t have permission to make changes to this device, but you
|
||||
can view most of its details.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/s/web-client-access"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
))}
|
||||
<PopoverContentFooter auth={auth} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// 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"
|
||||
|
||||
export function UpdateAvailableNotification({
|
||||
details,
|
||||
}: {
|
||||
details: VersionInfo
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-2">
|
||||
Update available{" "}
|
||||
{details.LatestVersion && `(v${details.LatestVersion})`}
|
||||
</h2>
|
||||
<p className="text-sm mb-1 mt-1">
|
||||
{details.LatestVersion
|
||||
? `Version ${details.LatestVersion}`
|
||||
: "A new update"}{" "}
|
||||
is now available. <ChangelogText version={details.LatestVersion} />
|
||||
</p>
|
||||
<Button
|
||||
className="mt-3 inline-block"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/update")}
|
||||
>
|
||||
Update now
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// isStableTrack takes a Tailscale version string
|
||||
// of form X.Y.Z (or vX.Y.Z) and returns whether
|
||||
// it is a stable release (even value of Y)
|
||||
// or unstable (odd value of Y).
|
||||
// eg. isStableTrack("1.48.0") === true
|
||||
// eg. isStableTrack("1.49.112") === false
|
||||
function isStableTrack(ver: string): boolean {
|
||||
const middle = ver.split(".")[1]
|
||||
if (middle && Number(middle) % 2 === 0) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function ChangelogText({ version }: { version?: string }) {
|
||||
if (!version || !isStableTrack(version)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
Check out the{" "}
|
||||
<a href="https://tailscale.com/changelog/" className="link">
|
||||
release notes
|
||||
</a>{" "}
|
||||
to find out what’s new!
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
// 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 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 { useLocation } from "wouter"
|
||||
|
||||
export default function DeviceDetailsView({
|
||||
node,
|
||||
auth,
|
||||
}: {
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
}) {
|
||||
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="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1>{node.DeviceName}</h1>
|
||||
<div
|
||||
className={cx("w-2.5 h-2.5 rounded-full", {
|
||||
"bg-emerald-500": node.Status === "Running",
|
||||
"bg-gray-300": node.Status !== "Running",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{canEdit("account", auth) && <DisconnectDialog />}
|
||||
</div>
|
||||
</Card>
|
||||
{node.Features["auto-update"] &&
|
||||
canEdit("account", auth) &&
|
||||
node.ClientVersion &&
|
||||
!node.ClientVersion.RunningLatest && (
|
||||
<UpdateAvailableNotification details={node.ClientVersion} />
|
||||
)}
|
||||
<Card noPadding className="-mx-5 p-5 details-card">
|
||||
<h2 className="mb-2">General</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr className="flex">
|
||||
<td>Managed by</td>
|
||||
<td className="flex gap-1 flex-wrap">
|
||||
{node.IsTagged
|
||||
? node.Tags.map((t) => <ACLTag key={t} tag={t} />)
|
||||
: node.Profile?.DisplayName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Machine name</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.DeviceName}
|
||||
primaryActionSubject="machine name"
|
||||
>
|
||||
{node.DeviceName}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OS</td>
|
||||
<td>{node.OS}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.ID}
|
||||
primaryActionSubject="ID"
|
||||
>
|
||||
{node.ID}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tailscale version</td>
|
||||
<td>{node.IPNVersion}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Key expiry</td>
|
||||
<td>
|
||||
{node.KeyExpired
|
||||
? "Expired"
|
||||
: // TODO: present as relative expiry (e.g. "5 months from now")
|
||||
node.KeyExpiry
|
||||
? new Date(node.KeyExpiry).toLocaleString()
|
||||
: "No expiry"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
<Card noPadding className="-mx-5 p-5 details-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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tailscale IPv6</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.IPv6}
|
||||
primaryActionSubject="IPv6 address"
|
||||
>
|
||||
<NiceIP ip={node.IPv6} />
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Short domain</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.DeviceName}
|
||||
primaryActionSubject="short domain"
|
||||
>
|
||||
{node.DeviceName}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Full domain</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={`${node.DeviceName}.${node.TailnetName}`}
|
||||
primaryActionSubject="full domain"
|
||||
>
|
||||
{node.DeviceName}.{node.TailnetName}
|
||||
</QuickCopy>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// 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 ExitNodeSelector from "src/components/exit-node-selector"
|
||||
import { AuthResponse, canEdit } from "src/hooks/auth"
|
||||
import { NodeData } from "src/types"
|
||||
import Card from "src/ui/card"
|
||||
import { pluralize } from "src/utils/util"
|
||||
import { Link, useLocation } from "wouter"
|
||||
|
||||
export default function HomeView({
|
||||
node,
|
||||
auth,
|
||||
}: {
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
}) {
|
||||
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="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="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>
|
||||
</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>
|
||||
{(node.Features["advertise-exit-node"] ||
|
||||
node.Features["use-exit-node"]) && (
|
||||
<ExitNodeSelector
|
||||
className="mb-5"
|
||||
node={node}
|
||||
disabled={!canEdit("exitnodes", auth)}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
className="link font-medium"
|
||||
to="/details"
|
||||
onClick={() => apiFetch("/device-details-click", "POST")}
|
||||
>
|
||||
View device details →
|
||||
</Link>
|
||||
</Card>
|
||||
<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
|
||||
link="/serve"
|
||||
title="Share local content"
|
||||
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsCard({
|
||||
title,
|
||||
link,
|
||||
body,
|
||||
badge,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
link: string
|
||||
body: string
|
||||
badge?: {
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
// 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 Collapsible from "src/ui/collapsible"
|
||||
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()
|
||||
const [controlURL, setControlURL] = useState<string>("")
|
||||
const [authKey, setAuthKey] = useState<string>("")
|
||||
|
||||
return (
|
||||
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
<TailscaleIcon className="my-2 mb-8" />
|
||||
{data.Status === "Stopped" ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-3xl font-semibold mb-3">Connect</h3>
|
||||
<p className="text-gray-700">
|
||||
Your device is disconnected from Tailscale.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => api({ action: "up", data: {} })}
|
||||
className="w-full mb-4"
|
||||
intent="primary"
|
||||
>
|
||||
Connect to Tailscale
|
||||
</Button>
|
||||
</>
|
||||
) : data.IPv4 ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700">
|
||||
Your device’s key has expired. Reauthenticate this device by
|
||||
logging in again, or{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1028/key-expiry"
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
learn more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
api({ action: "up", data: { Reauthenticate: true } })
|
||||
}
|
||||
className="w-full mb-4"
|
||||
intent="primary"
|
||||
>
|
||||
Reauthenticate
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-3xl font-semibold mb-3">Log in</h3>
|
||||
<p className="text-gray-700">
|
||||
Get started by logging in to your Tailscale network.
|
||||
Or, learn more at{" "}
|
||||
<a
|
||||
href="https://tailscale.com/"
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
tailscale.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
api({
|
||||
action: "up",
|
||||
data: {
|
||||
Reauthenticate: true,
|
||||
ControlURL: controlURL,
|
||||
AuthKey: authKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-full mb-4"
|
||||
intent="primary"
|
||||
>
|
||||
Log In
|
||||
</Button>
|
||||
<Collapsible trigger="Advanced options">
|
||||
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
Connect with a pre-authenticated key.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1085/auth-keys/"
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
<Input
|
||||
className="mt-2"
|
||||
value={authKey}
|
||||
onChange={(e) => setAuthKey(e.target.value)}
|
||||
placeholder="tskey-auth-XXX"
|
||||
/>
|
||||
<h4 className="font-medium mt-3 mb-1">Server URL</h4>
|
||||
<p className="text-sm text-gray-500">Base URL of control server.</p>
|
||||
<Input
|
||||
className="mt-2"
|
||||
value={controlURL}
|
||||
onChange={(e) => setControlURL(e.target.value)}
|
||||
placeholder="https://login.tailscale.com/"
|
||||
/>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
// 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 Toggle from "src/ui/toggle"
|
||||
|
||||
export default function SSHView({
|
||||
readonly,
|
||||
node,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
}) {
|
||||
const api = useAPI()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-1">Tailscale SSH server</h1>
|
||||
<p className="description mb-10">
|
||||
Run a Tailscale SSH server on this device and allow other devices in
|
||||
your tailnet to SSH into it.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1193/tailscale-ssh/"
|
||||
className="text-blue-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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
// 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 * as Control from "src/components/control-components"
|
||||
import { NodeData } from "src/types"
|
||||
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,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
}) {
|
||||
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 [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 (
|
||||
<>
|
||||
<h1 className="mb-1">Subnet router</h1>
|
||||
<p className="description mb-5">
|
||||
Add devices to your tailnet without installing Tailscale.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1019/subnets/"
|
||||
className="text-blue-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>
|
||||
) : (
|
||||
<Button
|
||||
intent="primary"
|
||||
prefixIcon={<Plus />}
|
||||
onClick={() => setInputOpen(true)}
|
||||
>
|
||||
Advertise new routes
|
||||
</Button>
|
||||
))}
|
||||
<div className="-mx-5 mt-10">
|
||||
{hasRoutes ? (
|
||||
<>
|
||||
<Card noPadding className="px-5 py-3">
|
||||
{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="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{r.Approved ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
<Clock className="w-4 h-4" />
|
||||
)}
|
||||
{r.Approved ? (
|
||||
<div className="text-green-500 text-sm leading-tight">
|
||||
Approved
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm leading-tight">
|
||||
Pending approval
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<StopAdvertisingDialog
|
||||
onSubmit={() =>
|
||||
api({
|
||||
action: "update-routes",
|
||||
data: advertisedRoutes.filter(
|
||||
(it) => it.Route !== r.Route
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card empty>
|
||||
<EmptyState description="Not advertising any routes" />
|
||||
</Card>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// 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 { 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 Spinner from "src/ui/spinner"
|
||||
import { useLocation } from "wouter"
|
||||
|
||||
/**
|
||||
* UpdatingView is rendered when the user initiates a Tailscale update, and
|
||||
* the update is in-progress, failed, or completed.
|
||||
*/
|
||||
export function UpdatingView({
|
||||
versionInfo,
|
||||
currentVersion,
|
||||
}: {
|
||||
versionInfo?: VersionInfo
|
||||
currentVersion: string
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
const { updateState, updateLog } = useInstallUpdate(
|
||||
currentVersion,
|
||||
versionInfo
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col justify-center items-center text-center mt-56">
|
||||
{updateState === UpdateState.InProgress ? (
|
||||
<>
|
||||
<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
|
||||
completed, you will be asked to log in again.
|
||||
</p>
|
||||
</>
|
||||
) : updateState === UpdateState.Complete ? (
|
||||
<>
|
||||
<CheckCircleIcon />
|
||||
<h1 className="text-2xl m-3">Update complete!</h1>
|
||||
<p className="text-gray-400">
|
||||
You updated Tailscale
|
||||
{versionInfo && versionInfo.LatestVersion
|
||||
? ` to ${versionInfo.LatestVersion}`
|
||||
: null}
|
||||
. <ChangelogText version={versionInfo?.LatestVersion} />
|
||||
</p>
|
||||
<Button
|
||||
className="m-3"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/")}
|
||||
>
|
||||
Log in to access
|
||||
</Button>
|
||||
</>
|
||||
) : updateState === UpdateState.UpToDate ? (
|
||||
<>
|
||||
<CheckCircleIcon />
|
||||
<h1 className="text-2xl m-3">Up to date!</h1>
|
||||
<p className="text-gray-400">
|
||||
You are already running Tailscale {currentVersion}, which is the
|
||||
newest version available.
|
||||
</p>
|
||||
<Button
|
||||
className="m-3"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/")}
|
||||
>
|
||||
Return
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
/* TODO(naman,sonia): Figure out the body copy and design for this view. */
|
||||
<>
|
||||
<XCircleIcon />
|
||||
<h1 className="text-2xl m-3">Update failed</h1>
|
||||
<p className="text-gray-400">
|
||||
Update
|
||||
{versionInfo && versionInfo.LatestVersion
|
||||
? ` to ${versionInfo.LatestVersion}`
|
||||
: null}{" "}
|
||||
failed.
|
||||
</p>
|
||||
<Button
|
||||
className="m-3"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/")}
|
||||
>
|
||||
Return
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<pre className="h-64 overflow-scroll m-3">
|
||||
<code>{updateLog}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch, setSynoToken } from "src/api"
|
||||
|
||||
export type AuthResponse = {
|
||||
serverMode: AuthServerMode
|
||||
authorized: 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.
|
||||
*/
|
||||
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")
|
||||
.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)
|
||||
}
|
||||
return d
|
||||
})
|
||||
.catch((error) => {
|
||||
setLoading(false)
|
||||
console.error(error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
return apiFetch<{ authUrl?: string }>("/auth/session/new", "GET")
|
||||
.then((d) => {
|
||||
if (d.authUrl) {
|
||||
window.open(d.authUrl, "_blank")
|
||||
return apiFetch("/auth/session/wait", "GET")
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
loadAuth()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}, [loadAuth])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuth().then((d) => {
|
||||
if (!d) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!d.authorized &&
|
||||
hasAnyEditCapabilities(d) &&
|
||||
// Start auth flow immediately if browser has requested it.
|
||||
new URLSearchParams(window.location.search).get("check") === "now"
|
||||
) {
|
||||
newSession()
|
||||
}
|
||||
})
|
||||
// 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,
|
||||
newSession,
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
// 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"
|
||||
|
||||
export default function useExitNodes(node: NodeData, filter?: string) {
|
||||
const { data } = useSWR<ExitNode[]>("/exit-nodes")
|
||||
|
||||
const { tailnetNodesSorted, locationNodesMap } = useMemo(() => {
|
||||
// First going through exit nodes and splitting them into two groups:
|
||||
// 1. tailnetNodes: exit nodes advertised by tailnet's own nodes
|
||||
// 2. locationNodes: exit nodes advertised by non-tailnet Mullvad nodes
|
||||
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) {
|
||||
// 2023-11-15: Currently, if the node doesn't have
|
||||
// location information, it is owned by the tailnet.
|
||||
// Only Mullvad exit nodes have locations filled.
|
||||
tailnetNodes.push({
|
||||
...n,
|
||||
Name: trimDNSSuffix(n.Name, node.TailnetName),
|
||||
})
|
||||
return
|
||||
}
|
||||
const countryNodes =
|
||||
locationNodes.get(loc.CountryCode) || new Map<CityCode, ExitNode[]>()
|
||||
const cityNodes = countryNodes.get(loc.CityCode) || []
|
||||
countryNodes.set(loc.CityCode, [...cityNodes, n])
|
||||
locationNodes.set(loc.CountryCode, countryNodes)
|
||||
})
|
||||
|
||||
return {
|
||||
tailnetNodesSorted: tailnetNodes.sort(compareByName),
|
||||
locationNodesMap: locationNodes,
|
||||
}
|
||||
}, [data, node.Features, node.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`.
|
||||
const addBestMatchNode = (
|
||||
options: ExitNode[],
|
||||
name: (l: ExitNodeLocation) => string
|
||||
) => {
|
||||
const bestNode = highestPriorityNode(options)
|
||||
if (!bestNode || !bestNode.Location) {
|
||||
return // not possible, doing this for type safety
|
||||
}
|
||||
nodes.push({
|
||||
...bestNode,
|
||||
Name: name(bestNode.Location),
|
||||
})
|
||||
}
|
||||
|
||||
if (!hasFilter) {
|
||||
// When nothing is searched, only show a single best-matching
|
||||
// exit node per-country.
|
||||
//
|
||||
// There's too many location-based nodes to display all of them.
|
||||
locationNodesMap.forEach(
|
||||
// add one node per country
|
||||
(countryNodes) =>
|
||||
addBestMatchNode(flattenMap(countryNodes), (l) => l.Country)
|
||||
)
|
||||
} else {
|
||||
// Otherwise, show the best match on a city-level,
|
||||
// with a "Country: Best Match" node at top.
|
||||
//
|
||||
// i.e. We allow for discovering cities through searching.
|
||||
locationNodesMap.forEach((countryNodes) => {
|
||||
countryNodes.forEach(
|
||||
// add one node per city
|
||||
(cityNodes) =>
|
||||
addBestMatchNode(cityNodes, (l) => `${l.Country}: ${l.City}`)
|
||||
)
|
||||
// add the "Country: Best Match" node
|
||||
addBestMatchNode(
|
||||
flattenMap(countryNodes),
|
||||
(l) => `${l.Country}: Best Match`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return nodes.sort(compareByName)
|
||||
}, [hasFilter, locationNodesMap, node.Features])
|
||||
|
||||
// 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: "tailnet",
|
||||
nodes: filterLower
|
||||
? tailnetNodesSorted.filter((n) =>
|
||||
n.Name.toLowerCase().includes(filterLower)
|
||||
)
|
||||
: tailnetNodesSorted,
|
||||
},
|
||||
{
|
||||
id: "mullvad",
|
||||
name: "Mullvad VPN",
|
||||
nodes: filterLower
|
||||
? mullvadNodesSorted.filter((n) =>
|
||||
n.Name.toLowerCase().includes(filterLower)
|
||||
)
|
||||
: mullvadNodesSorted,
|
||||
},
|
||||
]
|
||||
}, [filter, node.Features, tailnetNodesSorted, mullvadNodesSorted])
|
||||
|
||||
return { data: exitNodeGroups }
|
||||
}
|
||||
|
||||
// highestPriorityNode finds the highest priority node for use
|
||||
// (the "best match" node) from a list of exit nodes.
|
||||
// Nodes with equal priorities are picked between arbitrarily.
|
||||
function highestPriorityNode(nodes: ExitNode[]): ExitNode | undefined {
|
||||
return nodes.length === 0
|
||||
? undefined
|
||||
: nodes.sort(
|
||||
(a, b) => (b.Location?.Priority || 0) - (a.Location?.Priority || 0)
|
||||
)[0]
|
||||
}
|
||||
|
||||
// compareName compares two exit nodes alphabetically by name.
|
||||
function compareByName(a: ExitNode, b: ExitNode): number {
|
||||
if (a.Location && b.Location && a.Location.Country === b.Location.Country) {
|
||||
// Always put "<Country>: Best Match" node at top of country list.
|
||||
if (a.Name.includes(": Best Match")) {
|
||||
return -1
|
||||
} else if (b.Name.includes(": Best Match")) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return a.Name.localeCompare(b.Name)
|
||||
}
|
||||
|
||||
function flattenMap<T, V>(m: Map<T, V[]>): V[] {
|
||||
return Array.from(m.values()).reduce((prev, curr) => [...prev, ...curr])
|
||||
}
|
||||
|
||||
// trimDNSSuffix trims the tailnet dns name from s, leaving no
|
||||
// trailing dots.
|
||||
//
|
||||
// trimDNSSuffix("hello.ts.net", "ts.net") = "hello"
|
||||
// trimDNSSuffix("hello", "ts.net") = "hello"
|
||||
export function trimDNSSuffix(s: string, tailnetDNSName: string): string {
|
||||
if (s.endsWith(".")) {
|
||||
s = s.slice(0, -1)
|
||||
}
|
||||
if (s.endsWith("." + tailnetDNSName)) {
|
||||
s = s.replace("." + tailnetDNSName, "")
|
||||
}
|
||||
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 runAsExitNode: ExitNode = {
|
||||
ID: "RUNNING",
|
||||
Name: "Run as exit node",
|
||||
Online: true,
|
||||
}
|
||||
117
client/web/src/hooks/node-data.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch, setUnraidCsrfToken } from "src/api"
|
||||
|
||||
export type NodeData = {
|
||||
Profile: UserProfile
|
||||
Status: string
|
||||
DeviceName: string
|
||||
IP: string
|
||||
AdvertiseExitNode: boolean
|
||||
AdvertiseRoutes: string
|
||||
LicensesURL: string
|
||||
TUNMode: boolean
|
||||
IsSynology: boolean
|
||||
DSMVersion: number
|
||||
IsUnraid: boolean
|
||||
UnraidToken: string
|
||||
IPNVersion: string
|
||||
|
||||
DebugMode: "" | "login" | "full" // empty when not running in any debug mode
|
||||
}
|
||||
|
||||
export type UserProfile = {
|
||||
LoginName: string
|
||||
DisplayName: string
|
||||
ProfilePicURL: string
|
||||
}
|
||||
|
||||
export type NodeUpdate = {
|
||||
AdvertiseRoutes?: string
|
||||
AdvertiseExitNode?: boolean
|
||||
Reauthenticate?: boolean
|
||||
ForceLogout?: boolean
|
||||
}
|
||||
|
||||
// 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 updateNode = useCallback(
|
||||
(update: NodeUpdate) => {
|
||||
// The contents of this function are mostly copied over
|
||||
// from the legacy client's web.html file.
|
||||
// It makes all data updates through one API endpoint.
|
||||
// As we build out the web client in React,
|
||||
// this endpoint will eventually be deprecated.
|
||||
|
||||
if (isPosting || !data) {
|
||||
return
|
||||
}
|
||||
setIsPosting(true)
|
||||
|
||||
update = {
|
||||
...update,
|
||||
// Default to current data value for any unset fields.
|
||||
AdvertiseRoutes:
|
||||
update.AdvertiseRoutes !== undefined
|
||||
? update.AdvertiseRoutes
|
||||
: data.AdvertiseRoutes,
|
||||
AdvertiseExitNode:
|
||||
update.AdvertiseExitNode !== undefined
|
||||
? update.AdvertiseExitNode
|
||||
: data.AdvertiseExitNode,
|
||||
}
|
||||
|
||||
apiFetch("/data", "POST", update, { up: "true" })
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
setIsPosting(false)
|
||||
const err = r["error"]
|
||||
if (err) {
|
||||
throw new Error(err)
|
||||
}
|
||||
const url = r["url"]
|
||||
if (url) {
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
refreshData()
|
||||
})
|
||||
.catch((err) => alert("Failed operation: " + err.message))
|
||||
},
|
||||
[data]
|
||||
)
|
||||
|
||||
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.
|
||||
[]
|
||||
)
|
||||
|
||||
return { data, refreshData, updateNode, isPosting }
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { VersionInfo } from "src/types"
|
||||
|
||||
// see ipnstate.UpdateProgress
|
||||
export type UpdateProgress = {
|
||||
status: "UpdateFinished" | "UpdateInProgress" | "UpdateFailed"
|
||||
message: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export enum UpdateState {
|
||||
UpToDate,
|
||||
Available,
|
||||
InProgress,
|
||||
Complete,
|
||||
Failed,
|
||||
}
|
||||
|
||||
// useInstallUpdate initiates and tracks a Tailscale self-update via the LocalAPI,
|
||||
// and returns state messages showing the progress of the update.
|
||||
export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) {
|
||||
const [updateState, setUpdateState] = useState<UpdateState>(
|
||||
cv?.RunningLatest ? UpdateState.UpToDate : UpdateState.Available
|
||||
)
|
||||
|
||||
const [updateLog, setUpdateLog] = useState<string>("")
|
||||
|
||||
const appendUpdateLog = useCallback(
|
||||
(msg: string) => {
|
||||
setUpdateLog(updateLog + msg + "\n")
|
||||
},
|
||||
[updateLog, setUpdateLog]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (updateState !== UpdateState.Available) {
|
||||
// useEffect cleanup function
|
||||
return () => {}
|
||||
}
|
||||
|
||||
setUpdateState(UpdateState.InProgress)
|
||||
|
||||
apiFetch("/local/v0/update/install", "POST").catch((err) => {
|
||||
console.error(err)
|
||||
setUpdateState(UpdateState.Failed)
|
||||
})
|
||||
|
||||
let tsAwayForPolls = 0
|
||||
let updateMessagesRead = 0
|
||||
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
|
||||
function poll() {
|
||||
apiFetch<UpdateProgress[]>("/local/v0/update/progress", "GET")
|
||||
.then((res) => {
|
||||
// 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
|
||||
// initialized to zero here and we don't just use res.forEach()
|
||||
for (; updateMessagesRead < res.length; ++updateMessagesRead) {
|
||||
const up = res[updateMessagesRead]
|
||||
if (up.status === "UpdateFailed") {
|
||||
setUpdateState(UpdateState.Failed)
|
||||
if (up.message) appendUpdateLog("ERROR: " + up.message)
|
||||
return
|
||||
}
|
||||
|
||||
if (up.status === "UpdateFinished") {
|
||||
// if update finished and tailscaled did not go away (ie. did not restart),
|
||||
// then the version being the same might not be an error, it might just require
|
||||
// the user to restart Tailscale manually (this is required in some cases in the
|
||||
// clientupdate package).
|
||||
if (up.version === currentVersion && tsAwayForPolls > 0) {
|
||||
setUpdateState(UpdateState.Failed)
|
||||
appendUpdateLog(
|
||||
"ERROR: Update failed, still running Tailscale " + up.version
|
||||
)
|
||||
if (up.message) appendUpdateLog("ERROR: " + up.message)
|
||||
} else {
|
||||
setUpdateState(UpdateState.Complete)
|
||||
if (up.message) appendUpdateLog("INFO: " + up.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setUpdateState(UpdateState.InProgress)
|
||||
if (up.message) appendUpdateLog("INFO: " + up.message)
|
||||
}
|
||||
|
||||
// If we have gone through the entire loop without returning out of the function,
|
||||
// the update is still in progress. So we want to poll again for further status
|
||||
// updates.
|
||||
timer = setTimeout(poll, 1000)
|
||||
})
|
||||
.catch((err) => {
|
||||
++tsAwayForPolls
|
||||
if (tsAwayForPolls >= 5 * 60) {
|
||||
setUpdateState(UpdateState.Failed)
|
||||
appendUpdateLog(
|
||||
"ERROR: tailscaled went away but did not come back!"
|
||||
)
|
||||
appendUpdateLog("ERROR: last error received:")
|
||||
appendUpdateLog(err.toString())
|
||||
} else {
|
||||
timer = setTimeout(poll, 1000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
poll()
|
||||
|
||||
// useEffect cleanup function
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return !cv
|
||||
? { updateState: UpdateState.UpToDate, updateLog: "" }
|
||||
: { updateState, updateLog }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
15
client/web/src/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 |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -2,463 +2,129 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("./assets/fonts/Inter.var.latin.woff2") format("woff2-variations");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC,
|
||||
U+02BB-02BC, U+2000-206F, U+2122, U+2190-2199, U+2212, U+2215, U+FEFF,
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* Non-Tailwind styles begin here.
|
||||
*/
|
||||
|
||||
: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];
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-gray-500 text-sm font-medium uppercase leading-tight tracking-wide;
|
||||
}
|
||||
.bg-gray-0 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.details-card h1 {
|
||||
@apply text-gray-800 text-lg font-medium leading-snug;
|
||||
}
|
||||
.details-card h2 {
|
||||
@apply text-gray-500 text-xs font-semibold uppercase tracking-wide;
|
||||
}
|
||||
.details-card table {
|
||||
@apply w-full;
|
||||
}
|
||||
.details-card tbody {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
.details-card tr {
|
||||
@apply grid grid-flow-col grid-cols-3 gap-2;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.description {
|
||||
@apply text-gray-500 leading-snug;
|
||||
}
|
||||
|
||||
/**
|
||||
* .toggle applies "Toggle" UI styles to input[type="checkbox"] form elements.
|
||||
* 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;
|
||||
transition: background-color 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.toggle:disabled {
|
||||
@apply bg-gray-200;
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
.toggle:checked {
|
||||
@apply bg-blue-500;
|
||||
}
|
||||
|
||||
.toggle:checked:disabled {
|
||||
@apply bg-blue-300;
|
||||
}
|
||||
|
||||
.toggle:focus {
|
||||
@apply outline-none ring;
|
||||
}
|
||||
|
||||
.toggle::after {
|
||||
@apply absolute bg-white rounded-full will-change-[width];
|
||||
@apply w-3.5 h-3.5 m-[0.1875rem] translate-x-0;
|
||||
content: " ";
|
||||
transition: width 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.toggle:checked::after {
|
||||
@apply translate-x-5;
|
||||
}
|
||||
|
||||
.toggle:checked:disabled::after {
|
||||
@apply bg-blue-50;
|
||||
}
|
||||
|
||||
.toggle:enabled:active::after {
|
||||
@apply w-[1.125rem];
|
||||
}
|
||||
|
||||
.toggle:checked:enabled:active::after {
|
||||
@apply w-[1.125rem] translate-x-3.5;
|
||||
}
|
||||
|
||||
.toggle-large {
|
||||
@apply w-12 h-6;
|
||||
}
|
||||
|
||||
.toggle-large::after {
|
||||
@apply m-1 w-4 h-4;
|
||||
}
|
||||
|
||||
.toggle-large:checked::after {
|
||||
@apply translate-x-6;
|
||||
}
|
||||
|
||||
.toggle-large:enabled:active::after {
|
||||
@apply w-6;
|
||||
}
|
||||
|
||||
.toggle-large:checked:enabled:active::after {
|
||||
@apply w-6 translate-x-4;
|
||||
}
|
||||
|
||||
.toggle-small {
|
||||
@apply w-6 h-3;
|
||||
}
|
||||
|
||||
.toggle-small:focus {
|
||||
/**
|
||||
* We disable ring for .toggle-small because it is a
|
||||
* small, inline element.
|
||||
*/
|
||||
@apply outline-none shadow-none;
|
||||
}
|
||||
|
||||
.toggle-small::after {
|
||||
@apply w-2 h-2 m-0.5;
|
||||
}
|
||||
|
||||
.toggle-small:checked::after {
|
||||
@apply translate-x-3;
|
||||
}
|
||||
|
||||
.toggle-small:enabled:active::after {
|
||||
@apply w-[0.675rem];
|
||||
}
|
||||
|
||||
.toggle-small:checked:enabled:active::after {
|
||||
@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
|
||||
* and .button are commonly used together.
|
||||
*/
|
||||
|
||||
.input,
|
||||
.input-wrapper {
|
||||
@apply appearance-none leading-tight rounded-md bg-white border border-gray-300 hover:border-gray-400 transition-colors w-full h-input;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply px-3;
|
||||
}
|
||||
|
||||
.input::placeholder,
|
||||
.input-wrapper::placeholder {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.input:disabled,
|
||||
.input-wrapper:disabled {
|
||||
@apply border-gray-300;
|
||||
@apply bg-gray-0;
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.input-wrapper:focus-within {
|
||||
@apply outline-none ring border-gray-400;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.bg-gray-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.h-input {
|
||||
@apply h-[2.375rem];
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Preserved js license comment for web client app.
|
||||
/**
|
||||
* @license
|
||||
* Copyright (c) Tailscale Inc & AUTHORS
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
|
||||
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 +15,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
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { HTMLAttributes } from "react"
|
||||
|
||||
export type BadgeColor =
|
||||
| "blue"
|
||||
| "green"
|
||||
| "red"
|
||||
| "orange"
|
||||
| "yellow"
|
||||
| "gray"
|
||||
| "outline"
|
||||
|
||||
type Props = {
|
||||
variant: "tag" | "status"
|
||||
color: BadgeColor
|
||||
} & HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export default function Badge(props: Props) {
|
||||
const { className, color, variant, ...rest } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"inline-flex items-center align-middle justify-center font-medium",
|
||||
{
|
||||
"border border-gray-200 bg-gray-200 text-gray-600": color === "gray",
|
||||
"border border-green-50 bg-green-50 text-green-600":
|
||||
color === "green",
|
||||
"border border-blue-50 bg-blue-50 text-blue-600": color === "blue",
|
||||
"border border-orange-50 bg-orange-50 text-orange-600":
|
||||
color === "orange",
|
||||
"border border-yellow-50 bg-yellow-50 text-yellow-600":
|
||||
color === "yellow",
|
||||
"border border-red-50 bg-red-50 text-red-600": color === "red",
|
||||
"border border-gray-300 bg-white": color === "outline",
|
||||
"rounded-full px-2 py-1 leading-none": variant === "status",
|
||||
"rounded-sm px-1": variant === "tag",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Badge.defaultProps = {
|
||||
color: "gray",
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { HTMLProps } from "react"
|
||||
import LoadingDots from "src/ui/loading-dots"
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cx(
|
||||
"button",
|
||||
{
|
||||
// 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,
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
{...rest}
|
||||
>
|
||||
{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>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import * as Primitive from "@radix-ui/react-collapsible"
|
||||
import React, { useState } from "react"
|
||||
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
|
||||
|
||||
type CollapsibleProps = {
|
||||
trigger?: string
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export default function Collapsible(props: CollapsibleProps) {
|
||||
const { children, trigger, onOpenChange } = props
|
||||
const [open, setOpen] = useState(props.open)
|
||||
|
||||
return (
|
||||
<Primitive.Root
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open)
|
||||
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">
|
||||
<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>
|
||||
{trigger}
|
||||
</Primitive.Trigger>
|
||||
<Primitive.Content className="mt-2">{children}</Primitive.Content>
|
||||
</Primitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -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,44 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { InputHTMLAttributes } from "react"
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
error?: boolean
|
||||
suffix?: JSX.Element
|
||||
} & InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
// Input is styled in a way that only works for text inputs.
|
||||
const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
|
||||
const {
|
||||
className,
|
||||
inputClassName,
|
||||
error,
|
||||
prefix,
|
||||
suffix,
|
||||
disabled,
|
||||
...rest
|
||||
} = props
|
||||
return (
|
||||
<div className={cx("relative", className)}>
|
||||
<input
|
||||
ref={ref}
|
||||
className={cx("input z-10", inputClassName, {
|
||||
"input-error": error,
|
||||
})}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
{suffix ? (
|
||||
<div className="bg-white top-1 bottom-1 right-1 rounded-r-md absolute flex items-center">
|
||||
{suffix}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default Input
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
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
|
||||
content: ReactNode
|
||||
children: ReactNode
|
||||
|
||||
/**
|
||||
* asChild renders the trigger element without wrapping it in a button. Use
|
||||
* this when you want to use a `button` element as the trigger.
|
||||
*/
|
||||
asChild?: boolean
|
||||
/**
|
||||
* side is the side of the direction from the target element to render the
|
||||
* popover.
|
||||
*/
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
/**
|
||||
* sideOffset is how far from a give side to render the popover.
|
||||
*/
|
||||
sideOffset?: number
|
||||
/**
|
||||
* align is how to align the popover with the target element.
|
||||
*/
|
||||
align?: "start" | "center" | "end"
|
||||
/**
|
||||
* alignOffset is how far off of the alignment point to render the popover.
|
||||
*/
|
||||
alignOffset?: number
|
||||
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover is a UI component that allows rendering unique controls in a floating
|
||||
* popover, attached to a trigger element. It appears on click and manages focus
|
||||
* on its own behalf.
|
||||
*
|
||||
* To use the Popover, pass the content as children, and give it a `trigger`:
|
||||
*
|
||||
* <Popover trigger={<span>Open popover</span>}>
|
||||
* <p>Hello world!</p>
|
||||
* </Popover>
|
||||
*
|
||||
* By default, the toggle is wrapped in an accessible <button> tag. You can
|
||||
* customize by providing your own button and using the `asChild` prop.
|
||||
*
|
||||
* <Popover trigger={<Button>Hello</Button>} asChild>
|
||||
* <p>Hello world!</p>
|
||||
* </Popover>
|
||||
*
|
||||
* The former style is recommended whenever possible.
|
||||
*/
|
||||
export default function Popover(props: Props) {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
content,
|
||||
side,
|
||||
sideOffset,
|
||||
align,
|
||||
alignOffset,
|
||||
asChild,
|
||||
open,
|
||||
onOpenChange,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverPrimitive.Trigger asChild={asChild}>
|
||||
{children}
|
||||
</PopoverPrimitive.Trigger>
|
||||
<PortalContainerContext.Consumer>
|
||||
{(portalContainer) => (
|
||||
<PopoverPrimitive.Portal container={portalContainer}>
|
||||
<PopoverPrimitive.Content
|
||||
className={cx(
|
||||
"origin-radix-popover shadow-popover bg-white rounded-md z-50",
|
||||
"state-open:animate-scale-in state-closed:animate-scale-out",
|
||||
className
|
||||
)}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
collisionPadding={12}
|
||||
>
|
||||
{content}
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
)}
|
||||
</PortalContainerContext.Consumer>
|
||||
</PopoverPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
Popover.defaultProps = {
|
||||
sideOffset: 10,
|
||||
}
|
||||
@@ -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,41 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
|
||||
export default function ProfilePic({
|
||||
url,
|
||||
size = "large",
|
||||
className,
|
||||
}: {
|
||||
url?: string
|
||||
size?: "small" | "medium" | "large"
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"relative flex-shrink-0 rounded-full overflow-hidden",
|
||||
{
|
||||
"w-5 h-5": size === "small",
|
||||
"w-[26px] h-[26px]": size === "medium",
|
||||
"w-8 h-8": size === "large",
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{url ? (
|
||||
<div
|
||||
className="w-full h-full flex pointer-events-none rounded-full bg-gray-200"
|
||||
style={{
|
||||
backgroundImage: `url(${url})`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex pointer-events-none rounded-full border border-gray-400 border-dashed" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { forwardRef, InputHTMLAttributes } from "react"
|
||||
import Search from "src/assets/icons/search.svg?react"
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
} & InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
/**
|
||||
* SearchInput is a standard input with a search icon.
|
||||
*/
|
||||
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" />
|
||||
<input
|
||||
type="text"
|
||||
className={cx("input pl-9 pr-8", inputClassName)}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
SearchInput.displayName = "SearchInput"
|
||||
export default SearchInput
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { HTMLAttributes } from "react"
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
size: "sm" | "md"
|
||||
} & HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export default function Spinner(props: Props) {
|
||||
const { className, size, ...rest } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"spinner inline-block rounded-full align-middle",
|
||||
{
|
||||
"border-2 w-4 h-4": size === "sm",
|
||||
"border-4 w-8 h-8": size === "md",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Spinner.defaultProps = {
|
||||
size: "md",
|
||||
}
|
||||
@@ -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,44 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { ChangeEvent } from "react"
|
||||
|
||||
type Props = {
|
||||
id?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
checked: boolean
|
||||
sizeVariant?: "small" | "medium" | "large"
|
||||
onChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
export default function Toggle(props: Props) {
|
||||
const { className, id, disabled, checked, sizeVariant, onChange } = props
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
onChange(e.target.checked)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className={cx(
|
||||
"toggle",
|
||||
{
|
||||
"toggle-large": sizeVariant === "large",
|
||||
"toggle-small": sizeVariant === "small",
|
||||
},
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Toggle.defaultProps = {
|
||||
sizeVariant: "medium",
|
||||
}
|
||||
@@ -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>)"
|
||||
}
|
||||
}
|
||||
}
|
||||