Compare commits

..

1 Commits

Author SHA1 Message Date
Brad Fitzpatrick
f073740c08 wgengine/magicsock: make component debug logging include disco logs
Updates #1548

Change-Id: Ie70b4930670ca7a0f225d585afbb87048aefc965
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2023-11-17 13:02:51 -08:00
1061 changed files with 18851 additions and 106286 deletions

View File

@@ -24,11 +24,5 @@ jobs:
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
- name: Run checklocks vet
# TODO(#12625): add more packages as we add annotations
run: |-
./tool/go vet -vettool=/tmp/checklocks \
./envknob \
./ipn/store/mem \
./net/stun/stuntest \
./net/wsconn \
./proxymap
# TODO: remove || true once we have applied checklocks annotations everywhere.
run: ./tool/go vet -vettool=/tmp/checklocks ./... || true

View File

@@ -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
View 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

View File

@@ -34,7 +34,7 @@ jobs:
# Note: this is the 'v3' tag as of 2023-08-14
uses: golangci/golangci-lint-action@639cd343e1d3b897ff35927a75193d57cfcba299
with:
version: v1.56
version: v1.54.2
# Show only new issues if it's a pull request.
only-new-issues: true

View File

@@ -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'

View File

@@ -32,6 +32,7 @@ jobs:
- "ubuntu:18.04"
- "ubuntu:20.04"
- "ubuntu:22.04"
- "ubuntu:22.10"
- "ubuntu:23.04"
- "elementary/docker:stable"
- "elementary/docker:unstable"
@@ -67,11 +68,6 @@ jobs:
image: ${{ matrix.image }}
options: --user root
steps:
- name: install dependencies (pacman)
# Refresh the package databases to ensure that the tailscale package is
# defined.
run: pacman -Sy
if: contains(matrix.image, 'archlinux')
- name: install dependencies (yum)
# tar and gzip are needed by the actions/checkout below.
run: yum install -y --allowerasing tar gzip ${{ matrix.deps }}
@@ -95,10 +91,7 @@ jobs:
|| contains(matrix.image, 'parrotsec')
|| contains(matrix.image, 'kalilinux')
- name: checkout
# We cannot use v4, as it requires a newer glibc version than some of the
# tested images provide. See
# https://github.com/actions/checkout/issues/1487
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: run installer
run: scripts/installer.sh
# Package installation can fail in docker because systemd is not running

View File

@@ -2,8 +2,7 @@ name: "Kubernetes manifests"
on:
pull_request:
paths:
- 'cmd/k8s-operator/**'
- 'k8s-operator/**'
- './cmd/k8s-operator/'
- '.github/workflows/kubemanifests.yaml'
# Cancel workflow run if there is a newer push to the same PR for which it is
@@ -23,9 +22,3 @@ jobs:
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)

View File

@@ -1,23 +0,0 @@
# Run the ssh integration tests with `make sshintegrationtest`.
# These tests can also be running locally.
name: "ssh-integrationtest"
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
on:
pull_request:
paths:
- "ssh/**"
- "tempfork/gliderlabs/ssh/**"
- ".github/workflows/ssh-integrationtest"
jobs:
ssh-integrationtest:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Run SSH integration tests
run: |
make sshintegrationtest

View File

@@ -64,7 +64,6 @@ jobs:
matrix:
include:
- goarch: amd64
coverflags: "-coverprofile=/tmp/coverage.out"
- goarch: amd64
buildflags: "-race"
shard: '1/3'
@@ -119,15 +118,10 @@ 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 +177,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 ./derp/xdp
vm:
runs-on: ["self-hosted", "linux", "vm"]
# VM tests run with some privileges, don't let them run on 3p PRs.
@@ -206,9 +187,9 @@ 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"
XDB_CACHE_HOME: "/var/lib/ghrunner/cache"
race-build:
runs-on: ubuntu-22.04
@@ -254,6 +235,9 @@ jobs:
goarch: amd64
- goos: openbsd
goarch: amd64
# Plan9
- goos: plan9
goarch: amd64
runs-on: ubuntu-22.04
steps:
@@ -302,47 +286,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 +299,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 +423,7 @@ jobs:
uses: actions/checkout@v4
- name: check that 'go generate' is clean
run: |
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator|xdp')
pkgs=$(./tool/go list ./... | grep -v dnsfallback)
./tool/go generate $pkgs
echo
echo

View File

@@ -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
View File

@@ -9,7 +9,6 @@
cmd/tailscale/tailscale
cmd/tailscaled/tailscaled
ssh/tailssh/testcontainers/tailscaled
# Test binary, built with `go test -c`
*.test

View File

@@ -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:

View File

@@ -1 +1 @@
3.18
3.16

View File

@@ -1,13 +1,17 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
# Note that this Dockerfile is currently NOT used to build any of the published
# Tailscale container images and may have drifted from the image build mechanism
# we use.
# Tailscale images are currently built using https://github.com/tailscale/mkctr,
# and the build script can be found in ./build_docker.sh.
############################################################################
#
# WARNING: Tailscale is not yet officially supported in container
# environments, such as Docker and Kubernetes. Though it should work, we
# don't regularly test it, and we know there are some feature limitations.
#
# See current bugs tagged "containers":
# https://github.com/tailscale/tailscale/labels/containers
#
############################################################################
# This Dockerfile includes all the tailscale binaries.
#
# To build the Dockerfile:
@@ -27,7 +31,7 @@
# $ docker exec tailscaled tailscale status
FROM golang:1.22-alpine AS build-env
FROM golang:1.21-alpine AS build-env
WORKDIR /go/src/tailscale
@@ -62,7 +66,7 @@ RUN GOARCH=$TARGETARCH go install -ldflags="\
-X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
FROM alpine:3.18
FROM alpine:3.16
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
COPY --from=build-env /go/bin/* /usr/local/bin/

View File

@@ -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

View File

@@ -1,28 +1,21 @@
IMAGE_REPO ?= tailscale/tailscale
SYNO_ARCH ?= "x86_64"
SYNO_ARCH ?= "amd64"
SYNO_DSM ?= "7"
TAGS ?= "latest"
PLATFORM ?= "flyio" ## flyio==linux/amd64. Set to "" to build all platforms.
vet: ## Run go vet
./tool/go vet ./...
tidy: ## Run go mod tidy
./tool/go mod tidy
lint: ## Run golangci-lint
./tool/go run github.com/golangci/golangci-lint/cmd/golangci-lint run
updatedeps: ## Update depaware deps
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
# it finds in its $$PATH is the right one.
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --update \
tailscale.com/cmd/tailscaled \
tailscale.com/cmd/tailscale \
tailscale.com/cmd/derper \
tailscale.com/cmd/k8s-operator \
tailscale.com/cmd/stund
tailscale.com/cmd/derper
depaware: ## Run depaware checks
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
@@ -30,9 +23,7 @@ depaware: ## Run depaware checks
PATH="$$(./tool/go env GOROOT)/bin:$$PATH" ./tool/go run github.com/tailscale/depaware --check \
tailscale.com/cmd/tailscaled \
tailscale.com/cmd/tailscale \
tailscale.com/cmd/derper \
tailscale.com/cmd/k8s-operator \
tailscale.com/cmd/stund
tailscale.com/cmd/derper
buildwindows: ## Build tailscale CLI for windows/amd64
GOOS=windows GOARCH=amd64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
@@ -60,21 +51,6 @@ check: staticcheck vet depaware buildwindows build386 buildlinuxarm buildwasm ##
staticcheck: ## Run staticcheck.io checks
./tool/go run honnef.co/go/tools/cmd/staticcheck -- $$(./tool/go list ./... | grep -v tempfork)
kube-generate-all: kube-generate-deepcopy ## Refresh generated files for Tailscale Kubernetes Operator
./tool/go generate ./cmd/k8s-operator
# Tailscale operator watches Connector custom resources in a Kubernetes cluster
# and caches them locally. Caching is done implicitly by controller-runtime
# library (the middleware used by Tailscale operator to create kube control
# loops). When a Connector resource is GET/LIST-ed from within our control loop,
# the request goes through the cache. To ensure that cache contents don't get
# modified by control loops, controller-runtime deep copies the requested
# object. In order for this to work, Connector must implement deep copy
# functionality so we autogenerate it here.
# https://github.com/kubernetes-sigs/controller-runtime/blob/v0.16.3/pkg/cache/internal/cache_reader.go#L86-L89
kube-generate-deepcopy: ## Refresh generated deepcopy functionality for Tailscale kube API types
./scripts/kube-deepcopy.sh
spk: ## Build synology package for ${SYNO_ARCH} architecture and ${SYNO_DSM} DSM version
./tool/go run ./cmd/dist build synology/dsm${SYNO_DSM}/${SYNO_ARCH}
@@ -92,7 +68,7 @@ publishdevimage: ## Build and publish tailscale image to location specified by $
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=client ./build_docker.sh
TAGS="${TAGS}" REPOS=${REPO} PUSH=true TARGET=client ./build_docker.sh
publishdevoperator: ## Build and publish k8s-operator image to location specified by ${REPO}
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
@@ -100,24 +76,7 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-operator" || (echo "REPO=... must not be tailscale/k8s-operator" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh
publishdevnameserver: ## Build and publish k8s-nameserver image to location specified by ${REPO}
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
@test "${REPO}" != "tailscale/k8s-nameserver" || (echo "REPO=... must not be tailscale/k8s-nameserver" && exit 1)
@test "${REPO}" != "ghcr.io/tailscale/k8s-nameserver" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-nameserver" && exit 1)
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-nameserver ./build_docker.sh
.PHONY: sshintegrationtest
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
@GOOS=linux GOARCH=amd64 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
echo "Testing on ubuntu: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"

View File

@@ -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.)

View File

@@ -1 +1 @@
1.71.0
1.55.0

1781
api.md

File diff suppressed because it is too large Load Diff

View File

@@ -10,123 +10,24 @@
package appc
import (
"context"
"fmt"
"net/netip"
"slices"
"strings"
"sync"
"time"
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/util/clientmetric"
"tailscale.com/util/dnsname"
"tailscale.com/util/execqueue"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
)
// rateLogger responds to calls to update by adding a count for the current period and
// calling the callback if any previous period has finished since update was last called
type rateLogger struct {
interval time.Duration
start time.Time
periodStart time.Time
periodCount int64
now func() time.Time
callback func(int64, time.Time, int64)
}
func (rl *rateLogger) currentIntervalStart(now time.Time) time.Time {
millisSince := now.Sub(rl.start).Milliseconds() % rl.interval.Milliseconds()
return now.Add(-(time.Duration(millisSince)) * time.Millisecond)
}
func (rl *rateLogger) update(numRoutes int64) {
now := rl.now()
periodEnd := rl.periodStart.Add(rl.interval)
if periodEnd.Before(now) {
if rl.periodCount != 0 {
rl.callback(rl.periodCount, rl.periodStart, numRoutes)
}
rl.periodCount = 0
rl.periodStart = rl.currentIntervalStart(now)
}
rl.periodCount++
}
func newRateLogger(now func() time.Time, interval time.Duration, callback func(int64, time.Time, int64)) *rateLogger {
nowTime := now()
return &rateLogger{
callback: callback,
now: now,
interval: interval,
start: nowTime,
periodStart: nowTime,
}
}
// RouteAdvertiser is an interface that allows the AppConnector to advertise
// newly discovered routes that need to be served through the AppConnector.
type RouteAdvertiser interface {
// AdvertiseRoute adds one or more route advertisements skipping any that
// are already advertised.
AdvertiseRoute(...netip.Prefix) error
// UnadvertiseRoute removes any matching route advertisements.
UnadvertiseRoute(...netip.Prefix) error
}
var (
metricStoreRoutesRateBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000}
metricStoreRoutesNBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000, 10000}
metricStoreRoutesRate []*clientmetric.Metric
metricStoreRoutesN []*clientmetric.Metric
)
func initMetricStoreRoutes() {
for _, n := range metricStoreRoutesRateBuckets {
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_rate_%d", n)))
}
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter("appc_store_routes_rate_over"))
for _, n := range metricStoreRoutesNBuckets {
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_n_routes_%d", n)))
}
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter("appc_store_routes_n_routes_over"))
}
func recordMetric(val int64, buckets []int64, metrics []*clientmetric.Metric) {
if len(buckets) < 1 {
return
}
// finds the first bucket where val <=, or len(buckets) if none match
// for bucket values of 1, 10, 100; 0-1 goes to [0], 2-10 goes to [1], 11-100 goes to [2], 101+ goes to [3]
bucket, _ := slices.BinarySearch(buckets, val)
metrics[bucket].Add(1)
}
func metricStoreRoutes(rate, nRoutes int64) {
if len(metricStoreRoutesRate) == 0 {
initMetricStoreRoutes()
}
recordMetric(rate, metricStoreRoutesRateBuckets, metricStoreRoutesRate)
recordMetric(nRoutes, metricStoreRoutesNBuckets, metricStoreRoutesN)
}
// RouteInfo is a data structure used to persist the in memory state of an AppConnector
// so that we can know, even after a restart, which routes came from ACLs and which were
// learned from domains.
type RouteInfo struct {
// Control is the routes from the 'routes' section of an app connector acl.
Control []netip.Prefix `json:",omitempty"`
// Domains are the routes discovered by observing DNS lookups for configured domains.
Domains map[string][]netip.Addr `json:",omitempty"`
// Wildcards are the configured DNS lookup domains to observe. When a DNS query matches Wildcards,
// its result is added to Domains.
Wildcards []string `json:",omitempty"`
// AdvertiseRoute adds a new route advertisement if the route is not already
// being advertised.
AdvertiseRoute(netip.Prefix) error
}
// AppConnector is an implementation of an AppConnector that performs
@@ -142,115 +43,29 @@ type AppConnector struct {
logf logger.Logf
routeAdvertiser RouteAdvertiser
// storeRoutesFunc will be called to persist routes if it is not nil.
storeRoutesFunc func(*RouteInfo) error
// mu guards the fields that follow
mu sync.Mutex
// domains is a map of lower case domain names with no trailing dot, to an
// ordered list of resolved IP addresses.
// domains is a map of lower case domain names with no trailing dot, to a
// list of resolved IP addresses.
domains map[string][]netip.Addr
// controlRoutes is the list of routes that were last supplied by control.
controlRoutes []netip.Prefix
// wildcards is the list of domain strings that match subdomains.
wildcards []string
// queue provides ordering for update operations
queue execqueue.ExecQueue
writeRateMinute *rateLogger
writeRateDay *rateLogger
}
// NewAppConnector creates a new AppConnector.
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser, routeInfo *RouteInfo, storeRoutesFunc func(*RouteInfo) error) *AppConnector {
ac := &AppConnector{
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConnector {
return &AppConnector{
logf: logger.WithPrefix(logf, "appc: "),
routeAdvertiser: routeAdvertiser,
storeRoutesFunc: storeRoutesFunc,
}
if routeInfo != nil {
ac.domains = routeInfo.Domains
ac.wildcards = routeInfo.Wildcards
ac.controlRoutes = routeInfo.Control
}
ac.writeRateMinute = newRateLogger(time.Now, time.Minute, func(c int64, s time.Time, l int64) {
ac.logf("routeInfo write rate: %d in minute starting at %v (%d routes)", c, s, l)
metricStoreRoutes(c, l)
})
ac.writeRateDay = newRateLogger(time.Now, 24*time.Hour, func(c int64, s time.Time, l int64) {
ac.logf("routeInfo write rate: %d in 24 hours starting at %v (%d routes)", c, s, l)
})
return ac
}
// ShouldStoreRoutes returns true if the appconnector was created with the controlknob on
// and is storing its discovered routes persistently.
func (e *AppConnector) ShouldStoreRoutes() bool {
return e.storeRoutesFunc != nil
}
// storeRoutesLocked takes the current state of the AppConnector and persists it
func (e *AppConnector) storeRoutesLocked() error {
if !e.ShouldStoreRoutes() {
return nil
}
// log write rate and write size
numRoutes := int64(len(e.controlRoutes))
for _, rs := range e.domains {
numRoutes += int64(len(rs))
}
e.writeRateMinute.update(numRoutes)
e.writeRateDay.update(numRoutes)
return e.storeRoutesFunc(&RouteInfo{
Control: e.controlRoutes,
Domains: e.domains,
Wildcards: e.wildcards,
})
}
// ClearRoutes removes all route state from the AppConnector.
func (e *AppConnector) ClearRoutes() error {
e.mu.Lock()
defer e.mu.Unlock()
e.controlRoutes = nil
e.domains = nil
e.wildcards = nil
return e.storeRoutesLocked()
}
// UpdateDomainsAndRoutes starts an asynchronous update of the configuration
// given the new domains and routes.
func (e *AppConnector) UpdateDomainsAndRoutes(domains []string, routes []netip.Prefix) {
e.queue.Add(func() {
// Add the new routes first.
e.updateRoutes(routes)
e.updateDomains(domains)
})
}
// UpdateDomains asynchronously replaces the current set of configured domains
// with the supplied set of domains. Domains must not contain a trailing dot,
// and should be lower case. If the domain contains a leading '*' label it
// matches all subdomains of a domain.
// UpdateDomains replaces the current set of configured domains with the
// supplied set of domains. Domains must not contain a trailing dot, and should
// be lower case. If the domain contains a leading '*' label it matches all
// subdomains of a domain.
func (e *AppConnector) UpdateDomains(domains []string) {
e.queue.Add(func() {
e.updateDomains(domains)
})
}
// Wait waits for the currently scheduled asynchronous configuration changes to
// complete.
func (e *AppConnector) Wait(ctx context.Context) {
e.queue.Wait(ctx)
}
func (e *AppConnector) updateDomains(domains []string) {
e.mu.Lock()
defer e.mu.Unlock()
@@ -275,80 +90,13 @@ func (e *AppConnector) updateDomains(domains []string) {
for _, wc := range e.wildcards {
if dnsname.HasSuffix(d, wc) {
e.domains[d] = addrs
delete(oldDomains, d)
break
}
}
}
// Everything left in oldDomains is a domain we're no longer tracking
// and if we are storing route info we can unadvertise the routes
if e.ShouldStoreRoutes() {
toRemove := []netip.Prefix{}
for _, addrs := range oldDomains {
for _, a := range addrs {
toRemove = append(toRemove, netip.PrefixFrom(a, a.BitLen()))
}
}
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes on domain removal: %v: %v: %v", xmaps.Keys(oldDomains), toRemove, err)
}
}
e.logf("handling domains: %v and wildcards: %v", xmaps.Keys(e.domains), e.wildcards)
}
// updateRoutes merges the supplied routes into the currently configured routes. The routes supplied
// by control for UpdateRoutes are supplemental to the routes discovered by DNS resolution, but are
// also more often whole ranges. UpdateRoutes will remove any single address routes that are now
// covered by new ranges.
func (e *AppConnector) updateRoutes(routes []netip.Prefix) {
e.mu.Lock()
defer e.mu.Unlock()
// If there was no change since the last update, no work to do.
if slices.Equal(e.controlRoutes, routes) {
return
}
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
e.logf("failed to advertise routes: %v: %v", routes, err)
return
}
var toRemove []netip.Prefix
// If we're storing routes and know e.controlRoutes is a good
// representation of what should be in AdvertisedRoutes we can stop
// advertising routes that used to be in e.controlRoutes but are not
// in routes.
if e.ShouldStoreRoutes() {
toRemove = routesWithout(e.controlRoutes, routes)
}
nextRoute:
for _, r := range routes {
for _, addr := range e.domains {
for _, a := range addr {
if r.Contains(a) && netip.PrefixFrom(a, a.BitLen()) != r {
pfx := netip.PrefixFrom(a, a.BitLen())
toRemove = append(toRemove, pfx)
continue nextRoute
}
}
}
}
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
e.logf("failed to unadvertise routes: %v: %v", toRemove, err)
}
e.controlRoutes = routes
if err := e.storeRoutesLocked(); err != nil {
e.logf("failed to store route info: %v", err)
}
}
// Domains returns the currently configured domain list.
func (e *AppConnector) Domains() views.Slice[string] {
e.mu.Lock()
@@ -384,16 +132,6 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
return
}
// cnameChain tracks a chain of CNAMEs for a given query in order to reverse
// a CNAME chain back to the original query for flattening. The keys are
// CNAME record targets, and the value is the name the record answers, so
// for www.example.com CNAME example.com, the map would contain
// ["example.com"] = "www.example.com".
var cnameChain map[string]string
// addressRecords is a list of address records found in the response.
var addressRecords map[string][]netip.Addr
for {
h, err := p.AnswerHeader()
if err == dnsmessage.ErrSectionDone {
@@ -409,188 +147,75 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
}
continue
}
switch h.Type {
case dnsmessage.TypeCNAME, dnsmessage.TypeA, dnsmessage.TypeAAAA:
default:
if h.Type != dnsmessage.TypeA && h.Type != dnsmessage.TypeAAAA {
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
domain := strings.TrimSuffix(strings.ToLower(h.Name.String()), ".")
domain := h.Name.String()
if len(domain) == 0 {
continue
return
}
domain = strings.TrimSuffix(domain, ".")
domain = strings.ToLower(domain)
e.logf("[v2] observed DNS response for %s", domain)
if h.Type == dnsmessage.TypeCNAME {
res, err := p.CNAMEResource()
if err != nil {
e.mu.Lock()
addrs, ok := e.domains[domain]
// match wildcard domains
if !ok {
for _, wc := range e.wildcards {
if dnsname.HasSuffix(domain, wc) {
e.domains[domain] = nil
ok = true
break
}
}
}
e.mu.Unlock()
if !ok {
if err := p.SkipAnswer(); err != nil {
return
}
cname := strings.TrimSuffix(strings.ToLower(res.CNAME.String()), ".")
if len(cname) == 0 {
continue
}
mak.Set(&cnameChain, cname, domain)
continue
}
var addr netip.Addr
switch h.Type {
case dnsmessage.TypeA:
r, err := p.AResource()
if err != nil {
return
}
addr := netip.AddrFrom4(r.A)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
addr = netip.AddrFrom4(r.A)
case dnsmessage.TypeAAAA:
r, err := p.AAAAResource()
if err != nil {
return
}
addr := netip.AddrFrom16(r.AAAA)
mak.Set(&addressRecords, domain, append(addressRecords[domain], addr))
addr = netip.AddrFrom16(r.AAAA)
default:
if err := p.SkipAnswer(); err != nil {
return
}
continue
}
}
e.mu.Lock()
defer e.mu.Unlock()
for domain, addrs := range addressRecords {
domain, isRouted := e.findRoutedDomainLocked(domain, cnameChain)
// domain and none of the CNAMEs in the chain are routed
if !isRouted {
if slices.Contains(addrs, addr) {
continue
}
// advertise each address we have learned for the routed domain, that
// was not already known.
var toAdvertise []netip.Prefix
for _, addr := range addrs {
if !e.isAddrKnownLocked(domain, addr) {
toAdvertise = append(toAdvertise, netip.PrefixFrom(addr, addr.BitLen()))
}
// TODO(raggi): check for existing prefixes
if err := e.routeAdvertiser.AdvertiseRoute(netip.PrefixFrom(addr, addr.BitLen())); err != nil {
e.logf("failed to advertise route for %v: %v", addr, err)
continue
}
e.logf("[v2] advertised route for %v: %v", domain, addr)
if len(toAdvertise) > 0 {
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
e.scheduleAdvertisement(domain, toAdvertise...)
}
}
}
// starting from the given domain that resolved to an address, find it, or any
// of the domains in the CNAME chain toward resolving it, that are routed
// domains, returning the routed domain name and a bool indicating whether a
// routed domain was found.
// e.mu must be held.
func (e *AppConnector) findRoutedDomainLocked(domain string, cnameChain map[string]string) (string, bool) {
var isRouted bool
for {
_, isRouted = e.domains[domain]
if isRouted {
break
}
// match wildcard domains
for _, wc := range e.wildcards {
if dnsname.HasSuffix(domain, wc) {
e.domains[domain] = nil
isRouted = true
break
}
}
next, ok := cnameChain[domain]
if !ok {
break
}
domain = next
}
return domain, isRouted
}
// isAddrKnownLocked returns true if the address is known to be associated with
// the given domain. Known domain tables are updated for covered routes to speed
// up future matches.
// e.mu must be held.
func (e *AppConnector) isAddrKnownLocked(domain string, addr netip.Addr) bool {
if e.hasDomainAddrLocked(domain, addr) {
return true
}
for _, route := range e.controlRoutes {
if route.Contains(addr) {
// record the new address associated with the domain for faster matching in subsequent
// requests and for diagnostic records.
e.addDomainAddrLocked(domain, addr)
return true
}
}
return false
}
// scheduleAdvertisement schedules an advertisement of the given address
// associated with the given domain.
func (e *AppConnector) scheduleAdvertisement(domain string, routes ...netip.Prefix) {
e.queue.Add(func() {
if err := e.routeAdvertiser.AdvertiseRoute(routes...); err != nil {
e.logf("failed to advertise routes for %s: %v: %v", domain, routes, err)
return
}
e.mu.Lock()
defer e.mu.Unlock()
for _, route := range routes {
if !route.IsSingleIP() {
continue
}
addr := route.Addr()
if !e.hasDomainAddrLocked(domain, addr) {
e.addDomainAddrLocked(domain, addr)
e.logf("[v2] advertised route for %v: %v", domain, addr)
}
}
if err := e.storeRoutesLocked(); err != nil {
e.logf("failed to store route info: %v", err)
}
})
}
// hasDomainAddrLocked returns true if the address has been observed in a
// resolution of domain.
func (e *AppConnector) hasDomainAddrLocked(domain string, addr netip.Addr) bool {
_, ok := slices.BinarySearchFunc(e.domains[domain], addr, compareAddr)
return ok
}
// addDomainAddrLocked adds the address to the list of addresses resolved for
// domain and ensures the list remains sorted. Does not attempt to deduplicate.
func (e *AppConnector) addDomainAddrLocked(domain string, addr netip.Addr) {
e.domains[domain] = append(e.domains[domain], addr)
slices.SortFunc(e.domains[domain], compareAddr)
}
func compareAddr(l, r netip.Addr) int {
return l.Compare(r)
}
// routesWithout returns a without b where a and b
// are unsorted slices of netip.Prefix
func routesWithout(a, b []netip.Prefix) []netip.Prefix {
m := make(map[netip.Prefix]bool, len(b))
for _, p := range b {
m[p] = true
e.domains[domain] = append(addrs, addr)
e.mu.Unlock()
}
return slicesx.Filter(make([]netip.Prefix, 0, len(a)), a, func(p netip.Prefix) bool {
return !m[p]
})
}

View File

@@ -4,254 +4,110 @@
package appc
import (
"context"
"net/netip"
"reflect"
"slices"
"testing"
"time"
xmaps "golang.org/x/exp/maps"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/appc/appctest"
"tailscale.com/tstest"
"tailscale.com/util/clientmetric"
"tailscale.com/util/mak"
"tailscale.com/util/must"
)
func fakeStoreRoutes(*RouteInfo) error { return nil }
func TestUpdateDomains(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, nil, nil)
}
a.UpdateDomains([]string{"example.com"})
a.Wait(ctx)
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
addr := netip.MustParseAddr("192.0.0.8")
a.domains["example.com"] = append(a.domains["example.com"], addr)
a.UpdateDomains([]string{"example.com"})
a.Wait(ctx)
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// domains are explicitly downcased on set.
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
a.Wait(ctx)
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
a := NewAppConnector(t.Logf, nil)
a.UpdateDomains([]string{"example.com"})
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestUpdateRoutes(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
a.updateDomains([]string{"*.example.com"})
addr := netip.MustParseAddr("192.0.0.8")
a.domains["example.com"] = append(a.domains["example.com"], addr)
a.UpdateDomains([]string{"example.com"})
// This route should be collapsed into the range
a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1"))
a.Wait(ctx)
if !slices.Equal(rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) {
t.Fatalf("got %v, want %v", rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
}
// This route should not be collapsed or removed
a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1"))
a.Wait(ctx)
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")}
a.updateRoutes(routes)
slices.SortFunc(rc.Routes(), prefixCompare)
rc.SetRoutes(slices.Compact(rc.Routes()))
slices.SortFunc(routes, prefixCompare)
// Ensure that the non-matching /32 is preserved, even though it's in the domains table.
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
t.Errorf("added routes: got %v, want %v", rc.Routes(), routes)
}
// Ensure that the contained /32 is removed, replaced by the /24.
wantRemoved := []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}
if !slices.EqualFunc(rc.RemovedRoutes(), wantRemoved, prefixEqual) {
t.Fatalf("unexpected removed routes: %v", rc.RemovedRoutes())
}
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
rc := &appctest.RouteCollector{}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
mak.Set(&a.domains, "example.com", []netip.Addr{netip.MustParseAddr("192.0.2.1")})
rc.SetRoutes([]netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}
a.updateRoutes(routes)
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
t.Fatalf("got %v, want %v", rc.Routes(), routes)
}
// domains are explicitly downcased on set.
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestDomainRoutes(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
rc := &appctest.RouteCollector{}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
a.updateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
a.Wait(context.Background())
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
a.UpdateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
want := map[string][]netip.Addr{
"example.com": {netip.MustParseAddr("192.0.0.8")},
}
want := map[string][]netip.Addr{
"example.com": {netip.MustParseAddr("192.0.0.8")},
}
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
t.Fatalf("DomainRoutes: got %v, want %v", got, want)
}
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
t.Fatalf("DomainRoutes: got %v, want %v", got, want)
}
}
func TestObserveDNSResponse(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
// a has no domains configured, so it should not advertise any routes
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if got, want := rc.Routes(), ([]netip.Prefix)(nil); !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// a has no domains configured, so it should not advertise any routes
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if got, want := rc.routes, ([]netip.Prefix)(nil); !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
a.updateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
a.Wait(ctx)
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
a.UpdateDomains([]string{"example.com"})
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// a CNAME record chain should result in a route being added if the chain
// matches a routed domain.
a.updateDomains([]string{"www.example.com", "example.com"})
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com."))
a.Wait(ctx)
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.9/32"))
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
// a CNAME record chain should result in a route being added if the chain
// even if only found in the middle of the chain
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org."))
a.Wait(ctx)
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.10/32"))
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
if got, want := rc.routes, wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
a.Wait(ctx)
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
t.Errorf("got %v; want %v", got, want)
}
// don't re-advertise routes that have already been advertised
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
a.Wait(ctx)
if !slices.Equal(rc.Routes(), wantRoutes) {
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
}
// don't advertise addresses that are already in a control provided route
pfx := netip.MustParsePrefix("192.0.2.0/24")
a.updateRoutes([]netip.Prefix{pfx})
wantRoutes = append(wantRoutes, pfx)
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1"))
a.Wait(ctx)
if !slices.Equal(rc.Routes(), wantRoutes) {
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
}
if !slices.Contains(a.domains["example.com"], netip.MustParseAddr("192.0.2.1")) {
t.Errorf("missing %v from %v", "192.0.2.1", a.domains["exmaple.com"])
}
// don't re-advertise routes that have already been advertised
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
if !slices.Equal(rc.routes, wantRoutes) {
t.Errorf("got %v; want %v", rc.routes, wantRoutes)
}
}
func TestWildcardDomains(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
rc := &routeCollector{}
a := NewAppConnector(t.Logf, rc)
a.updateDomains([]string{"*.example.com"})
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
a.Wait(ctx)
if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
t.Errorf("routes: got %v; want %v", got, want)
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
a.UpdateDomains([]string{"*.example.com"})
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
if got, want := rc.routes, []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
t.Errorf("routes: got %v; want %v", got, want)
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
a.updateDomains([]string{"*.example.com", "example.com"})
if _, ok := a.domains["foo.example.com"]; !ok {
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard")
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
a.UpdateDomains([]string{"*.example.com", "example.com"})
if _, ok := a.domains["foo.example.com"]; !ok {
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard")
}
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
t.Errorf("wildcards: got %v; want %v", got, want)
}
// There was an early regression where the wildcard domain was added repeatedly, this guards against that.
a.updateDomains([]string{"*.example.com", "example.com"})
if len(a.wildcards) != 1 {
t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
}
// There was an early regression where the wildcard domain was added repeatedly, this guards against that.
a.UpdateDomains([]string{"*.example.com", "example.com"})
if len(a.wildcards) != 1 {
t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
}
}
@@ -292,313 +148,15 @@ func dnsResponse(domain, address string) []byte {
return must.Get(b.Finish())
}
func dnsCNAMEResponse(address string, domains ...string) []byte {
addr := netip.MustParseAddr(address)
b := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
b.EnableCompression()
b.StartAnswers()
if len(domains) >= 2 {
for i, domain := range domains[:len(domains)-1] {
b.CNAMEResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeCNAME,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.CNAMEResource{
CNAME: dnsmessage.MustNewName(domains[i+1]),
},
)
}
}
domain := domains[len(domains)-1]
switch addr.BitLen() {
case 32:
b.AResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AResource{
A: addr.As4(),
},
)
case 128:
b.AAAAResource(
dnsmessage.ResourceHeader{
Name: dnsmessage.MustNewName(domain),
Type: dnsmessage.TypeAAAA,
Class: dnsmessage.ClassINET,
TTL: 0,
},
dnsmessage.AAAAResource{
AAAA: addr.As16(),
},
)
default:
panic("invalid address length")
}
return must.Get(b.Finish())
// routeCollector is a test helper that collects the list of routes advertised
type routeCollector struct {
routes []netip.Prefix
}
func prefixEqual(a, b netip.Prefix) bool {
return a == b
}
func prefixCompare(a, b netip.Prefix) int {
if a.Addr().Compare(b.Addr()) == 0 {
return a.Bits() - b.Bits()
}
return a.Addr().Compare(b.Addr())
}
func prefixes(in ...string) []netip.Prefix {
toRet := make([]netip.Prefix, len(in))
for i, s := range in {
toRet[i] = netip.MustParsePrefix(s)
}
return toRet
}
func TestUpdateRouteRouteRemoval(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
if !slices.Equal(routes, rc.Routes()) {
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
}
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
}
}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
// nothing has yet been advertised
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.2/32"))
a.Wait(ctx)
// the routes passed to UpdateDomainsAndRoutes have been advertised
assertRoutes("simple update", prefixes("1.2.3.1/32", "1.2.3.2/32"), []netip.Prefix{})
// one route the same, one different
a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.3/32"))
a.Wait(ctx)
// old behavior: routes are not removed, resulting routes are both old and new
// (we have dupe 1.2.3.1 routes because the test RouteAdvertiser doesn't have the deduplication
// the real one does)
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.1/32", "1.2.3.3/32")
wantRemovedRoutes := []netip.Prefix{}
if shouldStore {
// new behavior: routes are removed, resulting routes are new only
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.1/32", "1.2.3.3/32")
wantRemovedRoutes = prefixes("1.2.3.2/32")
}
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
}
}
func TestUpdateDomainRouteRemoval(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
if !slices.Equal(routes, rc.Routes()) {
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
}
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
}
}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
a.UpdateDomainsAndRoutes([]string{"a.example.com", "b.example.com"}, []netip.Prefix{})
a.Wait(ctx)
// adding domains doesn't immediately cause any routes to be advertised
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.3"))
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.4"))
a.Wait(ctx)
// observing dns responses causes routes to be advertised
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{})
a.Wait(ctx)
// old behavior, routes are not removed
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32")
wantRemovedRoutes := []netip.Prefix{}
if shouldStore {
// new behavior, routes are removed for b.example.com
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32")
wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32")
}
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
}
}
func TestUpdateWildcardRouteRemoval(t *testing.T) {
for _, shouldStore := range []bool{false, true} {
ctx := context.Background()
rc := &appctest.RouteCollector{}
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
if !slices.Equal(routes, rc.Routes()) {
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
}
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
}
}
var a *AppConnector
if shouldStore {
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
} else {
a = NewAppConnector(t.Logf, rc, nil, nil)
}
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
a.UpdateDomainsAndRoutes([]string{"a.example.com", "*.b.example.com"}, []netip.Prefix{})
a.Wait(ctx)
// adding domains doesn't immediately cause any routes to be advertised
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
a.ObserveDNSResponse(dnsResponse("1.b.example.com.", "1.2.3.3"))
a.ObserveDNSResponse(dnsResponse("2.b.example.com.", "1.2.3.4"))
a.Wait(ctx)
// observing dns responses causes routes to be advertised
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{})
a.Wait(ctx)
// old behavior, routes are not removed
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32")
wantRemovedRoutes := []netip.Prefix{}
if shouldStore {
// new behavior, routes are removed for *.b.example.com
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32")
wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32")
}
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
}
}
func TestRoutesWithout(t *testing.T) {
assert := func(msg string, got, want []netip.Prefix) {
if !slices.Equal(want, got) {
t.Errorf("%s: want %v, got %v", msg, want, got)
}
}
assert("empty routes", routesWithout([]netip.Prefix{}, []netip.Prefix{}), []netip.Prefix{})
assert("a empty", routesWithout([]netip.Prefix{}, prefixes("1.1.1.1/32", "1.1.1.2/32")), []netip.Prefix{})
assert("b empty", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), []netip.Prefix{}), prefixes("1.1.1.1/32", "1.1.1.2/32"))
assert("no overlap", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.3/32", "1.1.1.4/32")), prefixes("1.1.1.1/32", "1.1.1.2/32"))
assert("a has fewer", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32")), []netip.Prefix{})
assert("a has more", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32"), prefixes("1.1.1.1/32", "1.1.1.3/32")), prefixes("1.1.1.2/32", "1.1.1.4/32"))
}
func TestRateLogger(t *testing.T) {
clock := tstest.Clock{}
wasCalled := false
rl := newRateLogger(func() time.Time { return clock.Now() }, 1*time.Second, func(count int64, _ time.Time, _ int64) {
if count != 3 {
t.Fatalf("count for prev period: got %d, want 3", count)
}
wasCalled = true
})
for i := 0; i < 3; i++ {
clock.Advance(1 * time.Millisecond)
rl.update(0)
if wasCalled {
t.Fatalf("wasCalled: got true, want false")
}
}
clock.Advance(1 * time.Second)
rl.update(0)
if !wasCalled {
t.Fatalf("wasCalled: got false, want true")
}
wasCalled = false
rl = newRateLogger(func() time.Time { return clock.Now() }, 1*time.Hour, func(count int64, _ time.Time, _ int64) {
if count != 3 {
t.Fatalf("count for prev period: got %d, want 3", count)
}
wasCalled = true
})
for i := 0; i < 3; i++ {
clock.Advance(1 * time.Minute)
rl.update(0)
if wasCalled {
t.Fatalf("wasCalled: got true, want false")
}
}
clock.Advance(1 * time.Hour)
rl.update(0)
if !wasCalled {
t.Fatalf("wasCalled: got false, want true")
}
}
func TestRouteStoreMetrics(t *testing.T) {
metricStoreRoutes(1, 1)
metricStoreRoutes(1, 1) // the 1 buckets value should be 2
metricStoreRoutes(5, 5) // the 5 buckets value should be 1
metricStoreRoutes(6, 6) // the 10 buckets value should be 1
metricStoreRoutes(10001, 10001) // the over buckets value should be 1
wanted := map[string]int64{
"appc_store_routes_n_routes_1": 2,
"appc_store_routes_rate_1": 2,
"appc_store_routes_n_routes_5": 1,
"appc_store_routes_rate_5": 1,
"appc_store_routes_n_routes_10": 1,
"appc_store_routes_rate_10": 1,
"appc_store_routes_n_routes_over": 1,
"appc_store_routes_rate_over": 1,
}
for _, x := range clientmetric.Metrics() {
if x.Value() != wanted[x.Name()] {
t.Errorf("%s: want: %d, got: %d", x.Name(), wanted[x.Name()], x.Value())
}
}
}
func TestMetricBucketsAreSorted(t *testing.T) {
if !slices.IsSorted(metricStoreRoutesRateBuckets) {
t.Errorf("metricStoreRoutesRateBuckets must be in order")
}
if !slices.IsSorted(metricStoreRoutesNBuckets) {
t.Errorf("metricStoreRoutesNBuckets must be in order")
}
// routeCollector implements RouteAdvertiser
var _ RouteAdvertiser = (*routeCollector)(nil)
func (rc *routeCollector) AdvertiseRoute(pfx netip.Prefix) error {
rc.routes = append(rc.routes, pfx)
return nil
}

View File

@@ -1,50 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package appctest contains code to help test App Connectors.
package appctest
import (
"net/netip"
"slices"
)
// RouteCollector is a test helper that collects the list of routes advertised
type RouteCollector struct {
routes []netip.Prefix
removedRoutes []netip.Prefix
}
func (rc *RouteCollector) AdvertiseRoute(pfx ...netip.Prefix) error {
rc.routes = append(rc.routes, pfx...)
return nil
}
func (rc *RouteCollector) UnadvertiseRoute(toRemove ...netip.Prefix) error {
routes := rc.routes
rc.routes = rc.routes[:0]
for _, r := range routes {
if !slices.Contains(toRemove, r) {
rc.routes = append(rc.routes, r)
} else {
rc.removedRoutes = append(rc.removedRoutes, r)
}
}
return nil
}
// RemovedRoutes returns the list of routes that were removed.
func (rc *RouteCollector) RemovedRoutes() []netip.Prefix {
return rc.removedRoutes
}
// Routes returns the ordered list of routes that were added, including
// possible duplicates.
func (rc *RouteCollector) Routes() []netip.Prefix {
return rc.routes
}
func (rc *RouteCollector) SetRoutes(routes []netip.Prefix) error {
rc.routes = routes
return nil
}

View File

@@ -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

View File

@@ -1,28 +1,37 @@
#!/usr/bin/env sh
#
# This script builds Tailscale container images using
# github.com/tailscale/mkctr.
# By default the images will be tagged with the current version and git
# hash of this repository as produced by ./cmd/mkversion.
# This is the image build mechanim used to build the official Tailscale
# container images.
# Runs `go build` with flags configured for docker distribution. All
# it does differently from `go build` is burn git commit and version
# information into the binaries inside docker, so that we can track down user
# issues.
#
############################################################################
#
# WARNING: Tailscale is not yet officially supported in container
# environments, such as Docker and Kubernetes. Though it should work, we
# don't regularly test it, and we know there are some feature limitations.
#
# See current bugs tagged "containers":
# https://github.com/tailscale/tailscale/labels/containers
#
############################################################################
set -eu
# Use the "go" binary from the "tool" directory (which is github.com/tailscale/go)
export PATH="$PWD"/tool:"$PATH"
export PATH=$PWD/tool:$PATH
eval "$(./build_dist.sh shellvars)"
eval $(./build_dist.sh shellvars)
DEFAULT_TARGET="client"
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
DEFAULT_BASE="tailscale/alpine-base:3.18"
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)
@@ -39,10 +48,8 @@ case "$TARGET" in
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
--base="${BASE}" \
--tags="${TAGS}" \
--gotags="ts_kube,ts_package_container" \
--repos="${REPOS}" \
--push="${PUSH}" \
--target="${PLATFORM}" \
/usr/local/bin/containerboot
;;
operator)
@@ -58,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

View File

@@ -37,16 +37,6 @@ type ACLTest struct {
Allow []string `json:"allow,omitempty"` // old name for accept
}
// NodeAttrGrant defines additional string attributes that apply to specific devices.
type NodeAttrGrant struct {
// Target specifies which nodes the attributes apply to. The nodes can be a
// tag (tag:server), user (alice@example.com), group (group:kids), or *.
Target []string `json:"target,omitempty"`
// Attr are the attributes to set on Target(s).
Attr []string `json:"attr,omitempty"`
}
// ACLDetails contains all the details for an ACL.
type ACLDetails struct {
Tests []ACLTest `json:"tests,omitempty"`
@@ -54,7 +44,6 @@ type ACLDetails struct {
Groups map[string][]string `json:"groups,omitempty"`
TagOwners map[string][]string `json:"tagowners,omitempty"`
Hosts map[string]string `json:"hosts,omitempty"`
NodeAttrs []NodeAttrGrant `json:"nodeAttrs,omitempty"`
}
// ACL contains an ACLDetails and metadata.
@@ -161,12 +150,7 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
// ACLTestFailureSummary specifies the JSON format sent to the
// JavaScript client to be rendered in the HTML.
type ACLTestFailureSummary struct {
// User is the source ("src") value of the ACL test that failed.
// The name "user" is a legacy holdover from the original naming and
// is kept for compatibility but it may also contain any value
// that's valid in a ACL test "src" field.
User string `json:"user,omitempty"`
User string `json:"user,omitempty"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
@@ -286,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
@@ -301,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
@@ -314,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) {
@@ -377,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
}
@@ -406,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
}
@@ -432,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
}
@@ -458,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
}

View File

@@ -49,11 +49,3 @@ type ReloadConfigResponse struct {
Reloaded bool // whether the config was reloaded
Err string // any error message
}
// ExitNodeSuggestionResponse is the response to a LocalAPI suggest-exit-node GET request.
// It returns the StableNodeID, name, and location of a suggested exit node for the client making the request.
type ExitNodeSuggestionResponse struct {
ID tailcfg.StableNodeID
Name string
Location tailcfg.LocationView `json:",omitempty"`
}

View File

@@ -10,7 +10,6 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
@@ -40,7 +39,6 @@ type Device struct {
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
Addresses []string `json:"addresses"`
DeviceID string `json:"id"`
NodeID string `json:"nodeId"`
User string `json:"user"`
Name string `json:"name"`
Hostname string `json:"hostname"`
@@ -73,17 +71,6 @@ type Device struct {
AdvertisedRoutes []string `json:"advertisedRoutes"` // Empty for external devices.
ClientConnectivity *ClientConnectivity `json:"clientConnectivity"`
// PostureIdentity contains extra identifiers collected from the device when
// the tailnet has the device posture identification features enabled. If
// Tailscale have attempted to collect this from the device but it has not
// opted in, PostureIdentity will have Disabled=true.
PostureIdentity *DevicePostureIdentity `json:"postureIdentity"`
}
type DevicePostureIdentity struct {
Disabled bool `json:"disabled,omitempty"`
SerialNumbers []string `json:"serialNumbers,omitempty"`
}
// DeviceFieldsOpts determines which fields should be returned in the response.
@@ -215,9 +202,6 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
if err != nil {
return err
}
log.Printf("RESP: %di, path: %s", resp.StatusCode, path)
// If status code was not successful, return the error.
// TODO: Change the check for the StatusCode to include other 2XX success codes.
if resp.StatusCode != http.StatusOK {

View File

@@ -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.ConnectContext(ctx, lc.socket())
s := safesocket.DefaultConnectionStrategy(lc.socket())
return safesocket.Connect(s)
}
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
@@ -253,16 +253,11 @@ func (lc *LocalClient) sendWithHeaders(
}
if res.StatusCode != wantStatus {
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
return nil, nil, bestError(err, slurp)
}
return slurp, res.Header, nil
}
type httpStatusError struct {
error
HTTPStatus int
}
func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) {
return lc.send(ctx, "GET", path, 200, nil)
}
@@ -283,50 +278,9 @@ func decodeJSON[T any](b []byte) (ret T, err error) {
}
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
//
// If not found, the error is ErrPeerNotFound.
//
// For connections proxied by tailscaled, this looks up the owner of the given
// address as TCP first, falling back to UDP; if you want to only check a
// specific address family, use WhoIsProto.
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// ErrPeerNotFound is returned by WhoIs and WhoIsNodeKey when a peer is not found.
var ErrPeerNotFound = errors.New("peer not found")
// WhoIsNodeKey returns the owner of the given wireguard public key.
//
// If not found, the error is ErrPeerNotFound.
func (lc *LocalClient) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(key.String()))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
}
// WhoIsProto returns the owner of the remoteAddr, which must be an IP or
// IP:port, for the given protocol (tcp or udp).
//
// If not found, the error is ErrPeerNotFound.
func (lc *LocalClient) WhoIsProto(ctx context.Context, proto, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/whois?proto="+url.QueryEscape(proto)+"&addr="+url.QueryEscape(remoteAddr))
if err != nil {
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
return nil, ErrPeerNotFound
}
return nil, err
}
return decodeJSON[*apitype.WhoIsResponse](body)
@@ -526,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))
@@ -745,27 +699,6 @@ func (lc *LocalClient) CheckUDPGROForwarding(ctx context.Context) error {
return nil
}
// SetUDPGROForwarding enables UDP GRO forwarding for the main interface of this
// node. This can be done to improve performance of tailnet nodes acting as exit
// nodes or subnet routers.
// See https://tailscale.com/kb/1320/performance-best-practices#linux-optimizations-for-subnet-routers-and-exit-nodes
func (lc *LocalClient) SetUDPGROForwarding(ctx context.Context) error {
body, err := lc.get200(ctx, "/localapi/v0/set-udp-gro-forwarding")
if err != nil {
return err
}
var jres struct {
Warning string
}
if err := json.Unmarshal(body, &jres); err != nil {
return fmt.Errorf("invalid JSON from set-udp-gro-forwarding: %w", err)
}
if jres.Warning != "" {
return errors.New(jres.Warning)
}
return nil
}
// CheckPrefs validates the provided preferences, without making any changes.
//
// The CLI uses this before a Start call to fail fast if the preferences won't
@@ -845,17 +778,6 @@ func (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error {
//
// The ctx is only used for the duration of the call, not the lifetime of the net.Conn.
func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
return lc.UserDial(ctx, "tcp", host, port)
}
// UserDial connects to the host's port via Tailscale for the given network.
//
// The host may be a base DNS name (resolved from the netmap inside tailscaled),
// a FQDN, or an IP address.
//
// The ctx is only used for the duration of the call, not the lifetime of the
// net.Conn.
func (lc *LocalClient) UserDial(ctx context.Context, network, host string, port uint16) (net.Conn, error) {
connCh := make(chan net.Conn, 1)
trace := httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
@@ -868,11 +790,10 @@ func (lc *LocalClient) UserDial(ctx context.Context, network, host string, port
return nil, err
}
req.Header = http.Header{
"Upgrade": []string{"ts-dial"},
"Connection": []string{"upgrade"},
"Dial-Host": []string{host},
"Dial-Port": []string{fmt.Sprint(port)},
"Dial-Network": []string{network},
"Upgrade": []string{"ts-dial"},
"Connection": []string{"upgrade"},
"Dial-Host": []string{host},
"Dial-Port": []string{fmt.Sprint(port)},
}
res, err := lc.DoLocalRequest(req)
if err != nil {
@@ -933,20 +854,7 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e
//
// API maturity: this is considered a stable API.
func (lc *LocalClient) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return lc.CertPairWithValidity(ctx, domain, 0)
}
// CertPairWithValidity returns a cert and private key for the provided DNS
// domain.
//
// It returns a cached certificate from disk if it's still valid.
// When minValidity is non-zero, the returned certificate will be valid for at
// least the given duration, if permitted by the CA. If the certificate is
// valid, but for less than minValidity, it will be synchronously renewed.
//
// API maturity: this is considered a stable API.
func (lc *LocalClient) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
res, err := lc.send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil)
if err != nil {
return nil, nil, err
}
@@ -1424,15 +1332,6 @@ func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode strin
return decodeJSON[*ipnstate.DebugDERPRegionReport](body)
}
// DebugPacketFilterRules returns the packet filter rules for the current device.
func (lc *LocalClient) DebugPacketFilterRules(ctx context.Context) ([]tailcfg.FilterRule, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/debug-packet-filter-rules", 200, nil)
if err != nil {
return nil, fmt.Errorf("error %w: %s", err, body)
}
return decodeJSON[[]tailcfg.FilterRule](body)
}
// DebugSetExpireIn marks the current node key to expire in d.
//
// This is meant primarily for debug and testing.
@@ -1510,66 +1409,6 @@ func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion,
return &cv, nil
}
// SetUseExitNode toggles the use of an exit node on or off.
// To turn it on, there must have been a previously used exit node.
// The most previously used one is reused.
// This is a convenience method for GUIs. To select an actual one, update the prefs.
func (lc *LocalClient) SetUseExitNode(ctx context.Context, on bool) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/set-use-exit-node-enabled?enabled="+strconv.FormatBool(on), http.StatusOK, nil)
return err
}
// DriveSetServerAddr instructs Taildrive to use the server at addr to access
// the filesystem. This is used on platforms like Windows and MacOS to let
// Taildrive know to use the file server running in the GUI app.
func (lc *LocalClient) DriveSetServerAddr(ctx context.Context, addr string) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/fileserver-address", http.StatusCreated, strings.NewReader(addr))
return err
}
// DriveShareSet adds or updates the given share in the list of shares that
// Taildrive will serve to remote nodes. If a share with the same name already
// exists, the existing share is replaced/updated.
func (lc *LocalClient) DriveShareSet(ctx context.Context, share *drive.Share) error {
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/shares", http.StatusCreated, jsonBody(share))
return err
}
// DriveShareRemove removes the share with the given name from the list of
// shares that Taildrive will serve to remote nodes.
func (lc *LocalClient) DriveShareRemove(ctx context.Context, name string) error {
_, err := lc.send(
ctx,
"DELETE",
"/localapi/v0/drive/shares",
http.StatusNoContent,
strings.NewReader(name))
return err
}
// DriveShareRename renames the share from old to new name.
func (lc *LocalClient) DriveShareRename(ctx context.Context, oldName, newName string) error {
_, err := lc.send(
ctx,
"POST",
"/localapi/v0/drive/shares",
http.StatusNoContent,
jsonBody([2]string{oldName, newName}))
return err
}
// DriveShareList returns the list of shares that drive is currently serving
// to remote nodes.
func (lc *LocalClient) DriveShareList(ctx context.Context) ([]*drive.Share, error) {
result, err := lc.get200(ctx, "/localapi/v0/drive/shares")
if err != nil {
return nil, err
}
var shares []*drive.Share
err = json.Unmarshal(result, &shares)
return shares, err
}
// IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus.
// It's returned by LocalClient.WatchIPNBus.
//
@@ -1606,12 +1445,3 @@ func (w *IPNBusWatcher) Next() (ipn.Notify, error) {
}
return n, nil
}
// SuggestExitNode requests an exit node suggestion and returns the exit node's details.
func (lc *LocalClient) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/suggest-exit-node")
if err != nil {
return apitype.ExitNodeSuggestionResponse{}, err
}
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
}

View File

@@ -5,16 +5,7 @@
package tailscale
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"tailscale.com/tstest/deptest"
"tailscale.com/types/key"
)
import "testing"
func TestGetServeConfigFromJSON(t *testing.T) {
sc, err := getServeConfigFromJSON([]byte("null"))
@@ -34,41 +25,3 @@ func TestGetServeConfigFromJSON(t *testing.T) {
t.Errorf("want non-nil TCP for object")
}
}
func TestWhoIsPeerNotFound(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer ts.Close()
lc := &LocalClient{
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
var std net.Dialer
return std.DialContext(ctx, network, ts.Listener.Addr().(*net.TCPAddr).String())
},
}
var k key.NodePublic
if err := k.UnmarshalText([]byte("nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261")); err != nil {
t.Fatal(err)
}
res, err := lc.WhoIsNodeKey(context.Background(), k)
if err != ErrPeerNotFound {
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
}
res, err = lc.WhoIs(context.Background(), "1.2.3.4:5678")
if err != ErrPeerNotFound {
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
}
}
func TestDeps(t *testing.T) {
deptest.DepChecker{
BadDeps: map[string]string{
// Make sure we don't again accidentally bring in a dependency on
// drive or its transitive dependencies
"testing": "do not use testing package in production code",
"tailscale.com/drive/driveimpl": "https://github.com/tailscale/tailscale/pull/10631",
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
},
}.Check(t)
}

View File

@@ -4,7 +4,6 @@
package web
import (
"io"
"io/fs"
"log"
"net/http"
@@ -14,13 +13,10 @@ import (
"os/exec"
"path/filepath"
"strings"
"time"
prebuilt "github.com/tailscale/web-client-prebuilt"
)
var start = time.Now()
func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
if devMode {
// When in dev mode, proxy asset requests to the Vite dev server.
@@ -29,48 +25,19 @@ func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
}
fsys := prebuilt.FS()
fileserver := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
f, err := openPrecompressedFile(w, r, path, fsys)
if err != nil {
// Rewrite request to just fetch index.html and let
_, err := fs.Stat(fsys, strings.TrimPrefix(r.URL.Path, "/"))
if os.IsNotExist(err) {
// rewrite request to just fetch /index.html and let
// the frontend router handle it.
r = r.Clone(r.Context())
path = "index.html"
f, err = openPrecompressedFile(w, r, path, fsys)
r.URL.Path = "/"
}
if f == nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
defer f.Close()
// fs.File does not claim to implement Seeker, but in practice it does.
fSeeker, ok := f.(io.ReadSeeker)
if !ok {
http.Error(w, "Not seekable", http.StatusInternalServerError)
return
}
if strings.HasPrefix(path, "assets/") {
// Aggressively cache static assets, since we cache-bust our assets with
// hashed filenames.
w.Header().Set("Cache-Control", "public, max-age=31535996")
w.Header().Set("Vary", "Accept-Encoding")
}
http.ServeContent(w, r, path, start, fSeeker)
fileserver.ServeHTTP(w, r)
}), nil
}
func openPrecompressedFile(w http.ResponseWriter, r *http.Request, path string, fs fs.FS) (fs.File, error) {
if f, err := fs.Open(path + ".gz"); err == nil {
w.Header().Set("Content-Encoding", "gzip")
return f, nil
}
return fs.Open(path) // fallback
}
// startDevServer starts the JS dev server that does on-demand rebuilding
// and serving of web client JS and CSS resources.
func startDevServer() (cleanup func()) {

View File

@@ -8,15 +8,10 @@ import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
)
@@ -105,54 +100,58 @@ var (
//
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, *ipnstate.Status, error) {
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
switch {
case whoIsErr != nil:
return nil, nil, status, errNotUsingTailscale
return nil, nil, errNotUsingTailscale
case statusErr != nil:
return nil, whoIs, nil, statusErr
return nil, whoIs, statusErr
case status.Self == nil:
return nil, whoIs, status, errors.New("missing self node in tailscale status")
return nil, whoIs, errors.New("missing self node in tailscale status")
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
return nil, whoIs, status, errTaggedLocalSource
return nil, whoIs, errTaggedLocalSource
case whoIs.Node.IsTagged():
return nil, whoIs, status, errTaggedRemoteSource
return nil, whoIs, errTaggedRemoteSource
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
return nil, whoIs, status, errNotOwner
return nil, whoIs, errNotOwner
}
srcNode := whoIs.Node.ID
srcUser := whoIs.UserProfile.ID
cookie, err := r.Cookie(sessionCookieName)
if errors.Is(err, http.ErrNoCookie) {
return nil, whoIs, status, errNoSession
return nil, whoIs, errNoSession
} else if err != nil {
return nil, whoIs, status, err
return nil, whoIs, err
}
v, ok := s.browserSessions.Load(cookie.Value)
if !ok {
return nil, whoIs, status, errNoSession
return nil, whoIs, errNoSession
}
session := v.(*browserSession)
if session.SrcNode != srcNode || session.SrcUser != srcUser {
// In this case the browser cookie is associated with another tailscale node.
// Maybe the source browser's machine was logged out and then back in as a different node.
// Return errNoSession because there is no session for this user.
return nil, whoIs, status, errNoSession
return nil, whoIs, errNoSession
} else if session.isExpired(s.timeNow()) {
// Session expired, remove from session map and return errNoSession.
s.browserSessions.Delete(session.ID)
return nil, whoIs, status, errNoSession
return nil, whoIs, errNoSession
}
return session, whoIs, status, nil
return session, whoIs, nil
}
// newSession creates a new session associated with the given source user/node,
// 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) {
a, err := s.newAuthURL(ctx, src.Node.ID)
if err != nil {
return nil, err
}
sid, err := s.newSessionID()
if err != nil {
return nil, err
@@ -161,44 +160,14 @@ func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*b
ID: sid,
SrcNode: src.Node.ID,
SrcUser: src.UserProfile.ID,
AuthID: a.ID,
AuthURL: a.URL,
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.
@@ -223,7 +192,7 @@ func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) err
func (s *Server) newSessionID() (string, error) {
raw := make([]byte, 16)
for range 5 {
for i := 0; i < 5; i++ {
if _, err := rand.Read(raw); err != nil {
return "", err
}
@@ -234,106 +203,3 @@ func (s *Server) newSessionID() (string, error) {
}
return "", errors.New("too many collisions generating new session; please refresh page")
}
// peerCapabilities holds information about what a source
// peer is allowed to edit via the web UI.
//
// map value is true if the peer can edit the given feature.
// Only capFeatures included in validCaps will be included.
type peerCapabilities map[capFeature]bool
// canEdit is true if the peerCapabilities grant edit access
// to the given feature.
func (p peerCapabilities) canEdit(feature capFeature) bool {
if p == nil {
return false
}
if p[capFeatureAll] {
return true
}
return p[feature]
}
// isEmpty is true if p is either nil or has no capabilities
// with value true.
func (p peerCapabilities) isEmpty() bool {
if p == nil {
return true
}
for _, v := range p {
if v == true {
return false
}
}
return true
}
type capFeature string
const (
// The following values should not be edited.
// New caps can be added, but existing ones should not be changed,
// as these exact values are used by users in tailnet policy files.
//
// IMPORTANT: When adding a new cap, also update validCaps slice below.
capFeatureAll capFeature = "*" // grants peer management of all features
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management
capFeatureExitNodes capFeature = "exitnodes" // grants peer ability to advertise-as and use exit nodes
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
)
// validCaps contains the list of valid capabilities used in the web client.
// Any capabilities included in a peer's grants that do not fall into this
// list will be ignored.
var validCaps []capFeature = []capFeature{
capFeatureAll,
capFeatureSSH,
capFeatureSubnets,
capFeatureExitNodes,
capFeatureAccount,
}
type capRule struct {
CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
}
// toPeerCapabilities parses out the web ui capabilities from the
// given whois response.
func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) {
if whois == nil || status == nil {
return peerCapabilities{}, nil
}
if whois.Node.IsTagged() {
// We don't allow management *from* tagged nodes, so ignore caps.
// The web client auth flow relies on having a true user identity
// that can be verified through login.
return peerCapabilities{}, nil
}
if !status.Self.IsTagged() {
// User owned nodes are only ever manageable by the owner.
if status.Self.UserID != whois.UserProfile.ID {
return peerCapabilities{}, nil
} else {
return peerCapabilities{capFeatureAll: true}, nil // owner can edit all features
}
}
// For tagged nodes, we actually look at the granted capabilities.
caps := peerCapabilities{}
rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal capability: %v", err)
}
for _, c := range rules {
for _, f := range c.CanEdit {
cap := capFeature(strings.ToLower(f))
if slices.Contains(validCaps, cap) {
caps[cap] = true
}
}
}
return caps, nil
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -3,79 +3,44 @@
"version": "0.0.1",
"license": "BSD-3-Clause",
"engines": {
"node": "18.20.4",
"node": "18.16.1",
"yarn": "1.22.19"
},
"type": "module",
"private": true,
"dependencies": {
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-popover": "^1.0.6",
"classnames": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"swr": "^2.2.4",
"wouter": "^2.11.0",
"zustand": "^4.4.7"
"wouter": "^2.11.0"
},
"devDependencies": {
"@types/node": "^18.16.1",
"@types/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",
"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": {}
}
}
}

View File

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

View File

@@ -1,273 +1,21 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback } from "react"
import useToaster from "src/hooks/toaster"
import { ExitNode, NodeData, SubnetRoute } from "src/types"
import { assertNever } from "src/utils/util"
import { MutatorOptions, SWRConfiguration, useSWRConfig } from "swr"
import { noExitNode, runAsExitNode } from "./hooks/exit-nodes"
export const swrConfig: SWRConfiguration = {
fetcher: (url: string) => apiFetch(url, "GET"),
onError: (err, _) => console.error(err),
}
type APIType =
| { action: "up"; data: TailscaleUpData }
| { action: "logout" }
| { action: "new-auth-session"; data: AuthSessionNewData }
| { action: "update-prefs"; data: LocalPrefsData }
| { action: "update-routes"; data: SubnetRoute[] }
| { action: "update-exit-node"; data: ExitNode }
/**
* POST /api/up data
*/
type TailscaleUpData = {
Reauthenticate?: boolean // force reauthentication
ControlURL?: string
AuthKey?: string
}
/**
* GET /api/auth/session/new data
*/
type AuthSessionNewData = {
authUrl: string
}
/**
* PATCH /api/local/v0/prefs data
*/
type LocalPrefsData = {
RunSSHSet?: boolean
RunSSH?: boolean
}
/**
* POST /api/routes data
*/
type RoutesData = {
SetExitNode?: boolean
SetRoutes?: boolean
UseExitNode?: string
AdvertiseExitNode?: boolean
AdvertiseRoutes?: string[]
}
/**
* useAPI hook returns an api handler that can execute api calls
* throughout the web client UI.
*/
export function useAPI() {
const toaster = useToaster()
const { mutate } = useSWRConfig() // allows for global mutation
const handlePostError = useCallback(
(toast?: string) => (err: Error) => {
console.error(err)
toast && toaster.show({ variant: "danger", message: toast })
throw err
},
[toaster]
)
/**
* optimisticMutate wraps the SWR `mutate` function to apply some
* type-awareness with the following behavior:
*
* 1. `optimisticData` update is applied immediately on FetchDataType
* throughout the web client UI.
*
* 2. `fetch` data mutation runs.
*
* 3. On completion, FetchDataType is revalidated to exactly reflect the
* updated server state.
*
* The `key` argument is the useSWR key associated with the MutateDataType.
* All `useSWR(key)` consumers throughout the UI will see updates reflected.
*/
const optimisticMutate = useCallback(
<MutateDataType, FetchDataType = any>(
key: string,
fetch: Promise<FetchDataType>,
optimisticData: (current: MutateDataType) => MutateDataType,
revalidate?: boolean // optionally specify whether to run final revalidation (step 3)
): Promise<FetchDataType | undefined> => {
const options: MutatorOptions = {
/**
* populateCache is meant for use when the remote request returns back
* the updated data directly. i.e. When FetchDataType is the same as
* MutateDataType. Most of our data manipulation requests return a 200
* with empty data on success. We turn off populateCache so that the
* cache only gets updated after completion of the remote reqeust when
* the revalidation step runs.
*/
populateCache: false,
optimisticData,
revalidate: revalidate,
}
return mutate(key, fetch, options)
},
[mutate]
)
const api = useCallback(
(t: APIType) => {
switch (t.action) {
/**
* "up" handles authenticating the machine to tailnet.
*/
case "up":
return apiFetch<{ url?: string }>("/up", "POST", t.data)
.then((d) => d.url && window.open(d.url, "_blank")) // "up" login step
.then(() => incrementMetric("web_client_node_connect"))
.then(() => mutate("/data"))
.catch(handlePostError("Failed to login"))
/**
* "logout" handles logging the node out of tailscale, effectively
* expiring its node key.
*/
case "logout":
// For logout, must increment metric before running api call,
// as tailscaled will be unreachable after the call completes.
incrementMetric("web_client_node_disconnect")
return apiFetch("/local/v0/logout", "POST").catch(
handlePostError("Failed to logout")
)
/**
* "new-auth-session" handles creating a new check mode session to
* authorize the viewing user to manage the node via the web client.
*/
case "new-auth-session":
return apiFetch<AuthSessionNewData>("/auth/session/new", "GET").catch(
handlePostError("Failed to create new session")
)
/**
* "update-prefs" handles setting the node's tailscale prefs.
*/
case "update-prefs": {
return optimisticMutate<NodeData>(
"/data",
apiFetch<LocalPrefsData>("/local/v0/prefs", "PATCH", t.data),
(old) => ({
...old,
RunningSSHServer: t.data.RunSSHSet
? Boolean(t.data.RunSSH)
: old.RunningSSHServer,
})
)
.then(
() =>
t.data.RunSSHSet &&
incrementMetric(
t.data.RunSSH
? "web_client_ssh_enable"
: "web_client_ssh_disable"
)
)
.catch(handlePostError("Failed to update node preference"))
}
/**
* "update-routes" handles setting the node's advertised routes.
*/
case "update-routes": {
const body: RoutesData = {
SetRoutes: true,
AdvertiseRoutes: t.data.map((r) => r.Route),
}
return optimisticMutate<NodeData>(
"/data",
apiFetch<void>("/routes", "POST", body),
(old) => ({ ...old, AdvertisedRoutes: t.data })
)
.then(() => incrementMetric("web_client_advertise_routes_change"))
.catch(handlePostError("Failed to update routes"))
}
/**
* "update-exit-node" handles updating the node's state as either
* running as an exit node or using another node as an exit node.
*/
case "update-exit-node": {
const id = t.data.ID
const body: RoutesData = {
SetExitNode: true,
}
if (id !== noExitNode.ID && id !== runAsExitNode.ID) {
body.UseExitNode = id
} else if (id === runAsExitNode.ID) {
body.AdvertiseExitNode = true
}
const metrics: MetricName[] = []
return optimisticMutate<NodeData>(
"/data",
apiFetch<void>("/routes", "POST", body),
(old) => {
// Only update metrics whose values have changed.
if (old.AdvertisingExitNode !== Boolean(body.AdvertiseExitNode)) {
metrics.push(
body.AdvertiseExitNode
? "web_client_advertise_exitnode_enable"
: "web_client_advertise_exitnode_disable"
)
}
if (Boolean(old.UsingExitNode) !== Boolean(body.UseExitNode)) {
metrics.push(
body.UseExitNode
? "web_client_use_exitnode_enable"
: "web_client_use_exitnode_disable"
)
}
return {
...old,
UsingExitNode: Boolean(body.UseExitNode) ? t.data : undefined,
AdvertisingExitNode: Boolean(body.AdvertiseExitNode),
AdvertisingExitNodeApproved: Boolean(body.AdvertiseExitNode)
? true // gets updated in revalidation
: old.AdvertisingExitNodeApproved,
}
},
false // skip final revalidation
)
.then(() => metrics.forEach((m) => incrementMetric(m)))
.catch(handlePostError("Failed to update exit node"))
}
default:
assertNever(t)
}
},
[handlePostError, mutate, optimisticMutate]
)
return api
}
let csrfToken: string
let synoToken: string | undefined // required for synology API requests
let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062)
/**
* apiFetch wraps the standard JS fetch function with csrf header
* management and param additions specific to the web client.
*
* apiFetch adds the `api` prefix to the request URL,
* so endpoint should be provided without the `api` prefix
* (i.e. provide `/data` rather than `api/data`).
*/
export function apiFetch<T>(
// apiFetch wraps the standard JS fetch function with csrf header
// management and param additions specific to the web client.
//
// apiFetch adds the `api` prefix to the request URL,
// so endpoint should be provided without the `api` prefix
// (i.e. provide `/data` rather than `api/data`).
export function apiFetch(
endpoint: string,
method: "GET" | "POST" | "PATCH",
body?: any
): Promise<T> {
body?: any,
params?: Record<string, string>
): Promise<Response> {
const urlParams = new URLSearchParams(window.location.search)
const nextParams = new URLSearchParams()
const nextParams = new URLSearchParams(params)
if (synoToken) {
nextParams.set("SynoToken", synoToken)
} else {
@@ -300,26 +48,16 @@ export function apiFetch<T>(
"Content-Type": contentType,
"X-CSRF-Token": csrfToken,
},
body: body,
body,
}).then((r) => {
updateCsrfToken(r)
if (!r.ok) {
return r.text().then((err) => {
throw new Error(err)
})
}
return r
})
.then((r) => {
updateCsrfToken(r)
if (!r.ok) {
return r.text().then((err) => {
throw new Error(err)
})
}
return r
})
.then((r) => {
if (r.headers.get("Content-Type") === "application/json") {
return r.json()
}
})
.then((r) => {
r?.UnraidToken && setUnraidCsrfToken(r.UnraidToken)
return r
})
}
function updateCsrfToken(r: Response) {
@@ -333,45 +71,6 @@ export function setSynoToken(token?: string) {
synoToken = token
}
function setUnraidCsrfToken(token?: string) {
export function setUnraidCsrfToken(token?: string) {
unraidCsrfToken = token
}
/**
* incrementMetric hits the client metrics local API endpoint to
* increment the given counter metric by one.
*/
export function incrementMetric(metricName: MetricName) {
const postData: MetricsPOSTData[] = [
{
Name: metricName,
Type: "counter",
Value: 1,
},
]
apiFetch("/local/v0/upload-client-metrics", "POST", postData).catch(
(error) => {
console.error(error)
}
)
}
type MetricsPOSTData = {
Name: MetricName
Type: MetricType
Value: number
}
type MetricType = "counter" | "gauge"
export type MetricName =
| "web_client_advertise_exitnode_enable"
| "web_client_advertise_exitnode_disable"
| "web_client_use_exitnode_enable"
| "web_client_use_exitnode_disable"
| "web_client_ssh_enable"
| "web_client_ssh_disable"
| "web_client_node_connect"
| "web_client_node_disconnect"
| "web_client_advertise_routes_change"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,6 +1,3 @@
// 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"

View File

@@ -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>
)
}

View File

@@ -1,31 +1,23 @@
// 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 cx from "classnames"
import React, { useEffect } from "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 useAuth, { AuthResponse } from "src/hooks/auth"
import useNodeData, { NodeData } from "src/hooks/node-data"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
import { Link, Route, Router, Switch, useLocation } from "wouter"
export default function App() {
const { data: auth, loading: loadingAuth, newSession } = useAuth()
return (
<main className="min-w-sm max-w-lg mx-auto py-4 sm:py-14 px-5">
<main className="min-w-sm max-w-lg mx-auto py-14 px-5">
{loadingAuth || !auth ? (
<LoadingView />
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
) : (
<WebClient auth={auth} newSession={newSession} />
)}
@@ -40,50 +32,51 @@ function WebClient({
auth: AuthResponse
newSession: () => Promise<void>
}) {
const { data: node } = useSWR<NodeData>("/data")
const { data, refreshData, updateNode, updatePrefs } = useNodeData()
useEffect(() => {
refreshData()
}, [auth, refreshData])
return !node ? (
<LoadingView />
) : node.Status === "NeedsLogin" ||
node.Status === "NoState" ||
node.Status === "Stopped" ? (
return !data ? (
<div className="text-center py-14">Loading...</div>
) : data.Status === "NeedsLogin" ||
data.Status === "NoState" ||
data.Status === "Stopped" ? (
// Client not on a tailnet, render login.
<LoginView data={node} />
<LoginView data={data} refreshData={refreshData} />
) : (
// Otherwise render the new web client.
<>
<Router base={node.URLPrefix}>
<Header node={node} auth={auth} newSession={newSession} />
<Router base={data.URLPrefix}>
<Header node={data} auth={auth} newSession={newSession} />
<Switch>
<Route path="/">
<HomeView node={node} auth={auth} />
<HomeView
readonly={!auth.canManageNode}
node={data}
updateNode={updateNode}
/>
</Route>
<Route path="/details">
<DeviceDetailsView node={node} auth={auth} />
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
</Route>
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
<SubnetRouterView
readonly={!canEdit("subnets", auth)}
node={node}
<Route path="/subnets">{/* TODO */}Subnet router</Route>
<Route path="/ssh">
<SSHView
readonly={!auth.canManageNode}
runningSSH={data.RunningSSHServer}
updatePrefs={updatePrefs}
/>
</FeatureRoute>
<FeatureRoute path="/ssh" feature="ssh" node={node}>
<SSHView readonly={!canEdit("ssh", auth)} node={node} />
</FeatureRoute>
{/* <Route path="/serve">Share local content</Route> */}
<FeatureRoute path="/update" feature="auto-update" node={node}>
</Route>
<Route path="/serve">{/* TODO */}Share local content</Route>
<Route path="/update">
<UpdatingView
versionInfo={node.ClientVersion}
currentVersion={node.IPNVersion}
versionInfo={data.ClientVersion}
currentVersion={data.IPNVersion}
/>
</FeatureRoute>
<Route path="/disconnected">
<DisconnectedView />
</Route>
<Route>
<Card className="mt-8">
<EmptyState description="Page not found" />
</Card>
<h2 className="mt-8">Page not found</h2>
</Route>
</Switch>
</Router>
@@ -91,40 +84,6 @@ function WebClient({
)
}
/**
* FeatureRoute renders a Route component,
* but only displays the child view if the specified feature is
* available for use on this node's platform. If not available,
* a not allowed view is rendered instead.
*/
function FeatureRoute({
path,
node,
feature,
children,
}: {
path: string
node: NodeData
feature: Feature
children: React.ReactNode
}) {
return (
<Route path={path}>
{!node.Features[feature] ? (
<Card className="mt-8">
<EmptyState
description={`${featureDescription(
feature
)} not available on this device.`}
/>
</Card>
) : (
children
)}
</Route>
)
}
function Header({
node,
auth,
@@ -136,24 +95,22 @@ function Header({
}) {
const [loc] = useLocation()
if (loc === "/disconnected") {
// No header on view presented after logout.
return null
}
return (
<>
<div className="flex flex-wrap gap-4 justify-between items-center mb-9 md:mb-12">
<Link to="/" className="flex gap-3 overflow-hidden">
<div className="flex justify-between mb-12">
<div className="flex gap-3">
<TailscaleIcon />
<div className="inline text-gray-800 text-lg font-medium leading-snug truncate">
<div className="inline text-neutral-800 text-lg font-medium leading-snug">
{node.DomainName}
</div>
</Link>
</div>
<LoginToggle node={node} auth={auth} newSession={newSession} />
</div>
{loc !== "/" && loc !== "/update" && (
<Link to="/" className="link font-medium block mb-2">
<Link
to="/"
className="text-indigo-500 font-medium leading-snug block mb-[10px]"
>
&larr; Back to {node.DeviceName}
</Link>
)}
@@ -161,12 +118,21 @@ function Header({
)
}
/**
* LoadingView fills its container with small animated loading dots
* in the center.
*/
export function LoadingView() {
function Footer({
licensesURL,
className,
}: {
licensesURL: string
className?: string
}) {
return (
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
<footer className={cx("container max-w-lg mx-auto text-center", className)}>
<a
className="text-xs text-gray-500 hover:text-gray-600"
href={licensesURL}
>
Open Source Licenses
</a>
</footer>
)
}

View File

@@ -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>
)
}

View File

@@ -1,584 +1,177 @@
// 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"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { NodeData, NodeUpdate } from "src/hooks/node-data"
import { ReactComponent as Check } from "src/icons/check.svg"
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
import { ReactComponent as Search } from "src/icons/search.svg"
const noExitNode = "None"
const runAsExitNode = "Run as exit node…"
export default function ExitNodeSelector({
className,
node,
updateNode,
disabled,
}: {
className?: string
node: NodeData
updateNode: (update: NodeUpdate) => Promise<void> | undefined
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])
const [selected, setSelected] = useState(
node.AdvertiseExitNode ? runAsExitNode : noExitNode
)
useEffect(() => {
setPending(
node.AdvertisingExitNode && node.AdvertisingExitNodeApproved === false
)
setSelected(node.AdvertiseExitNode ? runAsExitNode : noExitNode)
}, [node])
const handleSelect = useCallback(
(n: ExitNode) => {
(item: string) => {
setOpen(false)
if (n.ID === selected.ID) {
if (item === selected) {
return // no update
}
// Eager clear of pending state to avoid UI oddities
if (n.ID !== runAsExitNode.ID) {
setPending(false)
const old = selected
setSelected(item)
var update: NodeUpdate = {}
switch (item) {
case noExitNode:
// turn off exit node
update = { AdvertiseExitNode: false }
break
case runAsExitNode:
// turn on exit node
update = { AdvertiseExitNode: true }
break
}
api({ action: "update-exit-node", data: n })
// refresh data after short timeout to pick up any pending approval updates
setTimeout(() => {
mutate("/data")
}, 1000)
updateNode(update)?.catch(() => setSelected(old))
},
[api, mutate, selected.ID]
[setOpen, selected, setSelected]
)
// TODO: close on click outside
// TODO(sonia): allow choosing to use another exit node
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 === noExitNode,
selected === runAsExitNode,
selected !== noExitNode && selected !== runAsExitNode,
],
[selected.ID, selected.Online]
[selected]
)
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,
})}
className={cx(
"p-1.5 rounded-md border flex items-stretch gap-1.5",
{
"border-gray-200": none,
"bg-amber-600 border-amber-600": advertising,
"bg-indigo-500 border-indigo-500": using,
},
className
)}
>
<Popover
open={disabled ? false : open}
onOpenChange={setOpen}
className="overflow-hidden"
side="bottom"
sideOffset={0}
align="start"
content={
<ExitNodeSelectorInner
node={node}
selected={selected}
onSelect={handleSelect}
/>
}
asChild
<button
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
"bg-white hover:bg-stone-100": none,
"bg-amber-600 hover:bg-orange-400": advertising,
"bg-indigo-500 hover:bg-indigo-400": using,
"cursor-not-allowed": disabled,
})}
onClick={() => setOpen(!open)}
disabled={disabled}
>
<button
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
"bg-white": none,
"hover:bg-gray-100": none && !disabled,
"bg-yellow-300": advertising && !offline,
"hover:bg-yellow-200": advertising && !offline && !disabled,
"bg-blue-500": using && !offline,
"hover:bg-blue-400": using && !offline && !disabled,
"bg-red-500": offline,
"hover:bg-red-400": offline && !disabled,
})}
onClick={() => setOpen(!open)}
disabled={disabled}
<p
className={cx(
"text-neutral-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
{ "bg-opacity-70 text-white": advertising || using }
)}
>
Exit node
</p>
<div className="flex items-center">
<p
className={cx(
"text-gray-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
{ "opacity-70 text-white": advertising || using }
)}
className={cx("text-neutral-800", {
"text-white": advertising || using,
})}
>
Exit node{offline && " offline"}
{selected === runAsExitNode ? "Running as exit node" : "None"}
</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) && (
<ChevronDown
className={cx("ml-1", {
"stroke-neutral-800": none,
"stroke-white": advertising || using,
})}
/>
</div>
</button>
{(advertising || using) && (
<button
className={cx("px-3 py-2 rounded-sm text-white", {
"hover:bg-yellow-200": advertising && !offline,
"hover:bg-blue-400": using && !offline,
"hover:bg-red-400": offline,
"bg-orange-400": advertising,
"bg-indigo-400": using,
"cursor-not-allowed": disabled,
})}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleSelect(noExitNode)
}}
disabled={disabled}
>
Disable
</button>
)}
</div>
{offline && (
<p className="text-white p-3">
The selected exit node is currently offline. Your internet traffic is
blocked until you disable the exit node or select a different one.
</p>
{open && (
<div className="absolute ml-1.5 -mt-3 w-full max-w-md py-1 bg-white rounded-lg shadow">
<div className="w-full px-4 py-2 flex items-center gap-2.5">
<Search />
<input
className="flex-1 leading-snug"
placeholder="Search exit nodes…"
/>
</div>
<DropdownSection
items={[noExitNode, runAsExitNode]}
selected={selected}
onSelect={handleSelect}
/>
</div>
)}
{pending && (
<p className="text-white p-3">
Pending approval to run as exit node. This device wont 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,
function DropdownSection({
items,
selected,
onSelect,
}: {
node: NodeData
selected: ExitNode
onSelect: (node: ExitNode) => void
items: string[]
selected?: string
onSelect: (item: string) => 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 className="w-full mt-1 pt-1 border-t border-gray-200">
{items.map((v) => (
<button
key={v}
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
onClick={() => onSelect(v)}
>
<div className="leading-snug">{v}</div>
{selected == v && <Check />}
</button>
))}
</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: "🇿🇼",
}

View File

@@ -1,18 +1,12 @@
// 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 React, { useCallback, useEffect, useState } from "react"
import { AuthResponse, AuthType } from "src/hooks/auth"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
import { ReactComponent as Eye } from "src/icons/eye.svg"
import { ReactComponent as User } from "src/icons/user.svg"
import Popover from "src/ui/popover"
import ProfilePic from "src/ui/profile-pic"
import { assertNever, isHTTPS } from "src/utils/util"
export default function LoginToggle({
node,
@@ -24,29 +18,12 @@ export default function LoginToggle({
newSession: () => Promise<void>
}) {
const [open, setOpen] = useState<boolean>(false)
const { tsWebConnected, checkTSWebConnection } = useTSWebConnected(
auth.serverMode,
node.IPv4
)
return (
<Popover
className="p-3 bg-white rounded-lg shadow flex flex-col max-w-[317px]"
className="p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]"
content={
auth.serverMode === "readonly" ? (
<ReadonlyModeContent auth={auth} />
) : auth.serverMode === "login" ? (
<LoginModeContent
auth={auth}
node={node}
tsWebConnected={tsWebConnected}
checkTSWebConnection={checkTSWebConnection}
/>
) : auth.serverMode === "manage" ? (
<ManageModeContent auth={auth} node={node} newSession={newSession} />
) : (
assertNever(auth.serverMode)
)
<LoginPopoverContent node={node} auth={auth} newSession={newSession} />
}
side="bottom"
align="end"
@@ -54,323 +31,168 @@ export default function LoginToggle({
onOpenChange={setOpen}
asChild
>
<div>
{auth.authorized ? (
<TriggerWhenManaging auth={auth} open={open} setOpen={setOpen} />
) : (
<TriggerWhenReading auth={auth} open={open} setOpen={setOpen} />
)}
</div>
{!auth.canManageNode ? (
<button
className={cx(
"pl-3 py-1 bg-zinc-800 rounded-full flex justify-start items-center",
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
)}
onClick={() => setOpen(!open)}
>
<Eye />
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
{auth.viewerIdentity && (
<ProfilePic
className="ml-2"
size="medium"
url={auth.viewerIdentity.profilePicUrl}
/>
)}
</button>
) : (
<div
className={cx(
"w-[34px] h-[34px] p-1 rounded-full items-center inline-flex",
{
"bg-transparent": !open,
"bg-neutral-300": open,
}
)}
>
<button onClick={() => setOpen(!open)}>
<ProfilePic
size="medium"
url={auth.viewerIdentity?.profilePicUrl}
/>
</button>
</div>
)}
</Popover>
)
}
/**
* TriggerWhenManaging is displayed as the trigger for the login popover
* when the user has an active authorized managment session.
*/
function TriggerWhenManaging({
auth,
open,
setOpen,
}: {
auth: AuthResponse
open: boolean
setOpen: (next: boolean) => void
}) {
return (
<div
className={cx(
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
{
"bg-transparent": !open,
"bg-gray-300": open,
}
)}
>
<button onClick={() => setOpen(!open)}>
<ProfilePic size="medium" url={auth.viewerIdentity?.profilePicUrl} />
</button>
</div>
)
}
/**
* TriggerWhenReading is displayed as the trigger for the login popover
* when the user is currently in read mode (doesn't have an authorized
* management session).
*/
function TriggerWhenReading({
auth,
open,
setOpen,
}: {
auth: AuthResponse
open: boolean
setOpen: (next: boolean) => void
}) {
return (
<button
className={cx(
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
)}
onClick={() => setOpen(!open)}
>
<Eye />
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
{auth.viewerIdentity && (
<ProfilePic
className="ml-2"
size="medium"
url={auth.viewerIdentity.profilePicUrl}
/>
)}
</button>
)
}
/**
* PopoverContentHeader is the header for the login popover.
*/
function PopoverContentHeader({ auth }: { auth: AuthResponse }) {
return (
<div className="text-black text-sm font-medium leading-tight mb-1">
{auth.authorized ? "Managing" : "Viewing"}
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
</div>
)
}
/**
* PopoverContentFooter is the footer for the login popover.
*/
function PopoverContentFooter({ auth }: { auth: AuthResponse }) {
return auth.viewerIdentity ? (
<>
<hr className="my-2" />
<div className="flex items-center">
<User className="flex-shrink-0" />
<p className="text-gray-500 text-xs ml-2">
We recognize you because you are accessing this page from{" "}
<span className="font-medium">
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
</span>
</p>
</div>
</>
) : null
}
/**
* ReadonlyModeContent is the body of the login popover when the web
* client is being run in "readonly" server mode.
*/
function ReadonlyModeContent({ auth }: { auth: AuthResponse }) {
return (
<>
<PopoverContentHeader auth={auth} />
<p className="text-gray-500 text-xs">
This web interface is running in read-only mode.{" "}
<a
href="https://tailscale.com/s/web-client-read-only"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
<PopoverContentFooter auth={auth} />
</>
)
}
/**
* LoginModeContent is the body of the login popover when the web
* client is being run in "login" server mode.
*/
function LoginModeContent({
function LoginPopoverContent({
node,
auth,
tsWebConnected,
checkTSWebConnection,
}: {
node: NodeData
auth: AuthResponse
tsWebConnected: boolean
checkTSWebConnection: () => void
}) {
const https = isHTTPS()
// We can't run the ts web connection test when the webpage is loaded
// over HTTPS. So in this case, we default to presenting a login button
// with some helper text reminding the user to check their connection
// themselves.
const hasACLAccess = https || tsWebConnected
const hasEditCaps = useMemo(() => {
if (!auth.viewerIdentity) {
// If not connected to login client over tailscale, we won't know the viewer's
// identity. So we must assume they may be able to edit something and have the
// management client handle permissions once the user gets there.
return true
}
return hasAnyEditCapabilities(auth)
}, [auth])
const handleLogin = useCallback(() => {
// Must be connected over Tailscale to log in.
// Send user to Tailscale IP and start check mode
const manageURL = `http://${node.IPv4}:5252/?check=now`
if (window.self !== window.top) {
// If we're inside an iframe, open management client in new window.
window.open(manageURL, "_blank")
} else {
window.location.href = manageURL
}
}, [node.IPv4])
return (
<div
onMouseEnter={
hasEditCaps && !hasACLAccess ? checkTSWebConnection : undefined
}
>
<PopoverContentHeader auth={auth} />
{!hasACLAccess || !hasEditCaps ? (
<>
<p className="text-gray-500 text-xs">
{!hasEditCaps ? (
// ACLs allow access, but user isn't allowed to edit any features,
// restricted to readonly. No point in sending them over to the
// tailscaleIP:5252 address.
<>
You dont 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 devices 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 &rarr;
</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 devices details. To make changes, you need
to sign in.
</p>
{https && (
// we don't know if the user can connect over TS, so
// provide extra tips in case they have trouble.
<p className="text-gray-500 text-xs font-semibold pt-2">
Make sure you are connected to your tailnet, and that your policy
file allows access.
</p>
)}
<SignInButton auth={auth} onClick={handleLogin} />
</>
)}
<PopoverContentFooter auth={auth} />
</div>
)
}
/**
* ManageModeContent is the body of the login popover when the web
* client is being run in "manage" server mode.
*/
function ManageModeContent({
auth,
newSession,
}: {
node: NodeData
auth: AuthResponse
newSession: () => void
newSession: () => Promise<void>
}) {
const handleLogin = useCallback(() => {
if (window.self !== window.top) {
// If we're inside an iframe, start session in new window.
let url = new URL(window.location.href)
url.searchParams.set("check", "now")
window.open(url, "_blank")
} else {
newSession()
}
}, [newSession])
/**
* canConnectOverTS indicates whether the current viewer
* is able to hit the node's web client that's being served
* at http://${node.IP}:5252. If false, this means that the
* viewer must connect to the correct tailnet before being
* able to sign in.
*/
const [canConnectOverTS, setCanConnectOverTS] = useState<boolean>(false)
const [isRunningCheck, setIsRunningCheck] = useState<boolean>(false)
const hasAnyPermissions = useMemo(() => hasAnyEditCapabilities(auth), [auth])
const checkTSConnection = useCallback(() => {
if (auth.viewerIdentity) {
setCanConnectOverTS(true) // already connected over ts
return
}
// Otherwise, test connection to the ts IP.
if (isRunningCheck) {
return // already checking
}
setIsRunningCheck(true)
fetch(`http://${node.IP}:5252/ok`, { mode: "no-cors" })
.then(() => {
setIsRunningCheck(false)
setCanConnectOverTS(true)
})
.catch(() => setIsRunningCheck(false))
}, [
auth.viewerIdentity,
isRunningCheck,
setCanConnectOverTS,
setIsRunningCheck,
])
/**
* Checking connection for first time on page load.
*
* While not connected, we check again whenever the mouse
* enters the popover component, to pick up on the user
* leaving to turn on Tailscale then returning to the view.
* See `onMouseEnter` on the div below.
*/
useEffect(() => checkTSConnection(), [])
const handleSignInClick = useCallback(() => {
if (auth.viewerIdentity) {
newSession()
} else {
// Must be connected over Tailscale to log in.
// If not already connected, reroute to the Tailscale IP
// before sending user through check mode.
window.location.href = `http://${node.IP}:5252/?check=now`
}
}, [node.IP, auth.viewerIdentity, newSession])
return (
<>
<PopoverContentHeader auth={auth} />
{!auth.authorized &&
(hasAnyPermissions ? (
// User is connected over Tailscale, but needs to complete check mode.
<div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>
<div className="text-black text-sm font-medium leading-tight mb-1">
{!auth.canManageNode ? "Viewing" : "Managing"}
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
</div>
{!auth.canManageNode &&
(!auth.viewerIdentity || auth.authNeeded == AuthType.tailscale ? (
<>
<p className="text-gray-500 text-xs">
To make changes, sign in to confirm your identity. This extra step
helps us keep your device secure.
<p className="text-neutral-500 text-xs">
{auth.viewerIdentity ? (
<>
To make changes, sign in to confirm your identity. This extra
step helps us keep your device secure.
</>
) : (
<>
You can see most of this device's details. To make changes,
you need to sign in.
</>
)}
</p>
<SignInButton auth={auth} onClick={handleLogin} />
<button
className={cx(
"w-full px-3 py-2 bg-indigo-500 rounded shadow text-center text-white text-sm font-medium mt-2",
{
"mb-2": auth.viewerIdentity,
"cursor-not-allowed": !canConnectOverTS,
}
)}
onClick={handleSignInClick}
// TODO: add some helper info when disabled
// due to needing to connect to TS
disabled={!canConnectOverTS}
>
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
</button>
</>
) : (
// User is connected over tailscale, but doesn't have permission to manage.
<p className="text-gray-500 text-xs">
<p className="text-neutral-500 text-xs">
You dont 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 &rarr;
</a>
can view most of its details.
</p>
))}
<PopoverContentFooter auth={auth} />
</>
)
}
function SignInButton({
auth,
onClick,
}: {
auth: AuthResponse
onClick: () => void
}) {
return (
<Button
className={cx("text-center w-full mt-2", {
"mb-2": auth.viewerIdentity,
})}
intent="primary"
sizeVariant="small"
onClick={onClick}
>
{auth.viewerIdentity ? "Sign in to confirm identity" : "Sign in"}
</Button>
{auth.viewerIdentity && (
<>
<hr className="my-2" />
<div className="flex items-center">
<User className="flex-shrink-0" />
<p className="text-neutral-500 text-xs ml-2">
We recognize you because you are accessing this page from{" "}
<span className="font-medium">
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
</span>
</p>
</div>
</>
)}
</div>
)
}

View File

@@ -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]
}

View File

@@ -1,21 +1,14 @@
// 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"
import { VersionInfo } from "src/hooks/self-update"
import { Link } from "wouter"
export function UpdateAvailableNotification({
details,
}: {
details: VersionInfo
}) {
const [, setLocation] = useLocation()
return (
<Card>
<div className="card">
<h2 className="mb-2">
Update available{" "}
{details.LatestVersion && `(v${details.LatestVersion})`}
@@ -26,14 +19,13 @@ export function UpdateAvailableNotification({
: "A new update"}{" "}
is now available. <ChangelogText version={details.LatestVersion} />
</p>
<Button
className="mt-3 inline-block"
sizeVariant="small"
onClick={() => setLocation("/update")}
<Link
className="button button-blue mt-3 text-sm inline-block"
to="/update"
>
Update now
</Button>
</Card>
</Link>
</div>
)
}
@@ -61,7 +53,7 @@ export function ChangelogText({ version }: { version?: string }) {
<a href="https://tailscale.com/changelog/" className="link">
release notes
</a>{" "}
to find out whats new!
to find out what's new!
</>
)
}

View File

@@ -1,33 +1,25 @@
// 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 { apiFetch } from "src/api"
import { UpdateAvailableNotification } from "src/components/update-available"
import { AuthResponse, canEdit } from "src/hooks/auth"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Card from "src/ui/card"
import Dialog from "src/ui/dialog"
import QuickCopy from "src/ui/quick-copy"
import { NodeData } from "src/hooks/node-data"
import { useLocation } from "wouter"
import ACLTag from "../acl-tag"
export default function DeviceDetailsView({
readonly,
node,
auth,
}: {
readonly: boolean
node: NodeData
auth: AuthResponse
}) {
const [, setLocation] = useLocation()
return (
<>
<h1 className="mb-10">Device details</h1>
<div className="flex flex-col gap-4">
<Card noPadding className="-mx-5 p-5 details-card">
<div className="card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1>{node.DeviceName}</h1>
@@ -38,16 +30,28 @@ export default function DeviceDetailsView({
})}
/>
</div>
{canEdit("account", auth) && <DisconnectDialog />}
<button
className={cx(
"px-3 py-2 bg-stone-50 rounded shadow border border-stone-200 text-neutral-800 text-sm font-medium",
{ "cursor-not-allowed": readonly }
)}
onClick={() =>
apiFetch("/local/v0/logout", "POST")
.then(() => setLocation("/"))
.catch((err) => alert("Logout failed: " + err.message))
}
disabled={readonly}
>
Disconnect
</button>
</div>
</Card>
{node.Features["auto-update"] &&
canEdit("account", auth) &&
node.ClientVersion &&
!node.ClientVersion.RunningLatest && (
</div>
{node.ClientVersion &&
!node.ClientVersion.RunningLatest &&
!readonly && (
<UpdateAvailableNotification details={node.ClientVersion} />
)}
<Card noPadding className="-mx-5 p-5 details-card">
<div className="card">
<h2 className="mb-2">General</h2>
<table>
<tbody>
@@ -56,19 +60,12 @@ export default function DeviceDetailsView({
<td className="flex gap-1 flex-wrap">
{node.IsTagged
? node.Tags.map((t) => <ACLTag key={t} tag={t} />)
: node.Profile?.DisplayName}
: node.Profile.DisplayName}
</td>
</tr>
<tr>
<td>Machine name</td>
<td>
<QuickCopy
primaryActionValue={node.DeviceName}
primaryActionSubject="machine name"
>
{node.DeviceName}
</QuickCopy>
</td>
<td>{node.DeviceName}</td>
</tr>
<tr>
<td>OS</td>
@@ -76,14 +73,7 @@ export default function DeviceDetailsView({
</tr>
<tr>
<td>ID</td>
<td>
<QuickCopy
primaryActionValue={node.ID}
primaryActionSubject="ID"
>
{node.ID}
</QuickCopy>
</td>
<td>{node.ID}</td>
</tr>
<tr>
<td>Tailscale version</td>
@@ -95,155 +85,50 @@ export default function DeviceDetailsView({
{node.KeyExpired
? "Expired"
: // TODO: present as relative expiry (e.g. "5 months from now")
node.KeyExpiry
? new Date(node.KeyExpiry).toLocaleString()
: "No expiry"}
new Date(node.KeyExpiry).toLocaleString()}
</td>
</tr>
</tbody>
</table>
</Card>
<Card noPadding className="-mx-5 p-5 details-card">
</div>
<div className="card">
<h2 className="mb-2">Addresses</h2>
<table>
<tbody>
<tr>
<td>Tailscale IPv4</td>
<td>
<QuickCopy
primaryActionValue={node.IPv4}
primaryActionSubject="IPv4 address"
>
{node.IPv4}
</QuickCopy>
</td>
<td>{node.IP}</td>
</tr>
<tr>
<td>Tailscale IPv6</td>
<td>
<QuickCopy
primaryActionValue={node.IPv6}
primaryActionSubject="IPv6 address"
>
<NiceIP ip={node.IPv6} />
</QuickCopy>
</td>
<td>{node.IPv6}</td>
</tr>
<tr>
<td>Short domain</td>
<td>
<QuickCopy
primaryActionValue={node.DeviceName}
primaryActionSubject="short domain"
>
{node.DeviceName}
</QuickCopy>
</td>
<td>{node.DeviceName}</td>
</tr>
<tr>
<td>Full domain</td>
<td>
<QuickCopy
primaryActionValue={`${node.DeviceName}.${node.TailnetName}`}
primaryActionSubject="full domain"
>
{node.DeviceName}.{node.TailnetName}
</QuickCopy>
{node.DeviceName}.{node.TailnetName}
</td>
</tr>
</tbody>
</table>
</Card>
<Card noPadding className="-mx-5 p-5 details-card">
<h2 className="mb-2">Debug</h2>
<table>
<tbody>
<tr>
<td>TUN Mode</td>
<td>{node.TUNMode ? "Yes" : "No"}</td>
</tr>
{node.IsSynology && (
<tr>
<td>Synology Version</td>
<td>{node.DSMVersion}</td>
</tr>
)}
</tbody>
</table>
</Card>
<footer className="text-gray-500 text-sm leading-tight text-center">
<Control.AdminContainer node={node}>
Want even more details? Visit{" "}
<Control.AdminLink node={node} path={`/machines/${node.IPv4}`}>
this devices 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>
<p className="text-neutral-500 text-sm leading-tight text-center">
Want even more details? Visit{" "}
<a
// TODO: pipe control serve url from backend
href="https://login.tailscale.com/admin"
target="_blank"
className="text-indigo-700 text-sm"
>
this devices page
</a>{" "}
in the admin console.
</p>
</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 wont 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>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -1,134 +1,77 @@
// 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 React from "react"
import ExitNodeSelector from "src/components/exit-node-selector"
import { AuthResponse, canEdit } from "src/hooks/auth"
import { NodeData } from "src/types"
import Card from "src/ui/card"
import { pluralize } from "src/utils/util"
import { Link, useLocation } from "wouter"
import { NodeData, NodeUpdate } from "src/hooks/node-data"
import { ReactComponent as ArrowRight } from "src/icons/arrow-right.svg"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { Link } from "wouter"
export default function HomeView({
readonly,
node,
auth,
updateNode,
}: {
readonly: boolean
node: NodeData
auth: AuthResponse
updateNode: (update: NodeUpdate) => Promise<void> | undefined
}) {
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
() => [
node.AdvertisedRoutes?.length,
node.AdvertisedRoutes?.filter((r) => !r.Approved).length,
],
[node.AdvertisedRoutes]
)
return (
<div className="mb-12 w-full">
<h2 className="mb-3">This device</h2>
<Card noPadding className="-mx-5 p-5 mb-9">
<div className="-mx-5 card mb-9">
<div className="flex justify-between items-center text-lg mb-5">
<Link className="flex items-center" to="/details">
<div className="w-10 h-10 bg-gray-100 rounded-full justify-center items-center inline-flex">
<Machine />
</div>
<div className="flex items-center">
<ConnectedDeviceIcon />
<div className="ml-3">
<div className="text-gray-800 text-lg font-medium leading-snug">
{node.DeviceName}
</div>
<p className="text-gray-500 text-sm leading-[18.20px] flex items-center gap-2">
<span
className={cx("w-2 h-2 inline-block rounded-full", {
"bg-green-300": node.Status === "Running",
"bg-gray-300": node.Status !== "Running",
})}
/>
{node.Status === "Running" ? "Connected" : "Offline"}
</p>
<h1>{node.DeviceName}</h1>
{/* TODO(sonia): display actual status */}
<p className="text-neutral-500 text-sm">Connected</p>
</div>
</Link>
<AddressCard
className="-mr-2"
triggerClassName="relative text-gray-800 text-lg leading-[25.20px]"
v4Address={node.IPv4}
v6Address={node.IPv6}
shortDomain={node.DeviceName}
fullDomain={`${node.DeviceName}.${node.TailnetName}`}
/>
</div>
<p className="text-neutral-800 text-lg leading-[25.20px]">
{node.IP}
</p>
</div>
{(node.Features["advertise-exit-node"] ||
node.Features["use-exit-node"]) && (
<ExitNodeSelector
className="mb-5"
node={node}
disabled={!canEdit("exitnodes", auth)}
/>
)}
<ExitNodeSelector
className="mb-5"
node={node}
updateNode={updateNode}
disabled={readonly}
/>
<Link
className="link font-medium"
className="text-indigo-500 font-medium leading-snug"
to="/details"
onClick={() => apiFetch("/device-details-click", "POST")}
>
View device details &rarr;
</Link>
</Card>
</div>
<h2 className="mb-3">Settings</h2>
<div className="grid gap-3">
{node.Features["advertise-routes"] && (
<SettingsCard
link="/subnets"
title="Subnet router"
body="Add devices to your tailnet without installing Tailscale on them."
badge={
allSubnetRoutes
? {
text: `${allSubnetRoutes} ${pluralize(
"route",
"routes",
allSubnetRoutes
)}`,
}
: undefined
}
footer={
pendingSubnetRoutes
? `${pendingSubnetRoutes} ${pluralize(
"route",
"routes",
pendingSubnetRoutes
)} pending approval`
: undefined
}
/>
)}
{node.Features["ssh"] && (
<SettingsCard
link="/ssh"
title="Tailscale SSH server"
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
badge={
node.RunningSSHServer
? {
text: "Running",
icon: <div className="w-2 h-2 bg-green-300 rounded-full" />,
}
: undefined
}
/>
)}
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
{/* <SettingsCard
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
{/* <SettingsCard
link="/subnets"
className="mb-3"
title="Subnet router"
body="Add devices to your tailnet without installing Tailscale on them."
/> */}
<SettingsCard
link="/ssh"
className="mb-3"
title="Tailscale SSH server"
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
badge={
node.RunningSSHServer
? {
text: "Running",
icon: <div className="w-2 h-2 bg-emerald-500 rounded-full" />,
}
: undefined
}
/>
{/* <SettingsCard
link="/serve"
title="Share local content"
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
/> */}
</div>
</div>
)
}
@@ -138,7 +81,6 @@ function SettingsCard({
link,
body,
badge,
footer,
className,
}: {
title: string
@@ -148,42 +90,35 @@ function SettingsCard({
text: string
icon?: JSX.Element
}
footer?: string
className?: string
}) {
const [, setLocation] = useLocation()
return (
<button onClick={() => setLocation(link)}>
<Card noPadding className={cx("-mx-5 p-5", className)}>
<div className="flex justify-between items-center">
<div>
<div className="flex gap-2">
<p className="text-gray-800 font-medium leading-tight mb-2">
{title}
</p>
{badge && (
<div className="h-5 px-2 bg-gray-100 rounded-full flex items-center gap-2">
{badge.icon}
<div className="text-gray-500 text-xs font-medium">
{badge.text}
</div>
</div>
)}
<Link
to={link}
className={cx(
"-mx-5 card flex justify-between items-center cursor-pointer",
className
)}
>
<div>
<div className="flex gap-2">
<p className="text-neutral-800 font-medium leading-tight mb-2">
{title}
</p>
{badge && (
<div className="h-5 px-2 bg-stone-100 rounded-full flex items-center gap-2">
{badge.icon}
<div className="text-neutral-500 text-xs font-medium">
{badge.text}
</div>
</div>
<p className="text-gray-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div>
)}
</div>
{footer && (
<>
<hr className="my-3" />
<div className="text-gray-500 text-sm leading-tight">{footer}</div>
</>
)}
</Card>
</button>
<p className="text-neutral-500 text-sm leading-tight">{body}</p>
</div>
<div>
<ArrowRight className="ml-3" />
</div>
</Link>
)
}

View File

@@ -1,27 +1,30 @@
// 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"
import React, { useCallback } from "react"
import { apiFetch } from "src/api"
import { NodeData } from "src/hooks/node-data"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
/**
* 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>("")
export default function LoginView({
data,
refreshData,
}: {
data: NodeData
refreshData: () => void
}) {
const login = useCallback(
(opt: TailscaleUpOptions) => {
tailscaleUp(opt).then(refreshData)
},
[refreshData]
)
return (
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<TailscaleIcon className="my-2 mb-8" />
{data.Status === "Stopped" ? (
{data.Status == "Stopped" ? (
<>
<div className="mb-6">
<h3 className="text-3xl font-semibold mb-3">Connect</h3>
@@ -29,40 +32,35 @@ export default function LoginView({ data }: { data: NodeData }) {
Your device is disconnected from Tailscale.
</p>
</div>
<Button
onClick={() => api({ action: "up", data: {} })}
className="w-full mb-4"
intent="primary"
<button
onClick={() => login({})}
className="button button-blue w-full mb-4"
>
Connect to Tailscale
</Button>
</button>
</>
) : data.IPv4 ? (
) : data.IP ? (
<>
<div className="mb-6">
<p className="text-gray-700">
Your devices key has expired. Reauthenticate this device by
Your device's key has expired. Reauthenticate this device by
logging in again, or{" "}
<a
href="https://tailscale.com/kb/1028/key-expiry"
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"
<button
onClick={() => login({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
>
Reauthenticate
</Button>
</button>
</>
) : (
<>
@@ -71,63 +69,35 @@ export default function LoginView({ data }: { data: NodeData }) {
<p className="text-gray-700">
Get started by logging in to your Tailscale network.
Or,&nbsp;learn&nbsp;more at{" "}
<a
href="https://tailscale.com/"
className="link"
target="_blank"
rel="noreferrer"
>
<a href="https://tailscale.com/" className="link" target="_blank">
tailscale.com
</a>
.
</p>
</div>
<Button
onClick={() =>
api({
action: "up",
data: {
Reauthenticate: true,
ControlURL: controlURL,
AuthKey: authKey,
},
})
}
className="w-full mb-4"
intent="primary"
<button
onClick={() => login({ Reauthenticate: true })}
className="button button-blue w-full mb-4"
>
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 &rarr;
</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>
</button>
</>
)}
</div>
)
}
type TailscaleUpOptions = {
Reauthenticate?: boolean // force reauthentication
}
function tailscaleUp(options: TailscaleUpOptions) {
return apiFetch("/up", "POST", options)
.then((r) => r.json())
.then((d) => {
d.url && window.open(d.url, "_blank")
})
.catch((e) => {
console.error("Failed to login:", e)
})
}

View File

@@ -1,23 +1,16 @@
// 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 { PrefsUpdate } from "src/hooks/node-data"
import Toggle from "src/ui/toggle"
export default function SSHView({
readonly,
node,
runningSSH,
updatePrefs,
}: {
readonly: boolean
node: NodeData
runningSSH: boolean
updatePrefs: (p: PrefsUpdate) => Promise<void>
}) {
const api = useAPI()
return (
<>
<h1 className="mb-1">Tailscale SSH server</h1>
@@ -26,56 +19,33 @@ export default function SSHView({
your tailnet to SSH into it.{" "}
<a
href="https://tailscale.com/kb/1193/tailscale-ssh/"
className="text-blue-700"
className="text-indigo-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</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}
<div className="-mx-5 px-4 py-3 bg-white rounded-lg border border-gray-200 flex gap-2.5 mb-3">
<Toggle
checked={runningSSH}
onChange={() => updatePrefs({ RunSSHSet: true, RunSSH: !runningSSH })}
disabled={readonly}
/>
<div className="text-black text-sm font-medium leading-tight">
Run Tailscale SSH server
</div>
</div>
<p className="text-neutral-500 text-sm leading-tight">
Remember to make sure that the{" "}
<a
href="https://login.tailscale.com/admin/acls/"
className="text-indigo-700"
target="_blank"
>
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>
)}
tailnet policy file
</a>{" "}
allows other devices to SSH into this device.
</p>
</>
)
}

View File

@@ -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 &rarr;
</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 machines 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>
)
}

View File

@@ -1,15 +1,14 @@
// 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 {
UpdateState,
useInstallUpdate,
VersionInfo,
} from "src/hooks/self-update"
import { ReactComponent as CheckCircleIcon } from "src/icons/check-circle.svg"
import { ReactComponent as XCircleIcon } from "src/icons/x-circle.svg"
import Spinner from "src/ui/spinner"
import { useLocation } from "wouter"
import { Link } from "wouter"
/**
* UpdatingView is rendered when the user initiates a Tailscale update, and
@@ -22,7 +21,6 @@ export function UpdatingView({
versionInfo?: VersionInfo
currentVersion: string
}) {
const [, setLocation] = useLocation()
const { updateState, updateLog } = useInstallUpdate(
currentVersion,
versionInfo
@@ -35,7 +33,7 @@ export function UpdatingView({
<Spinner size="sm" className="text-gray-400" />
<h1 className="text-2xl m-3">Update in progress</h1>
<p className="text-gray-400">
The update shouldnt take more than a couple of minutes. Once its
The update shouldn't take more than a couple of minutes. Once it's
completed, you will be asked to log in again.
</p>
</>
@@ -50,13 +48,9 @@ export function UpdatingView({
: null}
. <ChangelogText version={versionInfo?.LatestVersion} />
</p>
<Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
<Link className="button button-blue text-sm m-3" to="/">
Log in to access
</Button>
</Link>
</>
) : updateState === UpdateState.UpToDate ? (
<>
@@ -66,13 +60,9 @@ export function UpdatingView({
You are already running Tailscale {currentVersion}, which is the
newest version available.
</p>
<Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
<Link className="button button-blue text-sm m-3" to="/">
Return
</Button>
</Link>
</>
) : (
/* TODO(naman,sonia): Figure out the body copy and design for this view. */
@@ -86,13 +76,9 @@ export function UpdatingView({
: null}{" "}
failed.
</p>
<Button
className="m-3"
sizeVariant="small"
onClick={() => setLocation("/")}
>
<Link className="button button-blue text-sm m-3" to="/">
Return
</Button>
</Link>
</>
)}
<pre className="h-64 overflow-scroll m-3">

View File

@@ -1,73 +1,45 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useState } from "react"
import { apiFetch, setSynoToken } from "src/api"
export enum AuthType {
synology = "synology",
tailscale = "tailscale",
}
export type AuthResponse = {
serverMode: AuthServerMode
authorized: boolean
authNeeded?: AuthType
canManageNode: boolean
viewerIdentity?: {
loginName: string
nodeName: string
nodeIP: string
profilePicUrl?: string
capabilities: { [key in PeerCapability]: boolean }
}
needsSynoAuth?: boolean
}
export type AuthServerMode = "login" | "readonly" | "manage"
export type PeerCapability = "*" | "ssh" | "subnets" | "exitnodes" | "account"
/**
* canEdit reports whether the given auth response specifies that the viewer
* has the ability to edit the given capability.
*/
export function canEdit(cap: PeerCapability, auth: AuthResponse): boolean {
if (!auth.authorized || !auth.viewerIdentity) {
return false
}
if (auth.viewerIdentity.capabilities["*"] === true) {
return true // can edit all features
}
return auth.viewerIdentity.capabilities[cap] === true
}
/**
* hasAnyEditCapabilities reports whether the given auth response specifies
* that the viewer has at least one edit capability. If this is true, the
* user is able to go through the auth flow to authenticate a management
* session.
*/
export function hasAnyEditCapabilities(auth: AuthResponse): boolean {
return Object.values(auth.viewerIdentity?.capabilities || {}).includes(true)
}
/**
* useAuth reports and refreshes Tailscale auth status for the web client.
*/
// useAuth reports and refreshes Tailscale auth status
// for the web client.
export default function useAuth() {
const [data, setData] = useState<AuthResponse>()
const [loading, setLoading] = useState<boolean>(true)
const [ranSynoAuth, setRanSynoAuth] = useState<boolean>(false)
const loadAuth = useCallback(() => {
setLoading(true)
return apiFetch<AuthResponse>("/auth", "GET")
return apiFetch("/auth", "GET")
.then((r) => r.json())
.then((d) => {
setData(d)
if (d.needsSynoAuth) {
fetch("/webman/login.cgi")
.then((r) => r.json())
.then((a) => {
setSynoToken(a.SynoToken)
setRanSynoAuth(true)
setLoading(false)
})
} else {
setLoading(false)
switch ((d as AuthResponse).authNeeded) {
case AuthType.synology:
fetch("/webman/login.cgi")
.then((r) => r.json())
.then((a) => {
setSynoToken(a.SynoToken)
setLoading(false)
})
break
default:
setLoading(false)
}
return d
})
@@ -78,43 +50,31 @@ export default function useAuth() {
}, [])
const newSession = useCallback(() => {
return apiFetch<{ authUrl?: string }>("/auth/session/new", "GET")
return apiFetch("/auth/session/new", "GET")
.then((r) => r.json())
.then((d) => {
if (d.authUrl) {
window.open(d.authUrl, "_blank")
return apiFetch("/auth/session/wait", "GET")
// refresh data when auth complete
apiFetch("/auth/session/wait", "GET").then(() => loadAuth())
}
})
.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"
!d.canManageNode &&
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,

View File

@@ -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,
}

View File

@@ -0,0 +1,169 @@
import { useCallback, useEffect, useState } from "react"
import { apiFetch, setUnraidCsrfToken } from "src/api"
import { VersionInfo } from "src/hooks/self-update"
export type NodeData = {
Profile: UserProfile
Status: NodeState
DeviceName: string
OS: string
IP: string
IPv6: string
ID: string
KeyExpiry: string
KeyExpired: boolean
AdvertiseExitNode: boolean
AdvertiseRoutes: string
LicensesURL: string
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
}
type NodeState =
| "NoState"
| "NeedsLogin"
| "NeedsMachineAuth"
| "Stopped"
| "Starting"
| "Running"
export type UserProfile = {
LoginName: string
DisplayName: string
ProfilePicURL: string
}
export type NodeUpdate = {
AdvertiseRoutes?: string
AdvertiseExitNode?: boolean
}
export type PrefsUpdate = {
RunSSHSet?: boolean
RunSSH?: 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,
}
return apiFetch("/data", "POST", update, { up: "true" })
.then((r) => r.json())
.then((r) => {
setIsPosting(false)
const err = r["error"]
if (err) {
throw new Error(err)
}
refreshData()
})
.catch((err) => {
setIsPosting(false)
alert("Failed operation: " + err.message)
throw err
})
},
[data]
)
const updatePrefs = useCallback(
(p: PrefsUpdate) => {
setIsPosting(true)
if (data) {
const optimisticUpdates = data
if (p.RunSSHSet) {
optimisticUpdates.RunningSSHServer = Boolean(p.RunSSH)
}
// Reflect the pref change immediatley on the frontend,
// then make the prefs PATCH. If the request fails,
// data will be updated to it's previous value in
// onComplete below.
setData(optimisticUpdates)
}
const onComplete = () => {
setIsPosting(false)
refreshData() // refresh data after PATCH finishes
}
return apiFetch("/local/v0/prefs", "PATCH", p)
.then(onComplete)
.catch(() => {
onComplete()
alert("Failed to update prefs")
})
},
[setIsPosting, refreshData, setData, 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, updatePrefs, isPosting }
}

View File

@@ -1,9 +1,12 @@
// 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"
// this type is deserialized from tailcfg.ClientVersion,
// so it should not include fields not included in that type.
export type VersionInfo = {
RunningLatest: boolean
LatestVersion?: string
}
// see ipnstate.UpdateProgress
export type UpdateProgress = {
@@ -23,8 +26,15 @@ export enum UpdateState {
// 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) {
if (!cv) {
return {
updateState: UpdateState.UpToDate,
updateLog: "",
}
}
const [updateState, setUpdateState] = useState<UpdateState>(
cv?.RunningLatest ? UpdateState.UpToDate : UpdateState.Available
cv.RunningLatest ? UpdateState.UpToDate : UpdateState.Available
)
const [updateLog, setUpdateLog] = useState<string>("")
@@ -52,11 +62,12 @@ export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) {
let tsAwayForPolls = 0
let updateMessagesRead = 0
let timer: NodeJS.Timeout | undefined
let timer = 0
function poll() {
apiFetch<UpdateProgress[]>("/local/v0/update/progress", "GET")
.then((res) => {
apiFetch("/local/v0/update/progress", "GET")
.then((res) => res.json())
.then((res: UpdateProgress[]) => {
// res contains a list of UpdateProgresses that is strictly increasing
// in size, so updateMessagesRead keeps track (across calls of poll())
// of how many of those we have already read. This is why it is not
@@ -116,12 +127,9 @@ export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) {
// useEffect cleanup function
return () => {
if (timer) clearTimeout(timer)
timer = undefined
timer = 0
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return !cv
? { updateState: UpdateState.UpToDate, updateLog: "" }
: { updateState, updateLog }
return { updateState, updateLog }
}

View File

@@ -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

View File

@@ -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 }
}

View File

Before

Width:  |  Height:  |  Size: 324 B

After

Width:  |  Height:  |  Size: 324 B

View File

Before

Width:  |  Height:  |  Size: 522 B

After

Width:  |  Height:  |  Size: 522 B

View File

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 704 B

View File

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 236 B

View File

Before

Width:  |  Height:  |  Size: 203 B

After

Width:  |  Height:  |  Size: 203 B

View 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

View File

Before

Width:  |  Height:  |  Size: 738 B

After

Width:  |  Height:  |  Size: 738 B

View File

Before

Width:  |  Height:  |  Size: 500 B

After

Width:  |  Height:  |  Size: 500 B

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Before

Width:  |  Height:  |  Size: 635 B

After

Width:  |  Height:  |  Size: 635 B

View File

Before

Width:  |  Height:  |  Size: 506 B

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -3,193 +3,37 @@
@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;
}
:root {
--color-white: 255 255 255;
--color-gray-0: 250 249 248;
--color-gray-50: 249 247 246;
--color-gray-100: 247 245 244;
--color-gray-200: 238 235 234;
--color-gray-300: 218 214 213;
--color-gray-400: 175 172 171;
--color-gray-500: 112 110 109;
--color-gray-600: 68 67 66;
--color-gray-700: 46 45 45;
--color-gray-800: 35 34 34;
--color-gray-900: 31 30 30;
--color-red-0: 255 246 244;
--color-red-50: 255 211 207;
--color-red-100: 255 177 171;
--color-red-200: 246 143 135;
--color-red-300: 228 108 99;
--color-red-400: 208 72 65;
--color-red-500: 178 45 48;
--color-red-600: 148 8 33;
--color-red-700: 118 0 18;
--color-red-800: 90 0 0;
--color-red-900: 66 0 0;
--color-yellow-0: 252 249 233;
--color-yellow-50: 248 229 185;
--color-yellow-100: 239 192 120;
--color-yellow-200: 229 153 62;
--color-yellow-300: 217 121 23;
--color-yellow-400: 187 85 4;
--color-yellow-500: 152 55 5;
--color-yellow-600: 118 43 11;
--color-yellow-700: 87 31 13;
--color-yellow-800: 58 22 7;
--color-yellow-900: 58 22 7;
--color-orange-0: 255 250 238;
--color-orange-50: 254 227 192;
--color-orange-100: 248 184 134;
--color-orange-200: 245 146 94;
--color-orange-300: 229 111 74;
--color-orange-400: 196 76 52;
--color-orange-500: 158 47 40;
--color-orange-600: 126 30 35;
--color-orange-700: 93 22 27;
--color-orange-800: 66 14 17;
--color-orange-900: 66 14 17;
--color-green-0: 239 255 237;
--color-green-50: 203 244 201;
--color-green-100: 133 217 150;
--color-green-200: 51 194 127;
--color-green-300: 30 166 114;
--color-green-400: 9 130 93;
--color-green-500: 14 98 69;
--color-green-600: 13 75 59;
--color-green-700: 11 55 51;
--color-green-800: 8 36 41;
--color-green-900: 8 36 41;
--color-blue-0: 240 245 255;
--color-blue-50: 206 222 253;
--color-blue-100: 173 199 252;
--color-blue-200: 133 170 245;
--color-blue-300: 108 148 236;
--color-blue-400: 90 130 222;
--color-blue-500: 75 112 204;
--color-blue-600: 63 93 179;
--color-blue-700: 50 73 148;
--color-blue-800: 37 53 112;
--color-blue-900: 25 34 74;
--color-text-base: rgb(var(--color-gray-800) / 1);
--color-text-muted: rgb(var(--color-gray-500) / 1);
--color-text-disabled: rgb(var(--color-gray-400) / 1);
--color-text-primary: rgb(var(--color-blue-600) / 1);
--color-text-warning: rgb(var(--color-orange-600) / 1);
--color-text-danger: rgb(var(--color-red-600) / 1);
--color-bg-app: rgb(var(--color-gray-100) / 1);
--color-bg-menu-item-hover: rgb(var(--color-gray-100) / 1);
--color-border-base: rgb(var(--color-gray-200) / 1);
}
html,
body,
#app-root {
min-height: 100vh;
}
body {
@apply text-text-base font-sans w-full antialiased;
font-size: 16px;
line-height: 1.4;
letter-spacing: -0.015em; /* Inter is a little loose by default */
text-rendering: optimizeLegibility;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
::selection {
background-color: rgba(97, 122, 255, 0.2);
}
strong {
@apply font-semibold;
}
button {
text-align: inherit; /* don't center buttons by default */
letter-spacing: inherit; /* inherit existing letter spacing, rather than using browser defaults */
vertical-align: top; /* fix alignment of display: inline-block buttons */
}
a:focus,
button:focus {
outline: none;
}
a:focus-visible,
button:focus-visible {
outline: auto;
}
h1 {
@apply text-gray-800 text-[22px] font-medium leading-[30.80px];
@apply text-neutral-800 text-[22px] font-medium leading-[30.80px];
}
h2 {
@apply text-gray-500 text-sm font-medium uppercase leading-tight tracking-wide;
@apply text-neutral-500 text-sm font-medium uppercase leading-tight tracking-wide;
}
}
@layer components {
.details-card h1 {
@apply text-gray-800 text-lg font-medium leading-snug;
.card {
@apply p-5 bg-white rounded-lg border border-gray-200;
}
.details-card h2 {
@apply text-gray-500 text-xs font-semibold uppercase tracking-wide;
.card h1 {
@apply text-neutral-800 text-lg font-medium leading-snug;
}
.details-card table {
@apply w-full;
.card h2 {
@apply text-neutral-500 text-xs font-semibold uppercase tracking-wide;
}
.details-card tbody {
.card tbody {
@apply flex flex-col gap-2;
}
.details-card tr {
@apply grid grid-flow-col grid-cols-3 gap-2;
.card td:first-child {
@apply w-40 text-neutral-500 text-sm leading-tight flex-shrink-0;
}
.details-card td:first-child {
@apply text-gray-500 text-sm leading-tight truncate;
}
.details-card td:last-child {
@apply col-span-2 text-gray-800 text-sm leading-tight;
.card td:last-child {
@apply text-neutral-800 text-sm leading-tight;
}
.description {
@apply text-gray-500 leading-snug;
@apply text-neutral-500 leading-snug
}
/**
@@ -197,21 +41,21 @@
* You can use the -large and -small modifiers for size variants.
*/
.toggle {
@apply appearance-none relative w-10 h-5 rounded-full bg-gray-300 cursor-pointer;
@apply appearance-none relative w-10 h-5 rounded-full bg-neutral-300 cursor-pointer;
transition: background-color 200ms ease-in-out;
}
.toggle:disabled {
@apply bg-gray-200;
@apply bg-neutral-200;
@apply cursor-not-allowed;
}
.toggle:checked {
@apply bg-blue-500;
@apply bg-indigo-500;
}
.toggle:checked:disabled {
@apply bg-blue-300;
@apply bg-indigo-300;
}
.toggle:focus {
@@ -230,7 +74,7 @@
}
.toggle:checked:disabled::after {
@apply bg-blue-50;
@apply bg-indigo-50;
}
.toggle:enabled:active::after {
@@ -288,177 +132,151 @@
.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.
*/
/**
* Non-Tailwind styles begin here.
*/
.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;
}
.bg-gray-0 {
--tw-bg-opacity: 1;
background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
}
.button-group {
@apply whitespace-nowrap;
}
.bg-gray-50 {
--tw-bg-opacity: 1;
background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
}
.button-group .button {
@apply min-w-[60px];
}
html {
letter-spacing: -0.015em;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.button-group .button:not(:first-child) {
@apply rounded-l-none;
}
.link {
--text-opacity: 1;
color: #4b70cc;
color: rgba(75, 112, 204, var(--text-opacity));
}
.button-group .button:not(:last-child) {
@apply rounded-r-none border-r-0;
}
.link:hover,
.link:active {
--text-opacity: 1;
color: #19224a;
color: rgba(25, 34, 74, var(--text-opacity));
}
/**
* .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.
*/
.link-underline {
text-decoration: underline;
}
.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;
}
.link-underline:hover,
.link-underline:active {
text-decoration: none;
}
.input {
@apply px-3;
}
.link-muted {
/* same as text-gray-500 */
--tw-text-opacity: 1;
color: rgba(112, 110, 109, var(--tw-text-opacity));
}
.input::placeholder,
.input-wrapper::placeholder {
@apply text-gray-400;
}
.link-muted:hover,
.link-muted:active {
/* same as text-gray-500 */
--tw-text-opacity: 1;
color: rgba(68, 67, 66, var(--tw-text-opacity));
}
.input:disabled,
.input-wrapper:disabled {
@apply border-gray-300;
@apply bg-gray-0;
@apply cursor-not-allowed;
}
.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;
}
.input:focus,
.input-wrapper:focus-within {
@apply outline-none ring border-gray-400;
}
.button:focus {
outline: 0;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
}
.input-error {
@apply border-red-200;
}
.button:disabled {
cursor: not-allowed;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
/**
* .loading-dots creates a set of three dots that pulse for indicating loading
* states where a more horizontal appearance is helpful.
*/
.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));
}
.loading-dots {
@apply inline-flex items-center;
}
.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));
}
.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;
}
.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));
}
.loading-dots span:nth-child(2) {
animation-delay: 200ms;
}
.button-red {
background-color: #d04841;
border-color: #d04841;
color: #fff;
}
.loading-dots span:nth-child(3) {
animation-delay: 400ms;
}
.button-red:enabled:hover {
background-color: #b22d30;
border-color: #b22d30;
}
@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);
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
.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;
100% {
transform: rotate(360deg);
}
}
@layer utilities {
.h-input {
@apply h-[2.375rem];
}
.spinner {
@apply border-transparent border-t-current border-l-current rounded-full;
animation: spin 700ms linear infinite;
}

View File

@@ -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>
)

View File

@@ -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
}

View File

@@ -1,6 +1,3 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { HTMLAttributes } from "react"

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -1,10 +1,6 @@
// 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
@@ -104,3 +100,7 @@ export default function Popover(props: Props) {
Popover.defaultProps = {
sideOffset: 10,
}
const PortalContainerContext = React.createContext<HTMLElement | undefined>(
undefined
)

View File

@@ -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

View File

@@ -1,6 +1,3 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React from "react"

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -1,6 +1,3 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { HTMLAttributes } from "react"

View File

@@ -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)
)
}

View File

@@ -1,6 +1,3 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { ChangeEvent } from "react"

View File

@@ -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()
}

View File

@@ -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()
})
})

View File

@@ -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:"
}

View File

@@ -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>)"
}
}
}

View File

@@ -1,117 +1,12 @@
import plugin from "tailwindcss/plugin"
import styles from "./styles.json"
const config = {
theme: {
screens: {
sm: "420px",
md: "768px",
lg: "1024px",
},
fontFamily: {
sans: [
"Inter",
"-apple-system",
"BlinkMacSystemFont",
"Helvetica",
"Arial",
"sans-serif",
],
mono: [
"SFMono-Regular",
"SFMono Regular",
"Consolas",
"Liberation Mono",
"Menlo",
"Courier",
"monospace",
],
},
fontWeight: {
normal: "400",
medium: "500",
semibold: "600",
bold: "700",
},
colors: styles.colors,
extend: {
colors: {
...styles.colors,
"bg-app": "var(--color-bg-app)",
"bg-menu-item-hover": "var(--color-bg-menu-item-hover)",
"border-base": "var(--color-border-base)",
"text-base": "var(--color-text-base)",
"text-muted": "var(--color-text-muted)",
"text-disabled": "var(--color-text-disabled)",
"text-primary": "var(--color-text-primary)",
"text-warning": "var(--color-text-warning)",
"text-danger": "var(--color-text-danger)",
},
borderColor: {
DEFAULT: "var(--color-border-base)",
},
boxShadow: {
dialog: "0 10px 40px rgba(0,0,0,0.12), 0 0 16px rgba(0,0,0,0.08)",
form: "0 1px 1px rgba(0, 0, 0, 0.04)",
soft: "0 4px 12px 0 rgba(0, 0, 0, 0.03)",
popover:
"0 0 0 1px rgba(136, 152, 170, 0.1), 0 15px 35px 0 rgba(49, 49, 93, 0.1), 0 5px 15px 0 rgba(0, 0, 0, 0.08)",
},
animation: {
"scale-in": "scale-in 120ms cubic-bezier(0.16, 1, 0.3, 1)",
"scale-out": "scale-out 120ms cubic-bezier(0.16, 1, 0.3, 1)",
},
transformOrigin: {
"radix-hovercard": "var(--radix-hover-card-content-transform-origin)",
"radix-popover": "var(--radix-popover-content-transform-origin)",
"radix-tooltip": "var(--radix-tooltip-content-transform-origin)",
},
keyframes: {
"scale-in": {
"0%": {
transform: "scale(0.94)",
opacity: "0",
},
"100%": {
transform: "scale(1)",
opacity: "1",
},
},
"scale-out": {
"0%": {
transform: "scale(1)",
opacity: "1",
},
"100%": {
transform: "scale(0.94)",
opacity: "0",
},
},
},
},
},
plugins: [
plugin(function ({ addVariant }) {
addVariant("state-open", [
"&[data-state=“open”]",
"[data-state=“open”] &",
])
addVariant("state-closed", [
"&[data-state=“closed”]",
"[data-state=“closed”] &",
])
addVariant("state-delayed-open", [
"&[data-state=“delayed-open”]",
"[data-state=“delayed-open”] &",
])
addVariant("state-active", ["&[data-state=“active”]"])
addVariant("state-inactive", ["&[data-state=“inactive”]"])
}),
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
content: ["./src/**/*.html", "./src/**/*.{ts,tsx}", "./index.html"],
theme: {
extend: {},
},
plugins: [],
}
export default config

View File

@@ -5,7 +5,6 @@
"module": "ES2020",
"strict": true,
"sourceMap": true,
"skipLibCheck": true,
"isolatedModules": true,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,

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