Compare commits
170 Commits
marwan/dis
...
will/webcl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f1db73444 | ||
|
|
ea9c7f991a | ||
|
|
4ce33c9758 | ||
|
|
7df9af2f5c | ||
|
|
20f3f706a4 | ||
|
|
05093ea7d9 | ||
|
|
953fa80c6f | ||
|
|
569b91417f | ||
|
|
e26ee6952f | ||
|
|
7b113a2d06 | ||
|
|
d96e0a553f | ||
|
|
55d302b48e | ||
|
|
133699284e | ||
|
|
c05c4bdce4 | ||
|
|
d50303bef7 | ||
|
|
35c303227a | ||
|
|
dbe70962b1 | ||
|
|
d3574a350f | ||
|
|
aed2cfec4e | ||
|
|
46bdbb3878 | ||
|
|
29e98e18f8 | ||
|
|
124dc10261 | ||
|
|
d9aeb30281 | ||
|
|
10c595d962 | ||
|
|
3a9450bc06 | ||
|
|
5a2eb26db3 | ||
|
|
e32a064659 | ||
|
|
fa3639783c | ||
|
|
b084888e4d | ||
|
|
1f1ab74250 | ||
|
|
3d57c885bf | ||
|
|
1406a9d494 | ||
|
|
e72f2b7791 | ||
|
|
1d22265f69 | ||
|
|
5deeb56b95 | ||
|
|
5812093d31 | ||
|
|
cae6edf485 | ||
|
|
2716250ee8 | ||
|
|
c9836b454d | ||
|
|
2e956713de | ||
|
|
1302bd1181 | ||
|
|
3c333f6341 | ||
|
|
f815d66a88 | ||
|
|
01286af82b | ||
|
|
7a2eb22e94 | ||
|
|
09136e5995 | ||
|
|
65f2d32300 | ||
|
|
03f22cd9fa | ||
|
|
5e3126f510 | ||
|
|
0957258f84 | ||
|
|
865ee25a57 | ||
|
|
a661287c4b | ||
|
|
945cf836ee | ||
|
|
d05a572db4 | ||
|
|
38b4eb9419 | ||
|
|
dc2792aaee | ||
|
|
3fb6ee7fdb | ||
|
|
3a635db06e | ||
|
|
706e30d49e | ||
|
|
c6a274611e | ||
|
|
685b853763 | ||
|
|
3ae562366b | ||
|
|
1a08ea5990 | ||
|
|
b62a3fc895 | ||
|
|
727acf96a6 | ||
|
|
bac4890467 | ||
|
|
971fa8dc56 | ||
|
|
e00141ccbe | ||
|
|
e78cb9aeb3 | ||
|
|
4fb679d9cd | ||
|
|
869b34ddeb | ||
|
|
affe11c503 | ||
|
|
3ba5fd4baa | ||
|
|
be19262cc5 | ||
|
|
7370f3e3a7 | ||
|
|
77f5d669fa | ||
|
|
06af3e3014 | ||
|
|
808f19bf01 | ||
|
|
afe138f18d | ||
|
|
dd0279a6c9 | ||
|
|
8c2fcef453 | ||
|
|
343f4e4f26 | ||
|
|
1b7d289fad | ||
|
|
552b1ad094 | ||
|
|
1aee6e901d | ||
|
|
0cdc8e20d6 | ||
|
|
d2fbdb005d | ||
|
|
bc9b9e8f69 | ||
|
|
fc69301fd1 | ||
|
|
3aa6468c63 | ||
|
|
fb632036e3 | ||
|
|
4e012794fc | ||
|
|
763b9daa84 | ||
|
|
e9f203d747 | ||
|
|
501478dcdc | ||
|
|
970dc2a976 | ||
|
|
c2fe123232 | ||
|
|
109929d110 | ||
|
|
d8493d4bd5 | ||
|
|
1b1b6bb634 | ||
|
|
bac0df6949 | ||
|
|
5a2e6a6f7d | ||
|
|
a4c7b0574a | ||
|
|
69b56462fc | ||
|
|
c615fe2296 | ||
|
|
261b6f1e9f | ||
|
|
33de922d57 | ||
|
|
c2319f0dfa | ||
|
|
7c172df791 | ||
|
|
86aa0485a6 | ||
|
|
21958d2934 | ||
|
|
7bdea283bd | ||
|
|
ddb4b51122 | ||
|
|
e25f114916 | ||
|
|
2f01d5e3da | ||
|
|
6389322619 | ||
|
|
0f646937e9 | ||
|
|
646d17ac8d | ||
|
|
d5d42d0293 | ||
|
|
e5e5ebda44 | ||
|
|
97f8577ad2 | ||
|
|
9fd29f15c7 | ||
|
|
f706a3abd0 | ||
|
|
ef4f1e3a0b | ||
|
|
3f576fc4ca | ||
|
|
f5f21c213c | ||
|
|
97f84200ac | ||
|
|
95655405b8 | ||
|
|
014ae98297 | ||
|
|
1a4d423328 | ||
|
|
2731a9da36 | ||
|
|
af32d1c120 | ||
|
|
ac6f671c54 | ||
|
|
a54a4f757b | ||
|
|
cc6729a0bc | ||
|
|
4a24db852a | ||
|
|
137e9f4c46 | ||
|
|
d46a4eced5 | ||
|
|
aad5fb28b1 | ||
|
|
47db67fef5 | ||
|
|
a95b3cbfa8 | ||
|
|
650c67a0a1 | ||
|
|
0a59754eda | ||
|
|
215f657a5e | ||
|
|
94a64c0017 | ||
|
|
70f201c691 | ||
|
|
9095518c2d | ||
|
|
a217f1fccf | ||
|
|
c5208f8138 | ||
|
|
c4ccdd1bd1 | ||
|
|
6b083a8ddf | ||
|
|
9c4b73d77d | ||
|
|
9441a4e15d | ||
|
|
65643f6606 | ||
|
|
f5989f317f | ||
|
|
b144391c06 | ||
|
|
95e9d22a16 | ||
|
|
64a26b221b | ||
|
|
49fd0a62c9 | ||
|
|
263e01c47b | ||
|
|
c85532270f | ||
|
|
2003d1139f | ||
|
|
f9550e0bed | ||
|
|
5e125750bc | ||
|
|
f13255d54d | ||
|
|
7a4ba609d9 | ||
|
|
7d61b827e8 | ||
|
|
b155c7a091 | ||
|
|
db39a43f06 | ||
|
|
59d1077e28 |
39
.github/workflows/govulncheck.yml
vendored
39
.github/workflows/govulncheck.yml
vendored
@@ -22,17 +22,30 @@ jobs:
|
||||
- name: Scan source code for known vulnerabilities
|
||||
run: PATH=$PWD/tool/:$PATH "$(./tool/go env GOPATH)/bin/govulncheck" -test ./...
|
||||
|
||||
- uses: ruby/action-slack@v3.2.1
|
||||
with:
|
||||
payload: >
|
||||
{
|
||||
"attachments": [{
|
||||
"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 }}
|
||||
- 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 }}
|
||||
with:
|
||||
channel-id: 'C05PXRM304B'
|
||||
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 }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
7
.github/workflows/kubemanifests.yaml
vendored
7
.github/workflows/kubemanifests.yaml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- './cmd/k8s-operator/'
|
||||
- './k8s-operator/'
|
||||
- '.github/workflows/kubemanifests.yaml'
|
||||
|
||||
# Cancel workflow run if there is a newer push to the same PR for which it is
|
||||
@@ -22,3 +23,9 @@ 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)
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -429,7 +429,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go generate' is clean
|
||||
run: |
|
||||
pkgs=$(./tool/go list ./... | grep -v dnsfallback)
|
||||
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator')
|
||||
./tool/go generate $pkgs
|
||||
echo
|
||||
echo
|
||||
|
||||
40
.github/workflows/webclient.yml
vendored
Normal file
40
.github/workflows/webclient.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
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)
|
||||
@@ -6,6 +6,7 @@ linters:
|
||||
- bidichk
|
||||
- gofmt
|
||||
- goimports
|
||||
- govet
|
||||
- misspell
|
||||
- revive
|
||||
|
||||
@@ -35,6 +36,48 @@ linters-settings:
|
||||
|
||||
goimports:
|
||||
|
||||
govet:
|
||||
# Matches what we use in corp as of 2023-12-07
|
||||
enable:
|
||||
- asmdecl
|
||||
- assign
|
||||
- atomic
|
||||
- bools
|
||||
- buildtag
|
||||
- cgocall
|
||||
- copylocks
|
||||
- deepequalerrors
|
||||
- errorsas
|
||||
- framepointer
|
||||
- httpresponse
|
||||
- ifaceassert
|
||||
- loopclosure
|
||||
- lostcancel
|
||||
- nilfunc
|
||||
- nilness
|
||||
- printf
|
||||
- reflectvaluecompare
|
||||
- shift
|
||||
- sigchanyzer
|
||||
- sortslice
|
||||
- stdmethods
|
||||
- stringintconv
|
||||
- structtag
|
||||
- testinggoroutine
|
||||
- tests
|
||||
- unmarshal
|
||||
- unreachable
|
||||
- unsafeptr
|
||||
- unusedresult
|
||||
settings:
|
||||
printf:
|
||||
# List of print function names to check (in addition to default)
|
||||
funcs:
|
||||
- github.com/tailscale/tailscale/types/logger.Discard
|
||||
# NOTE(andrew-d): this doesn't currently work because the printf
|
||||
# analyzer doesn't support type declarations
|
||||
#- github.com/tailscale/tailscale/types/logger.Logf
|
||||
|
||||
misspell:
|
||||
|
||||
revive:
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.16
|
||||
3.18
|
||||
@@ -66,7 +66,7 @@ RUN GOARCH=$TARGETARCH go install -ldflags="\
|
||||
-X tailscale.com/version.gitCommitStamp=$VERSION_GIT_HASH" \
|
||||
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
|
||||
|
||||
FROM alpine:3.16
|
||||
FROM alpine:3.18
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
|
||||
COPY --from=build-env /go/bin/* /usr/local/bin/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
FROM alpine:3.16
|
||||
FROM alpine:3.18
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables iputils
|
||||
|
||||
24
Makefile
24
Makefile
@@ -9,13 +9,17 @@ vet: ## Run 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/derper \
|
||||
tailscale.com/cmd/stund
|
||||
|
||||
depaware: ## Run depaware checks
|
||||
# depaware (via x/tools/go/packages) shells back to "go", so make sure the "go"
|
||||
@@ -23,7 +27,8 @@ 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/derper \
|
||||
tailscale.com/cmd/stund
|
||||
|
||||
buildwindows: ## Build tailscale CLI for windows/amd64
|
||||
GOOS=windows GOARCH=amd64 ./tool/go install tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled
|
||||
@@ -51,6 +56,21 @@ 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}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.55.0
|
||||
1.57.0
|
||||
|
||||
59
api.md
59
api.md
@@ -60,6 +60,8 @@ The Tailscale API does not currently support pagination. All results are returne
|
||||
- Update tags: [`POST /api/v2/device/{deviceID}/tags`](#update-device-tags)
|
||||
- **Key**
|
||||
- Update device key: [`POST /api/v2/device/{deviceID}/key`](#update-device-key)
|
||||
- **IP Address**
|
||||
- Set device IPv4 address: [`POST /api/v2/device/{deviceID}/ip`](#set-device-ipv4-address)
|
||||
|
||||
**[Tailnet](#tailnet)**
|
||||
- [**Policy File**](#policy-file)
|
||||
@@ -277,6 +279,15 @@ You can also [list all devices in the tailnet](#list-tailnet-devices) to get the
|
||||
// tailnet lock is not enabled.
|
||||
// Learn more about tailnet lock at https://tailscale.com/kb/1226/.
|
||||
"tailnetLockKey": "",
|
||||
|
||||
// postureIdentity contains extra identifiers from the device when the tailnet
|
||||
// it is connected to has device posture identification collection enabled.
|
||||
// If the device has not opted-in to posture identification collection, this
|
||||
// will contain {"disabled": true}.
|
||||
// Learn more about posture identity at https://tailscale.com/kb/1326/device-identity
|
||||
"postureIdentity": {
|
||||
"serialNumbers": ["CP74LFQJXM"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -328,6 +339,7 @@ Currently, there are two supported options:
|
||||
- `enabledRoutes`
|
||||
- `advertisedRoutes`
|
||||
- `clientConnectivity` (which contains the following fields: `mappingVariesByDestIP`, `derp`, `endpoints`, `latency`, and `clientSupports`)
|
||||
- `postureIdentity`
|
||||
|
||||
### Request example
|
||||
|
||||
@@ -590,7 +602,7 @@ If the tags supplied in the `POST` call do not exist in the tailnet policy file,
|
||||
}
|
||||
```
|
||||
|
||||
<a href="device-key-post"><a>
|
||||
<a href="device-key-post"></a>
|
||||
|
||||
## Update device key
|
||||
|
||||
@@ -644,6 +656,51 @@ curl "https://api.tailscale.com/api/v2/device/11055/key" \
|
||||
|
||||
The response is 2xx on success. The response body is currently an empty JSON object.
|
||||
|
||||
## Set device IPv4 address
|
||||
|
||||
``` http
|
||||
POST /api/v2/device/{deviceID}/ip
|
||||
```
|
||||
|
||||
Set the Tailscale IPv4 address of the device.
|
||||
|
||||
### Parameters
|
||||
|
||||
#### `deviceid` (required in URL path)
|
||||
|
||||
The ID of the device.
|
||||
|
||||
#### `ipv4` (optional in `POST` body)
|
||||
|
||||
Provide a new IPv4 address for the device.
|
||||
|
||||
When a device is added to a tailnet, its Tailscale IPv4 address is set at random either from the CGNAT range, or a subset of the CGNAT range specified by an [ip pool](https://tailscale.com/kb/1304/ip-pool).
|
||||
This endpoint can be used to replace the existing IPv4 address with a specific value.
|
||||
|
||||
``` jsonc
|
||||
{
|
||||
"ipv4": "100.80.0.1"
|
||||
}
|
||||
```
|
||||
|
||||
This action will break any existing connections to this machine.
|
||||
You will need to reconnect to this machine using the new IP address.
|
||||
You may also need to flush your DNS cache.
|
||||
|
||||
This returns a 2xx code on success, with an empty JSON object in the response body.
|
||||
|
||||
### Request example
|
||||
|
||||
``` sh
|
||||
curl "https://api.tailscale.com/api/v2/device/11055/ip" \
|
||||
-u "tskey-api-xxxxx:" \
|
||||
--data-binary '{"ipv4": "100.80.0.1"}'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
The response is 2xx on success. The response body is currently an empty JSON object.
|
||||
|
||||
# Tailnet
|
||||
|
||||
A tailnet is your private network, composed of all the devices on it and their configuration.
|
||||
|
||||
@@ -206,9 +206,8 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
if slices.Contains(addrs, addr) {
|
||||
continue
|
||||
}
|
||||
// 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)
|
||||
e.logf("failed to advertise route for %s: %v: %v", domain, addr, err)
|
||||
continue
|
||||
}
|
||||
e.logf("[v2] advertised route for %v: %v", domain, addr)
|
||||
@@ -217,5 +216,4 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
e.domains[domain] = append(addrs, addr)
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ eval $(./build_dist.sh shellvars)
|
||||
|
||||
DEFAULT_TARGET="client"
|
||||
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
|
||||
DEFAULT_BASE="tailscale/alpine-base:3.16"
|
||||
DEFAULT_BASE="tailscale/alpine-base:3.18"
|
||||
|
||||
PUSH="${PUSH:-false}"
|
||||
TARGET="${TARGET:-${DEFAULT_TARGET}}"
|
||||
|
||||
@@ -71,6 +71,17 @@ 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.
|
||||
|
||||
@@ -102,8 +102,7 @@ func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string)
|
||||
return d.DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
|
||||
}
|
||||
}
|
||||
s := safesocket.DefaultConnectionStrategy(lc.socket())
|
||||
return safesocket.Connect(s)
|
||||
return safesocket.Connect(lc.socket())
|
||||
}
|
||||
|
||||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||||
@@ -1332,6 +1331,15 @@ 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.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -13,10 +14,13 @@ 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.
|
||||
@@ -25,19 +29,48 @@ 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) {
|
||||
_, err := fs.Stat(fsys, strings.TrimPrefix(r.URL.Path, "/"))
|
||||
if os.IsNotExist(err) {
|
||||
// rewrite request to just fetch /index.html and let
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
f, err := openPrecompressedFile(w, r, path, fsys)
|
||||
if err != nil {
|
||||
// Rewrite request to just fetch index.html and let
|
||||
// the frontend router handle it.
|
||||
r = r.Clone(r.Context())
|
||||
r.URL.Path = "/"
|
||||
path = "index.html"
|
||||
f, err = openPrecompressedFile(w, r, path, fsys)
|
||||
}
|
||||
fileserver.ServeHTTP(w, r)
|
||||
if f == nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// fs.File does not claim to implement Seeker, but in practice it does.
|
||||
fSeeker, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
http.Error(w, "Not seekable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "assets/") {
|
||||
// Aggressively cache static assets, since we cache-bust our assets with
|
||||
// hashed filenames.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31535996")
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, path, start, fSeeker)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func openPrecompressedFile(w http.ResponseWriter, r *http.Request, path string, fs fs.FS) (fs.File, error) {
|
||||
if f, err := fs.Open(path + ".gz"); err == nil {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
return f, nil
|
||||
}
|
||||
return fs.Open(path) // fallback
|
||||
}
|
||||
|
||||
// startDevServer starts the JS dev server that does on-demand rebuilding
|
||||
// and serving of web client JS and CSS resources.
|
||||
func startDevServer() (cleanup func()) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -102,48 +103,48 @@ var (
|
||||
//
|
||||
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
|
||||
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
|
||||
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
|
||||
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, *ipnstate.Status, error) {
|
||||
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
|
||||
switch {
|
||||
case whoIsErr != nil:
|
||||
return nil, nil, errNotUsingTailscale
|
||||
return nil, nil, status, errNotUsingTailscale
|
||||
case statusErr != nil:
|
||||
return nil, whoIs, statusErr
|
||||
return nil, whoIs, nil, statusErr
|
||||
case status.Self == nil:
|
||||
return nil, whoIs, errors.New("missing self node in tailscale status")
|
||||
return nil, whoIs, status, errors.New("missing self node in tailscale status")
|
||||
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
|
||||
return nil, whoIs, errTaggedLocalSource
|
||||
return nil, whoIs, status, errTaggedLocalSource
|
||||
case whoIs.Node.IsTagged():
|
||||
return nil, whoIs, errTaggedRemoteSource
|
||||
return nil, whoIs, status, errTaggedRemoteSource
|
||||
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
|
||||
return nil, whoIs, errNotOwner
|
||||
return nil, whoIs, status, errNotOwner
|
||||
}
|
||||
srcNode := whoIs.Node.ID
|
||||
srcUser := whoIs.UserProfile.ID
|
||||
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
return nil, whoIs, errNoSession
|
||||
return nil, whoIs, status, errNoSession
|
||||
} else if err != nil {
|
||||
return nil, whoIs, err
|
||||
return nil, whoIs, status, err
|
||||
}
|
||||
v, ok := s.browserSessions.Load(cookie.Value)
|
||||
if !ok {
|
||||
return nil, whoIs, errNoSession
|
||||
return nil, whoIs, status, errNoSession
|
||||
}
|
||||
session := v.(*browserSession)
|
||||
if session.SrcNode != srcNode || session.SrcUser != srcUser {
|
||||
// In this case the browser cookie is associated with another tailscale node.
|
||||
// Maybe the source browser's machine was logged out and then back in as a different node.
|
||||
// Return errNoSession because there is no session for this user.
|
||||
return nil, whoIs, errNoSession
|
||||
return nil, whoIs, status, errNoSession
|
||||
} else if session.isExpired(s.timeNow()) {
|
||||
// Session expired, remove from session map and return errNoSession.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return nil, whoIs, errNoSession
|
||||
return nil, whoIs, status, errNoSession
|
||||
}
|
||||
return session, whoIs, nil
|
||||
return session, whoIs, status, nil
|
||||
}
|
||||
|
||||
// newSession creates a new session associated with the given source user/node,
|
||||
@@ -189,7 +190,7 @@ func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
controlURL, err := url.Parse(prefs.ControlURL)
|
||||
controlURL, err := url.Parse(prefs.ControlURLOrDefault())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
<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" />
|
||||
|
||||
<script type="module" crossorigin src="./assets/index-4d1f45ea.js"></script>
|
||||
<link rel="stylesheet" href="./assets/index-8612dca6.css">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<body class="px-2">
|
||||
<noscript>
|
||||
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
|
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
|
||||
|
||||
@@ -8,20 +8,21 @@
|
||||
<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>
|
||||
<body class="px-2">
|
||||
<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 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.'
|
||||
rootEl.innerHTML = 'Tailscale web interface is unavailable.';
|
||||
document.body.append(rootEl)
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
"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",
|
||||
"wouter": "^2.11.0"
|
||||
"swr": "^2.2.4",
|
||||
"wouter": "^2.11.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.20",
|
||||
@@ -22,6 +25,7 @@
|
||||
"autoprefixer": "^10.4.15",
|
||||
"eslint": "^8.23.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"jsdom": "^23.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
@@ -59,5 +63,11 @@
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 80
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"tailwindcss": {},
|
||||
"autoprefixer": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,24 +1,273 @@
|
||||
// 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(
|
||||
/**
|
||||
* 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>(
|
||||
endpoint: string,
|
||||
method: "GET" | "POST" | "PATCH",
|
||||
body?: any,
|
||||
params?: Record<string, string>
|
||||
): Promise<Response> {
|
||||
body?: any
|
||||
): Promise<T> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const nextParams = new URLSearchParams(params)
|
||||
const nextParams = new URLSearchParams()
|
||||
if (synoToken) {
|
||||
nextParams.set("SynoToken", synoToken)
|
||||
} else {
|
||||
@@ -51,16 +300,26 @@ export function apiFetch(
|
||||
"Content-Type": contentType,
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
body,
|
||||
}).then((r) => {
|
||||
updateCsrfToken(r)
|
||||
if (!r.ok) {
|
||||
return r.text().then((err) => {
|
||||
throw new Error(err)
|
||||
})
|
||||
}
|
||||
return r
|
||||
body: body,
|
||||
})
|
||||
.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) {
|
||||
@@ -74,6 +333,45 @@ export function setSynoToken(token?: string) {
|
||||
synoToken = token
|
||||
}
|
||||
|
||||
export function setUnraidCsrfToken(token?: string) {
|
||||
function setUnraidCsrfToken(token?: string) {
|
||||
unraidCsrfToken = token
|
||||
}
|
||||
|
||||
/**
|
||||
* incrementMetric hits the client metrics local API endpoint to
|
||||
* increment the given counter metric by one.
|
||||
*/
|
||||
export function incrementMetric(metricName: MetricName) {
|
||||
const postData: MetricsPOSTData[] = [
|
||||
{
|
||||
Name: metricName,
|
||||
Type: "counter",
|
||||
Value: 1,
|
||||
},
|
||||
]
|
||||
|
||||
apiFetch("/local/v0/upload-client-metrics", "POST", postData).catch(
|
||||
(error) => {
|
||||
console.error(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
type MetricsPOSTData = {
|
||||
Name: MetricName
|
||||
Type: MetricType
|
||||
Value: number
|
||||
}
|
||||
|
||||
type MetricType = "counter" | "gauge"
|
||||
|
||||
export type MetricName =
|
||||
| "web_client_advertise_exitnode_enable"
|
||||
| "web_client_advertise_exitnode_disable"
|
||||
| "web_client_use_exitnode_enable"
|
||||
| "web_client_use_exitnode_disable"
|
||||
| "web_client_ssh_enable"
|
||||
| "web_client_ssh_disable"
|
||||
| "web_client_node_connect"
|
||||
| "web_client_node_disconnect"
|
||||
| "web_client_advertise_routes_change"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
4
client/web/src/assets/icons/copy.svg
Normal file
4
client/web/src/assets/icons/copy.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 649 B |
13
client/web/src/assets/icons/machine.svg
Normal file
13
client/web/src/assets/icons/machine.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
client/web/src/assets/icons/x.svg
Normal file
4
client/web/src/assets/icons/x.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 277 B |
133
client/web/src/components/address-copy-card.tsx
Normal file
133
client/web/src/components/address-copy-card.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
// 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 { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||
import { ReactComponent as Copy } from "src/assets/icons/copy.svg"
|
||||
import NiceIP from "src/components/nice-ip"
|
||||
import useToaster from "src/hooks/toaster"
|
||||
import Button from "src/ui/button"
|
||||
import { copyText } from "src/utils/clipboard"
|
||||
|
||||
/**
|
||||
* AddressCard renders a clickable IP address text that opens a
|
||||
* dialog with a copyable list of all addresses (IPv4, IPv6, DNS)
|
||||
* for the machine.
|
||||
*/
|
||||
export default function AddressCard({
|
||||
v4Address,
|
||||
v6Address,
|
||||
shortDomain,
|
||||
fullDomain,
|
||||
className,
|
||||
triggerClassName,
|
||||
}: {
|
||||
v4Address: string
|
||||
v6Address: string
|
||||
shortDomain?: string
|
||||
fullDomain?: string
|
||||
className?: string
|
||||
triggerClassName?: string
|
||||
}) {
|
||||
const children = (
|
||||
<ul className="flex flex-col divide-y rounded-md overflow-hidden">
|
||||
{shortDomain && <AddressRow label="short domain" value={shortDomain} />}
|
||||
{fullDomain && <AddressRow label="full domain" value={fullDomain} />}
|
||||
{v4Address && (
|
||||
<AddressRow
|
||||
key={v4Address}
|
||||
label="IPv4 address"
|
||||
ip={true}
|
||||
value={v4Address}
|
||||
/>
|
||||
)}
|
||||
{v6Address && (
|
||||
<AddressRow
|
||||
key={v6Address}
|
||||
label="IPv6 address"
|
||||
ip={true}
|
||||
value={v6Address}
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
|
||||
return (
|
||||
<Primitive.Root>
|
||||
<Primitive.Trigger asChild>
|
||||
<Button
|
||||
variant="minimal"
|
||||
className={cx("-ml-1 px-1 py-0 font-normal", className)}
|
||||
suffixIcon={
|
||||
<ChevronDown className="w-5 h-5" stroke="#232222" /* gray-800 */ />
|
||||
}
|
||||
aria-label="See all addresses for this device."
|
||||
>
|
||||
<NiceIP className={triggerClassName} ip={v4Address ?? v6Address} />
|
||||
</Button>
|
||||
</Primitive.Trigger>
|
||||
<Primitive.Content
|
||||
className="shadow-popover origin-radix-popover state-open:animate-scale-in state-closed:animate-scale-out bg-white rounded-md z-50 max-w-sm"
|
||||
sideOffset={10}
|
||||
side="top"
|
||||
>
|
||||
{children}
|
||||
</Primitive.Content>
|
||||
</Primitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function AddressRow({
|
||||
label,
|
||||
value,
|
||||
ip,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
ip?: boolean
|
||||
}) {
|
||||
const toaster = useToaster()
|
||||
const onCopyClick = useCallback(() => {
|
||||
copyText(value)
|
||||
.then(() => toaster.show({ message: `Copied ${label} to clipboard` }))
|
||||
.catch(() =>
|
||||
toaster.show({
|
||||
message: `Failed to copy ${label} to clipboard`,
|
||||
variant: "danger",
|
||||
})
|
||||
)
|
||||
}, [label, toaster, value])
|
||||
|
||||
return (
|
||||
<li className="py flex items-center gap-2">
|
||||
<button
|
||||
className={cx(
|
||||
"relative flex group items-center transition-colors",
|
||||
"focus:outline-none focus-visible:ring",
|
||||
"disabled:text-text-muted enabled:hover:text-gray-500",
|
||||
"w-60 text-sm flex-1"
|
||||
)}
|
||||
onClick={onCopyClick}
|
||||
aria-label={`Copy ${value} to your clip board.`}
|
||||
>
|
||||
<div className="overflow-hidden pl-3 pr-10 py-2 tabular-nums">
|
||||
{ip ? (
|
||||
<NiceIP ip={value} />
|
||||
) : (
|
||||
<div className="truncate m-w-full">{value}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cx(
|
||||
"absolute right-0 pl-6 pr-3 bg-gradient-to-r from-transparent",
|
||||
"text-gray-900 group-hover:text-gray-600"
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React, { useEffect } from "react"
|
||||
import React from "react"
|
||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||
import LoginToggle from "src/components/login-toggle"
|
||||
import DeviceDetailsView from "src/components/views/device-details-view"
|
||||
import DisconnectedView from "src/components/views/disconnected-view"
|
||||
import HomeView from "src/components/views/home-view"
|
||||
import LoginView from "src/components/views/login-view"
|
||||
import SSHView from "src/components/views/ssh-view"
|
||||
import SubnetRouterView from "src/components/views/subnet-router-view"
|
||||
import { UpdatingView } from "src/components/views/updating-view"
|
||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||
import useNodeData, { NodeData } from "src/hooks/node-data"
|
||||
import { Feature, featureDescription, NodeData } from "src/types"
|
||||
import Card from "src/ui/card"
|
||||
import EmptyState from "src/ui/empty-state"
|
||||
import LoadingDots from "src/ui/loading-dots"
|
||||
import useSWR from "swr"
|
||||
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-14 px-5">
|
||||
<main className="min-w-sm max-w-lg mx-auto py-4 sm:py-14 px-5">
|
||||
{loadingAuth || !auth ? (
|
||||
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
|
||||
<LoadingView />
|
||||
) : (
|
||||
<WebClient auth={auth} newSession={newSession} />
|
||||
)}
|
||||
@@ -35,57 +40,47 @@ function WebClient({
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const { data, refreshData, nodeUpdaters } = useNodeData()
|
||||
useEffect(() => {
|
||||
refreshData()
|
||||
}, [auth, refreshData])
|
||||
const { data: node } = useSWR<NodeData>("/data")
|
||||
|
||||
return !data ? (
|
||||
<div className="text-center py-14">Loading...</div>
|
||||
) : data.Status === "NeedsLogin" ||
|
||||
data.Status === "NoState" ||
|
||||
data.Status === "Stopped" ? (
|
||||
return !node ? (
|
||||
<LoadingView />
|
||||
) : node.Status === "NeedsLogin" ||
|
||||
node.Status === "NoState" ||
|
||||
node.Status === "Stopped" ? (
|
||||
// Client not on a tailnet, render login.
|
||||
<LoginView data={data} refreshData={refreshData} />
|
||||
<LoginView data={node} />
|
||||
) : (
|
||||
// Otherwise render the new web client.
|
||||
<>
|
||||
<Router base={data.URLPrefix}>
|
||||
<Header node={data} auth={auth} newSession={newSession} />
|
||||
<Router base={node.URLPrefix}>
|
||||
<Header node={node} auth={auth} newSession={newSession} />
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<HomeView
|
||||
readonly={!auth.canManageNode}
|
||||
node={data}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
/>
|
||||
<HomeView readonly={!auth.canManageNode} node={node} />
|
||||
</Route>
|
||||
<Route path="/details">
|
||||
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
|
||||
<DeviceDetailsView readonly={!auth.canManageNode} node={node} />
|
||||
</Route>
|
||||
<Route path="/subnets">
|
||||
<SubnetRouterView
|
||||
readonly={!auth.canManageNode}
|
||||
node={data}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/ssh">
|
||||
<SSHView
|
||||
readonly={!auth.canManageNode}
|
||||
node={data}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/serve">{/* TODO */}Share local content</Route>
|
||||
<Route path="/update">
|
||||
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
|
||||
<SubnetRouterView readonly={!auth.canManageNode} node={node} />
|
||||
</FeatureRoute>
|
||||
<FeatureRoute path="/ssh" feature="ssh" node={node}>
|
||||
<SSHView readonly={!auth.canManageNode} node={node} />
|
||||
</FeatureRoute>
|
||||
{/* <Route path="/serve">Share local content</Route> */}
|
||||
<FeatureRoute path="/update" feature="auto-update" node={node}>
|
||||
<UpdatingView
|
||||
versionInfo={data.ClientVersion}
|
||||
currentVersion={data.IPNVersion}
|
||||
versionInfo={node.ClientVersion}
|
||||
currentVersion={node.IPNVersion}
|
||||
/>
|
||||
</FeatureRoute>
|
||||
<Route path="/disconnected">
|
||||
<DisconnectedView />
|
||||
</Route>
|
||||
<Route>
|
||||
<h2 className="mt-8">Page not found</h2>
|
||||
<Card className="mt-8">
|
||||
<EmptyState description="Page not found" />
|
||||
</Card>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
@@ -93,6 +88,40 @@ 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,
|
||||
@@ -104,25 +133,37 @@ function Header({
|
||||
}) {
|
||||
const [loc] = useLocation()
|
||||
|
||||
if (loc === "/disconnected") {
|
||||
// No header on view presented after logout.
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between mb-12">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-wrap gap-4 justify-between items-center mb-9 md:mb-12">
|
||||
<Link to="/" className="flex gap-3 overflow-hidden">
|
||||
<TailscaleIcon />
|
||||
<div className="inline text-neutral-800 text-lg font-medium leading-snug">
|
||||
<div className="inline text-gray-800 text-lg font-medium leading-snug truncate">
|
||||
{node.DomainName}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<LoginToggle node={node} auth={auth} newSession={newSession} />
|
||||
</div>
|
||||
{loc !== "/" && loc !== "/update" && (
|
||||
<Link
|
||||
to="/"
|
||||
className="text-indigo-500 font-medium leading-snug block mb-[10px]"
|
||||
>
|
||||
<Link to="/" className="link font-medium block mb-2">
|
||||
← Back to {node.DeviceName}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LoadingView fills its container with small animated loading dots
|
||||
* in the center.
|
||||
*/
|
||||
export function LoadingView() {
|
||||
return (
|
||||
<LoadingDots className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { NodeData } from "src/types"
|
||||
|
||||
/**
|
||||
* AdminContainer renders its contents only if the node's control
|
||||
|
||||
@@ -2,32 +2,40 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useAPI } from "src/api"
|
||||
import { ReactComponent as Check } from "src/assets/icons/check.svg"
|
||||
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||
import useExitNodes, {
|
||||
ExitNode,
|
||||
noExitNode,
|
||||
runAsExitNode,
|
||||
trimDNSSuffix,
|
||||
} from "src/hooks/exit-nodes"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import { ExitNode, NodeData } from "src/types"
|
||||
import Popover from "src/ui/popover"
|
||||
import SearchInput from "src/ui/search-input"
|
||||
import { useSWRConfig } from "swr"
|
||||
|
||||
export default function ExitNodeSelector({
|
||||
className,
|
||||
node,
|
||||
nodeUpdaters,
|
||||
disabled,
|
||||
}: {
|
||||
className?: string
|
||||
node: NodeData
|
||||
nodeUpdaters: NodeUpdaters
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const api = useAPI()
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
|
||||
const [pending, setPending] = useState<boolean>(false)
|
||||
const { mutate } = useSWRConfig() // allows for global mutation
|
||||
useEffect(() => setSelected(toSelectedExitNode(node)), [node])
|
||||
useEffect(() => {
|
||||
setPending(
|
||||
node.AdvertisingExitNode && node.AdvertisingExitNodeApproved === false
|
||||
)
|
||||
}, [node])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(n: ExitNode) => {
|
||||
@@ -35,114 +43,148 @@ export default function ExitNodeSelector({
|
||||
if (n.ID === selected.ID) {
|
||||
return // no update
|
||||
}
|
||||
const old = selected
|
||||
setSelected(n) // optimistic UI update
|
||||
nodeUpdaters.postExitNode(n).catch(() => setSelected(old))
|
||||
// Eager clear of pending state to avoid UI oddities
|
||||
if (n.ID !== runAsExitNode.ID) {
|
||||
setPending(false)
|
||||
}
|
||||
api({ action: "update-exit-node", data: n })
|
||||
|
||||
// refresh data after short timeout to pick up any pending approval updates
|
||||
setTimeout(() => {
|
||||
mutate("/data")
|
||||
}, 1000)
|
||||
},
|
||||
[nodeUpdaters, selected]
|
||||
[api, mutate, selected.ID]
|
||||
)
|
||||
|
||||
const [
|
||||
none, // not using exit nodes
|
||||
advertising, // advertising as exit node
|
||||
using, // using another exit node
|
||||
offline, // selected exit node node is offline
|
||||
] = useMemo(
|
||||
() => [
|
||||
selected.ID === noExitNode.ID,
|
||||
selected.ID === runAsExitNode.ID,
|
||||
selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
|
||||
!selected.Online,
|
||||
],
|
||||
[selected]
|
||||
[selected.ID, selected.Online]
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={disabled ? false : open}
|
||||
onOpenChange={setOpen}
|
||||
side="bottom"
|
||||
sideOffset={5}
|
||||
align="start"
|
||||
alignOffset={8}
|
||||
content={
|
||||
<ExitNodeSelectorInner
|
||||
node={node}
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
}
|
||||
asChild
|
||||
<div
|
||||
className={cx(
|
||||
"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-amber-600 border-amber-600": advertising,
|
||||
"bg-indigo-500 border-indigo-500": using,
|
||||
},
|
||||
className
|
||||
)}
|
||||
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,
|
||||
})}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<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-neutral-800", {
|
||||
"text-white": advertising || using,
|
||||
})}
|
||||
>
|
||||
{selected.Location && (
|
||||
<>
|
||||
<CountryFlag code={selected.Location.CountryCode} />{" "}
|
||||
</>
|
||||
)}
|
||||
{selected === runAsExitNode
|
||||
? "Running as exit node"
|
||||
: selected.Name}
|
||||
</p>
|
||||
<ChevronDown
|
||||
className={cx("ml-1", {
|
||||
"stroke-neutral-800": none,
|
||||
"stroke-white": advertising || using,
|
||||
})}
|
||||
<Popover
|
||||
open={disabled ? false : open}
|
||||
onOpenChange={setOpen}
|
||||
className="overflow-hidden"
|
||||
side="bottom"
|
||||
sideOffset={0}
|
||||
align="start"
|
||||
content={
|
||||
<ExitNodeSelectorInner
|
||||
node={node}
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{(advertising || using) && (
|
||||
}
|
||||
asChild
|
||||
>
|
||||
<button
|
||||
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
|
||||
"bg-white": none,
|
||||
"hover:bg-gray-100": none && !disabled,
|
||||
"bg-yellow-300": advertising && !offline,
|
||||
"hover:bg-yellow-200": advertising && !offline && !disabled,
|
||||
"bg-blue-500": using && !offline,
|
||||
"hover:bg-blue-400": using && !offline && !disabled,
|
||||
"bg-red-500": offline,
|
||||
"hover:bg-red-400": offline && !disabled,
|
||||
})}
|
||||
onClick={() => setOpen(!open)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<p
|
||||
className={cx(
|
||||
"text-gray-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
|
||||
{ "opacity-70 text-white": advertising || using }
|
||||
)}
|
||||
>
|
||||
Exit node{offline && " offline"}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className={cx("text-gray-800", {
|
||||
"text-white": advertising || using,
|
||||
})}
|
||||
>
|
||||
{selected.Location && (
|
||||
<>
|
||||
<CountryFlag code={selected.Location.CountryCode} />{" "}
|
||||
</>
|
||||
)}
|
||||
{selected === runAsExitNode
|
||||
? "Running as exit node"
|
||||
: selected.Name}
|
||||
</p>
|
||||
{!disabled && (
|
||||
<ChevronDown
|
||||
className={cx("ml-1", {
|
||||
"stroke-gray-800": none,
|
||||
"stroke-white": advertising || using,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Popover>
|
||||
{!disabled && (advertising || using) && (
|
||||
<button
|
||||
className={cx("px-3 py-2 rounded-sm text-white", {
|
||||
"bg-orange-400": advertising,
|
||||
"bg-indigo-400": using,
|
||||
"cursor-not-allowed": disabled,
|
||||
"hover:bg-yellow-200": advertising && !offline,
|
||||
"hover:bg-blue-400": using && !offline,
|
||||
"hover:bg-red-400": offline,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleSelect(noExitNode)
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
{offline && (
|
||||
<p className="text-white p-3">
|
||||
The selected exit node is currently offline. Your internet traffic is
|
||||
blocked until you disable the exit node or select a different one.
|
||||
</p>
|
||||
)}
|
||||
{pending && (
|
||||
<p className="text-white p-3">
|
||||
Pending approval to run as exit node. This device won't be usable as
|
||||
an exit node until then.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -175,7 +217,7 @@ function ExitNodeSelectorInner({
|
||||
onSelect: (node: ExitNode) => void
|
||||
}) {
|
||||
const [filter, setFilter] = useState<string>("")
|
||||
const { data: exitNodes } = useExitNodes(node.TailnetName, filter)
|
||||
const { data: exitNodes } = useExitNodes(node, filter)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const hasNodes = useMemo(
|
||||
@@ -184,10 +226,12 @@ function ExitNodeSelectorInner({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-[calc(var(--radix-popover-trigger-width)-16px)] py-1 rounded-lg shadow">
|
||||
<div className="w-[var(--radix-popover-trigger-width)]">
|
||||
<SearchInput
|
||||
name="exit-node-search"
|
||||
inputClassName="w-full px-4 py-2"
|
||||
className="px-2"
|
||||
inputClassName="w-full py-3 !h-auto border-none rounded-b-none !ring-0"
|
||||
autoFocus
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
@@ -202,7 +246,7 @@ function ExitNodeSelectorInner({
|
||||
{/* TODO(sonia): use loading spinner when loading useExitNodes */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="pt-1 border-t border-gray-200 max-h-64 overflow-y-scroll"
|
||||
className="pt-1 border-t border-gray-200 max-h-60 overflow-y-scroll"
|
||||
>
|
||||
{hasNodes ? (
|
||||
exitNodes.map(
|
||||
@@ -210,10 +254,10 @@ function ExitNodeSelectorInner({
|
||||
group.nodes.length > 0 && (
|
||||
<div
|
||||
key={group.id}
|
||||
className="pb-1 mb-1 border-b last:border-b-0 last:mb-0"
|
||||
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-neutral-500 text-xs font-medium uppercase tracking-wide">
|
||||
<div className="px-4 py-2 text-gray-500 text-xs font-medium uppercase tracking-wide">
|
||||
{group.name}
|
||||
</div>
|
||||
)}
|
||||
@@ -252,10 +296,16 @@ function ExitNodeSelectorItem({
|
||||
return (
|
||||
<button
|
||||
key={node.ID}
|
||||
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
|
||||
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>
|
||||
<div className="w-full">
|
||||
{node.Location && (
|
||||
<>
|
||||
<CountryFlag code={node.Location.CountryCode} />{" "}
|
||||
@@ -263,7 +313,8 @@ function ExitNodeSelectorItem({
|
||||
)}
|
||||
<span className="leading-snug">{node.Name}</span>
|
||||
</div>
|
||||
{isSelected && <Check />}
|
||||
{node.Online || <span className="leading-snug">Offline</span>}
|
||||
{isSelected && <Check className="ml-1" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg
|
||||
import { ReactComponent as Eye } from "src/assets/icons/eye.svg"
|
||||
import { ReactComponent as User } from "src/assets/icons/user.svg"
|
||||
import { AuthResponse, AuthType } from "src/hooks/auth"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Popover from "src/ui/popover"
|
||||
import ProfilePic from "src/ui/profile-pic"
|
||||
|
||||
@@ -37,7 +38,7 @@ export default function LoginToggle({
|
||||
{!auth.canManageNode ? (
|
||||
<button
|
||||
className={cx(
|
||||
"pl-3 py-1 bg-zinc-800 rounded-full flex justify-start items-center",
|
||||
"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)}
|
||||
@@ -56,10 +57,10 @@ export default function LoginToggle({
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
"w-[34px] h-[34px] p-1 rounded-full items-center inline-flex",
|
||||
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
|
||||
{
|
||||
"bg-transparent": !open,
|
||||
"bg-neutral-300": open,
|
||||
"bg-gray-300": open,
|
||||
}
|
||||
)}
|
||||
>
|
||||
@@ -104,13 +105,13 @@ function LoginPopoverContent({
|
||||
return // already checking
|
||||
}
|
||||
setIsRunningCheck(true)
|
||||
fetch(`http://${node.IP}:5252/ok`, { mode: "no-cors" })
|
||||
fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" })
|
||||
.then(() => {
|
||||
setIsRunningCheck(false)
|
||||
setCanConnectOverTS(true)
|
||||
setIsRunningCheck(false)
|
||||
})
|
||||
.catch(() => setIsRunningCheck(false))
|
||||
}, [auth.viewerIdentity, isRunningCheck, node.IP])
|
||||
}, [auth.viewerIdentity, isRunningCheck, node.IPv4])
|
||||
|
||||
/**
|
||||
* Checking connection for first time on page load.
|
||||
@@ -124,15 +125,27 @@ function LoginPopoverContent({
|
||||
useEffect(() => checkTSConnection(), [])
|
||||
|
||||
const handleSignInClick = useCallback(() => {
|
||||
if (auth.viewerIdentity) {
|
||||
newSession()
|
||||
if (auth.viewerIdentity && auth.serverMode === "manage") {
|
||||
if (window.self !== window.top) {
|
||||
// if we're inside an iframe, start session in new window
|
||||
let url = new URL(window.location.href)
|
||||
url.searchParams.set("check", "now")
|
||||
window.open(url, "_blank")
|
||||
} else {
|
||||
newSession()
|
||||
}
|
||||
} else {
|
||||
// Must be connected over Tailscale to log in.
|
||||
// If not already connected, reroute to the Tailscale IP
|
||||
// before sending user through check mode.
|
||||
window.location.href = `http://${node.IP}:5252/?check=now`
|
||||
// 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.IP, auth.viewerIdentity, newSession])
|
||||
}, [auth.viewerIdentity, auth.serverMode, newSession, node.IPv4])
|
||||
|
||||
return (
|
||||
<div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>
|
||||
@@ -140,50 +153,74 @@ function LoginPopoverContent({
|
||||
{!auth.canManageNode ? "Viewing" : "Managing"}
|
||||
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
|
||||
</div>
|
||||
{!auth.canManageNode &&
|
||||
(!auth.viewerIdentity || auth.authNeeded === AuthType.tailscale ? (
|
||||
<>
|
||||
<p className="text-neutral-500 text-xs">
|
||||
{auth.viewerIdentity ? (
|
||||
{!auth.canManageNode && (
|
||||
<>
|
||||
{!auth.viewerIdentity ? (
|
||||
// User is not connected over Tailscale.
|
||||
// These states are only possible on the login client.
|
||||
<>
|
||||
{!canConnectOverTS ? (
|
||||
<>
|
||||
To make changes, sign in to confirm your identity. This extra
|
||||
step helps us keep your device secure.
|
||||
<p className="text-gray-500 text-xs">
|
||||
{!node.ACLAllowsAnyIncomingTraffic ? (
|
||||
// Tailnet ACLs don't allow access.
|
||||
<>
|
||||
The current tailnet policy file does not allow
|
||||
connecting to this device.
|
||||
</>
|
||||
) : (
|
||||
// ACLs allow access, but user can't connect.
|
||||
<>
|
||||
Cannot access this device's Tailscale IP. Make sure you
|
||||
are connected to your tailnet, and that your policy file
|
||||
allows access.
|
||||
</>
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://tailscale.com/s/web-client-connection"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
// User can connect to Tailcale IP; sign in when ready.
|
||||
<>
|
||||
You can see most of this device's details. To make changes,
|
||||
you need to sign in.
|
||||
<p className="text-gray-500 text-xs">
|
||||
You can see most of this device's details. To make changes,
|
||||
you need to sign in.
|
||||
</p>
|
||||
<SignInButton auth={auth} onClick={handleSignInClick} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : auth.authNeeded === AuthType.tailscale ? (
|
||||
// User is connected over Tailscale, but needs to complete check mode.
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
To make changes, sign in to confirm your identity. This extra
|
||||
step helps us keep your device secure.
|
||||
</p>
|
||||
<SignInButton auth={auth} onClick={handleSignInClick} />
|
||||
</>
|
||||
) : (
|
||||
// User is connected over tailscale, but doesn't have permission to manage.
|
||||
<p className="text-gray-500 text-xs">
|
||||
You don’t have permission to make changes to this device, but you
|
||||
can view most of its details.
|
||||
</p>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-neutral-500 text-xs">
|
||||
You don’t have permission to make changes to this device, but you
|
||||
can view most of its details.
|
||||
</p>
|
||||
))}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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">
|
||||
<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}
|
||||
@@ -195,3 +232,24 @@ function LoginPopoverContent({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
65
client/web/src/components/nice-ip.tsx
Normal file
65
client/web/src/components/nice-ip.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import { isTailscaleIPv6 } from "src/utils/util"
|
||||
|
||||
type Props = {
|
||||
ip: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* NiceIP displays IP addresses with nice truncation.
|
||||
*/
|
||||
export default function NiceIP(props: Props) {
|
||||
const { ip, className } = props
|
||||
|
||||
if (!isTailscaleIPv6(ip)) {
|
||||
return <span className={className}>{ip}</span>
|
||||
}
|
||||
|
||||
const [trimmable, untrimmable] = splitIPv6(ip)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cx("inline-flex justify-start min-w-0 max-w-full", className)}
|
||||
>
|
||||
{trimmable.length > 0 && (
|
||||
<span className="truncate w-fit flex-shrink">{trimmable}</span>
|
||||
)}
|
||||
<span className="flex-grow-0 flex-shrink-0">{untrimmable}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an IPv6 address into two pieces, to help with truncating the middle.
|
||||
* Only exported for testing purposes. Do not use.
|
||||
*/
|
||||
export function splitIPv6(ip: string): [string, string] {
|
||||
// We want to split the IPv6 address into segments, but not remove the delimiter.
|
||||
// So we inject an invalid IPv6 character ("|") as a delimiter into the string,
|
||||
// then split on that.
|
||||
const parts = ip.replace(/(:{1,2})/g, "|$1").split("|")
|
||||
|
||||
// Then we find the number of end parts that fits within the character limit,
|
||||
// and join them back together.
|
||||
const characterLimit = 12
|
||||
let characterCount = 0
|
||||
let idxFromEnd = 1
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (characterCount + part.length > characterLimit) {
|
||||
break
|
||||
}
|
||||
characterCount += part.length
|
||||
idxFromEnd++
|
||||
}
|
||||
|
||||
const start = parts.slice(0, -idxFromEnd).join("")
|
||||
const end = parts.slice(-idxFromEnd).join("")
|
||||
|
||||
return [start, end]
|
||||
}
|
||||
@@ -2,16 +2,20 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import { VersionInfo } from "src/hooks/self-update"
|
||||
import { Link } from "wouter"
|
||||
import { VersionInfo } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Card from "src/ui/card"
|
||||
import { useLocation } from "wouter"
|
||||
|
||||
export function UpdateAvailableNotification({
|
||||
details,
|
||||
}: {
|
||||
details: VersionInfo
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<Card>
|
||||
<h2 className="mb-2">
|
||||
Update available{" "}
|
||||
{details.LatestVersion && `(v${details.LatestVersion})`}
|
||||
@@ -22,13 +26,14 @@ export function UpdateAvailableNotification({
|
||||
: "A new update"}{" "}
|
||||
is now available. <ChangelogText version={details.LatestVersion} />
|
||||
</p>
|
||||
<Link
|
||||
className="button button-blue mt-3 text-sm inline-block"
|
||||
to="/update"
|
||||
<Button
|
||||
className="mt-3 inline-block"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/update")}
|
||||
>
|
||||
Update now
|
||||
</Link>
|
||||
</div>
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { useAPI } from "src/api"
|
||||
import ACLTag from "src/components/acl-tag"
|
||||
import * as Control from "src/components/control-components"
|
||||
import NiceIP from "src/components/nice-ip"
|
||||
import { UpdateAvailableNotification } from "src/components/update-available"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Card from "src/ui/card"
|
||||
import Dialog from "src/ui/dialog"
|
||||
import QuickCopy from "src/ui/quick-copy"
|
||||
import { useLocation } from "wouter"
|
||||
|
||||
export default function DeviceDetailsView({
|
||||
@@ -17,13 +22,11 @@ export default function DeviceDetailsView({
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-10">Device details</h1>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="card">
|
||||
<Card noPadding className="-mx-5 p-5 details-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1>{node.DeviceName}</h1>
|
||||
@@ -34,28 +37,16 @@ export default function DeviceDetailsView({
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
{!readonly && <DisconnectDialog />}
|
||||
</div>
|
||||
</div>
|
||||
{node.ClientVersion &&
|
||||
!node.ClientVersion.RunningLatest &&
|
||||
!readonly && (
|
||||
</Card>
|
||||
{node.Features["auto-update"] &&
|
||||
!readonly &&
|
||||
node.ClientVersion &&
|
||||
!node.ClientVersion.RunningLatest && (
|
||||
<UpdateAvailableNotification details={node.ClientVersion} />
|
||||
)}
|
||||
<div className="card">
|
||||
<Card noPadding className="-mx-5 p-5 details-card">
|
||||
<h2 className="mb-2">General</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
@@ -69,7 +60,14 @@ export default function DeviceDetailsView({
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Machine name</td>
|
||||
<td>{node.DeviceName}</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.DeviceName}
|
||||
primaryActionSubject="machine name"
|
||||
>
|
||||
{node.DeviceName}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OS</td>
|
||||
@@ -77,7 +75,14 @@ export default function DeviceDetailsView({
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td>{node.ID}</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.ID}
|
||||
primaryActionSubject="ID"
|
||||
>
|
||||
{node.ID}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tailscale version</td>
|
||||
@@ -89,48 +94,155 @@ export default function DeviceDetailsView({
|
||||
{node.KeyExpired
|
||||
? "Expired"
|
||||
: // TODO: present as relative expiry (e.g. "5 months from now")
|
||||
new Date(node.KeyExpiry).toLocaleString()}
|
||||
node.KeyExpiry
|
||||
? new Date(node.KeyExpiry).toLocaleString()
|
||||
: "No expiry"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="card">
|
||||
</Card>
|
||||
<Card noPadding className="-mx-5 p-5 details-card">
|
||||
<h2 className="mb-2">Addresses</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Tailscale IPv4</td>
|
||||
<td>{node.IP}</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.IPv4}
|
||||
primaryActionSubject="IPv4 address"
|
||||
>
|
||||
{node.IPv4}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tailscale IPv6</td>
|
||||
<td>{node.IPv6}</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.IPv6}
|
||||
primaryActionSubject="IPv6 address"
|
||||
>
|
||||
<NiceIP ip={node.IPv6} />
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Short domain</td>
|
||||
<td>{node.DeviceName}</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.DeviceName}
|
||||
primaryActionSubject="short domain"
|
||||
>
|
||||
{node.DeviceName}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Full domain</td>
|
||||
<td>
|
||||
{node.DeviceName}.{node.TailnetName}
|
||||
<QuickCopy
|
||||
primaryActionValue={`${node.DeviceName}.${node.TailnetName}`}
|
||||
primaryActionSubject="full domain"
|
||||
>
|
||||
{node.DeviceName}.{node.TailnetName}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Control.AdminContainer
|
||||
className="text-neutral-500 text-sm leading-tight text-center"
|
||||
node={node}
|
||||
>
|
||||
Want even more details? Visit{" "}
|
||||
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
|
||||
this device’s page
|
||||
</Control.AdminLink>{" "}
|
||||
in the admin console.
|
||||
</Control.AdminContainer>
|
||||
</Card>
|
||||
<Card noPadding className="-mx-5 p-5 details-card">
|
||||
<h2 className="mb-2">Debug</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>TUN Mode</td>
|
||||
<td>{node.TUNMode ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
{node.IsSynology && (
|
||||
<tr>
|
||||
<td>Synology Version</td>
|
||||
<td>{node.DSMVersion}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
<footer className="text-gray-500 text-sm leading-tight text-center">
|
||||
<Control.AdminContainer node={node}>
|
||||
Want even more details? Visit{" "}
|
||||
<Control.AdminLink node={node} path={`/machines/${node.IPv4}`}>
|
||||
this device’s page
|
||||
</Control.AdminLink>{" "}
|
||||
in the admin console.
|
||||
</Control.AdminContainer>
|
||||
<p className="mt-12">
|
||||
<a
|
||||
className="link"
|
||||
href={node.LicensesURL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Acknowledgements
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a
|
||||
className="link"
|
||||
href="https://tailscale.com/privacy-policy/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a
|
||||
className="link"
|
||||
href="https://tailscale.com/terms/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
</p>
|
||||
<p className="my-2">
|
||||
WireGuard is a registered trademark of Jason A. Donenfeld.
|
||||
</p>
|
||||
<p>
|
||||
© {new Date().getFullYear()} Tailscale Inc. All rights reserved.
|
||||
Tailscale is a registered trademark of Tailscale Inc.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DisconnectDialog() {
|
||||
const api = useAPI()
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="max-w-md"
|
||||
title="Log out"
|
||||
trigger={<Button sizeVariant="small">Log out…</Button>}
|
||||
>
|
||||
<Dialog.Form
|
||||
cancelButton
|
||||
submitButton="Log out"
|
||||
destructive
|
||||
onSubmit={() => {
|
||||
api({ action: "logout" })
|
||||
setLocation("/disconnected")
|
||||
}}
|
||||
>
|
||||
Logging out of this device will disconnect it from your tailnet and
|
||||
expire its node key. You won’t be able to use this web interface until
|
||||
you re-authenticate the device from either the Tailscale app or the
|
||||
Tailscale command line interface.
|
||||
</Dialog.Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
21
client/web/src/components/views/disconnected-view.tsx
Normal file
21
client/web/src/components/views/disconnected-view.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||
|
||||
/**
|
||||
* DisconnectedView is rendered after node logout.
|
||||
*/
|
||||
export default function DisconnectedView() {
|
||||
return (
|
||||
<>
|
||||
<TailscaleIcon className="mx-auto" />
|
||||
<p className="mt-12 text-center text-text-muted">
|
||||
You logged out of this device. To reconnect it you will have to
|
||||
re-authenticate the device from either the Tailscale app or the
|
||||
Tailscale command line interface.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,79 +2,128 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import React, { useMemo } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
|
||||
import { ReactComponent as Machine } from "src/assets/icons/machine.svg"
|
||||
import AddressCard from "src/components/address-copy-card"
|
||||
import ExitNodeSelector from "src/components/exit-node-selector"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import { Link } from "wouter"
|
||||
import { NodeData } from "src/types"
|
||||
import Card from "src/ui/card"
|
||||
import { pluralize } from "src/utils/util"
|
||||
import { Link, useLocation } from "wouter"
|
||||
|
||||
export default function HomeView({
|
||||
readonly,
|
||||
node,
|
||||
nodeUpdaters,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
nodeUpdaters: NodeUpdaters
|
||||
}) {
|
||||
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
|
||||
() => [
|
||||
node.AdvertisedRoutes?.length,
|
||||
node.AdvertisedRoutes?.filter((r) => !r.Approved).length,
|
||||
],
|
||||
[node.AdvertisedRoutes]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mb-12 w-full">
|
||||
<h2 className="mb-3">This device</h2>
|
||||
<div className="-mx-5 card mb-9">
|
||||
<Card noPadding className="-mx-5 p-5 mb-9">
|
||||
<div className="flex justify-between items-center text-lg mb-5">
|
||||
<div className="flex items-center">
|
||||
<ConnectedDeviceIcon />
|
||||
<div className="ml-3">
|
||||
<h1>{node.DeviceName}</h1>
|
||||
{/* TODO(sonia): display actual status */}
|
||||
<p className="text-neutral-500 text-sm">Connected</p>
|
||||
<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>
|
||||
<p className="text-neutral-800 text-lg leading-[25.20px]">
|
||||
{node.IP}
|
||||
</p>
|
||||
<div className="ml-3">
|
||||
<div className="text-gray-800 text-lg font-medium leading-snug">
|
||||
{node.DeviceName}
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm leading-[18.20px] flex items-center gap-2">
|
||||
<span
|
||||
className={cx("w-2 h-2 inline-block rounded-full", {
|
||||
"bg-green-300": node.Status === "Running",
|
||||
"bg-gray-300": node.Status !== "Running",
|
||||
})}
|
||||
/>
|
||||
{node.Status === "Running" ? "Connected" : "Offline"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<AddressCard
|
||||
className="-mr-2"
|
||||
triggerClassName="relative text-gray-800 text-lg leading-[25.20px]"
|
||||
v4Address={node.IPv4}
|
||||
v6Address={node.IPv6}
|
||||
shortDomain={node.DeviceName}
|
||||
fullDomain={`${node.DeviceName}.${node.TailnetName}`}
|
||||
/>
|
||||
</div>
|
||||
<ExitNodeSelector
|
||||
className="mb-5"
|
||||
node={node}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
disabled={readonly}
|
||||
/>
|
||||
{(node.Features["advertise-exit-node"] ||
|
||||
node.Features["use-exit-node"]) && (
|
||||
<ExitNodeSelector className="mb-5" node={node} disabled={readonly} />
|
||||
)}
|
||||
<Link
|
||||
className="text-indigo-500 font-medium leading-snug"
|
||||
className="link font-medium"
|
||||
to="/details"
|
||||
onClick={() => apiFetch("/device-details-click", "POST")}
|
||||
>
|
||||
View device details →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
<h2 className="mb-3">Settings</h2>
|
||||
<SettingsCard
|
||||
link="/subnets"
|
||||
className="mb-3"
|
||||
title="Subnet router"
|
||||
body="Add devices to your tailnet without installing Tailscale on them."
|
||||
/>
|
||||
<SettingsCard
|
||||
link="/ssh"
|
||||
className="mb-3"
|
||||
title="Tailscale SSH server"
|
||||
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
|
||||
badge={
|
||||
node.RunningSSHServer
|
||||
? {
|
||||
text: "Running",
|
||||
icon: <div className="w-2 h-2 bg-emerald-500 rounded-full" />,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
|
||||
{/* <SettingsCard
|
||||
<div className="grid gap-3">
|
||||
{node.Features["advertise-routes"] && (
|
||||
<SettingsCard
|
||||
link="/subnets"
|
||||
title="Subnet router"
|
||||
body="Add devices to your tailnet without installing Tailscale on them."
|
||||
badge={
|
||||
allSubnetRoutes
|
||||
? {
|
||||
text: `${allSubnetRoutes} ${pluralize(
|
||||
"route",
|
||||
"routes",
|
||||
allSubnetRoutes
|
||||
)}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
footer={
|
||||
pendingSubnetRoutes
|
||||
? `${pendingSubnetRoutes} ${pluralize(
|
||||
"route",
|
||||
"routes",
|
||||
pendingSubnetRoutes
|
||||
)} pending approval`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{node.Features["ssh"] && (
|
||||
<SettingsCard
|
||||
link="/ssh"
|
||||
title="Tailscale SSH server"
|
||||
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
|
||||
badge={
|
||||
node.RunningSSHServer
|
||||
? {
|
||||
text: "Running",
|
||||
icon: <div className="w-2 h-2 bg-green-300 rounded-full" />,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
|
||||
{/* <SettingsCard
|
||||
link="/serve"
|
||||
title="Share local content"
|
||||
body="Share local ports, services, and content to your Tailscale network or to the broader internet."
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -84,6 +133,7 @@ function SettingsCard({
|
||||
link,
|
||||
body,
|
||||
badge,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
@@ -93,35 +143,42 @@ function SettingsCard({
|
||||
text: string
|
||||
icon?: JSX.Element
|
||||
}
|
||||
footer?: string
|
||||
className?: string
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
return (
|
||||
<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>
|
||||
<button onClick={() => setLocation(link)}>
|
||||
<Card noPadding className={cx("-mx-5 p-5", className)}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<p className="text-gray-800 font-medium leading-tight mb-2">
|
||||
{title}
|
||||
</p>
|
||||
{badge && (
|
||||
<div className="h-5 px-2 bg-gray-100 rounded-full flex items-center gap-2">
|
||||
{badge.icon}
|
||||
<div className="text-gray-500 text-xs font-medium">
|
||||
{badge.text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-gray-500 text-sm leading-tight">{body}</p>
|
||||
</div>
|
||||
<div>
|
||||
<ArrowRight className="ml-3" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-neutral-500 text-sm leading-tight">{body}</p>
|
||||
</div>
|
||||
<div>
|
||||
<ArrowRight className="ml-3" />
|
||||
</div>
|
||||
</Link>
|
||||
{footer && (
|
||||
<>
|
||||
<hr className="my-3" />
|
||||
<div className="text-gray-500 text-sm leading-tight">{footer}</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import React, { useState } from "react"
|
||||
import { useAPI } from "src/api"
|
||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Collapsible from "src/ui/collapsible"
|
||||
import Input from "src/ui/input"
|
||||
|
||||
@@ -12,23 +13,11 @@ import Input from "src/ui/input"
|
||||
* LoginView is rendered when the client is not authenticated
|
||||
* to a tailnet.
|
||||
*/
|
||||
export default function LoginView({
|
||||
data,
|
||||
refreshData,
|
||||
}: {
|
||||
data: NodeData
|
||||
refreshData: () => void
|
||||
}) {
|
||||
export default function LoginView({ data }: { data: NodeData }) {
|
||||
const api = useAPI()
|
||||
const [controlURL, setControlURL] = useState<string>("")
|
||||
const [authKey, setAuthKey] = useState<string>("")
|
||||
|
||||
const login = useCallback(
|
||||
(opt: TailscaleUpOptions) => {
|
||||
tailscaleUp(opt).then(refreshData)
|
||||
},
|
||||
[refreshData]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
<TailscaleIcon className="my-2 mb-8" />
|
||||
@@ -40,14 +29,15 @@ export default function LoginView({
|
||||
Your device is disconnected from Tailscale.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => login({})}
|
||||
className="button button-blue w-full mb-4"
|
||||
<Button
|
||||
onClick={() => api({ action: "up", data: {} })}
|
||||
className="w-full mb-4"
|
||||
intent="primary"
|
||||
>
|
||||
Connect to Tailscale
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : data.IP ? (
|
||||
) : data.IPv4 ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700">
|
||||
@@ -64,12 +54,15 @@ export default function LoginView({
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => login({ Reauthenticate: true })}
|
||||
className="button button-blue w-full mb-4"
|
||||
<Button
|
||||
onClick={() =>
|
||||
api({ action: "up", data: { Reauthenticate: true } })
|
||||
}
|
||||
className="w-full mb-4"
|
||||
intent="primary"
|
||||
>
|
||||
Reauthenticate
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -89,18 +82,22 @@ export default function LoginView({
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
onClick={() =>
|
||||
login({
|
||||
Reauthenticate: true,
|
||||
ControlURL: controlURL,
|
||||
AuthKey: authKey,
|
||||
api({
|
||||
action: "up",
|
||||
data: {
|
||||
Reauthenticate: true,
|
||||
ControlURL: controlURL,
|
||||
AuthKey: authKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="button button-blue w-full mb-4"
|
||||
className="w-full mb-4"
|
||||
intent="primary"
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
</Button>
|
||||
<Collapsible trigger="Advanced options">
|
||||
<h4 className="font-medium mb-1 mt-2">Auth Key</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
@@ -134,20 +131,3 @@ export default function LoginView({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TailscaleUpOptions = {
|
||||
Reauthenticate?: boolean // force reauthentication
|
||||
ControlURL?: string
|
||||
AuthKey?: string
|
||||
}
|
||||
|
||||
function tailscaleUp(options: TailscaleUpOptions) {
|
||||
return apiFetch("/up", "POST", options)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
d.url && window.open(d.url, "_blank")
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to login:", e)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
// 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, NodeUpdaters } from "src/hooks/node-data"
|
||||
import { NodeData } from "src/types"
|
||||
import Card from "src/ui/card"
|
||||
import Toggle from "src/ui/toggle"
|
||||
|
||||
export default function SSHView({
|
||||
readonly,
|
||||
node,
|
||||
nodeUpdaters,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
nodeUpdaters: NodeUpdaters
|
||||
}) {
|
||||
const api = useAPI()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-1">Tailscale SSH server</h1>
|
||||
@@ -23,38 +26,56 @@ export default function SSHView({
|
||||
your tailnet to SSH into it.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1193/tailscale-ssh/"
|
||||
className="text-indigo-700"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
<div className="-mx-5 px-4 py-3 bg-white rounded-lg border border-gray-200 flex gap-2.5 mb-3">
|
||||
<Toggle
|
||||
checked={node.RunningSSHServer}
|
||||
onChange={() =>
|
||||
nodeUpdaters.patchPrefs({
|
||||
RunSSHSet: true,
|
||||
RunSSH: !node.RunningSSHServer,
|
||||
})
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<div className="text-black text-sm font-medium leading-tight">
|
||||
Run Tailscale SSH server
|
||||
</div>
|
||||
</div>
|
||||
<Control.AdminContainer
|
||||
className="text-neutral-500 text-sm leading-tight"
|
||||
node={node}
|
||||
>
|
||||
Remember to make sure that the{" "}
|
||||
<Control.AdminLink node={node} path="/acls">
|
||||
tailnet policy file
|
||||
</Control.AdminLink>{" "}
|
||||
allows other devices to SSH into this device.
|
||||
</Control.AdminContainer>
|
||||
<Card noPadding className="-mx-5 p-5">
|
||||
{!readonly ? (
|
||||
<label className="flex gap-3 items-center">
|
||||
<Toggle
|
||||
checked={node.RunningSSHServer}
|
||||
onChange={() =>
|
||||
api({
|
||||
action: "update-prefs",
|
||||
data: {
|
||||
RunSSHSet: true,
|
||||
RunSSH: !node.RunningSSHServer,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="text-black text-sm font-medium leading-tight">
|
||||
Run Tailscale SSH server
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<span
|
||||
className={cx("w-2 h-2 rounded-full", {
|
||||
"bg-green-300": node.RunningSSHServer,
|
||||
"bg-gray-300": !node.RunningSSHServer,
|
||||
})}
|
||||
/>
|
||||
{node.RunningSSHServer ? "Running" : "Not running"}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{node.RunningSSHServer && (
|
||||
<Control.AdminContainer
|
||||
className="text-gray-500 text-sm leading-tight mt-3"
|
||||
node={node}
|
||||
>
|
||||
Remember to make sure that the{" "}
|
||||
<Control.AdminLink node={node} path="/acls">
|
||||
tailnet policy file
|
||||
</Control.AdminLink>{" "}
|
||||
allows other devices to SSH into this device.
|
||||
</Control.AdminContainer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,45 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React, { useMemo, useState } from "react"
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import { useAPI } from "src/api"
|
||||
import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg"
|
||||
import { ReactComponent as Clock } from "src/assets/icons/clock.svg"
|
||||
import { ReactComponent as Plus } from "src/assets/icons/plus.svg"
|
||||
import * as Control from "src/components/control-components"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
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,
|
||||
nodeUpdaters,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
nodeUpdaters: NodeUpdaters
|
||||
}) {
|
||||
const advertisedRoutes = useMemo(
|
||||
() => node.AdvertisedRoutes || [],
|
||||
[node.AdvertisedRoutes]
|
||||
)
|
||||
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 (
|
||||
<>
|
||||
@@ -35,59 +48,80 @@ export default function SubnetRouterView({
|
||||
Add devices to your tailnet without installing Tailscale.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1019/subnets/"
|
||||
className="text-indigo-700"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
{inputOpen ? (
|
||||
<div className="-mx-5 card shadow">
|
||||
<p className="font-medium leading-snug mb-3">Advertise new routes</p>
|
||||
<Input
|
||||
type="text"
|
||||
className="text-sm"
|
||||
placeholder="192.168.0.0/24"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
/>
|
||||
<p className="my-2 h-6 text-neutral-500 text-sm leading-tight">
|
||||
Add multiple routes by providing a comma-separated list.
|
||||
</p>
|
||||
{!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
|
||||
onClick={() =>
|
||||
nodeUpdaters
|
||||
.postSubnetRoutes([
|
||||
...advertisedRoutes.map((r) => r.Route),
|
||||
...inputText.split(","),
|
||||
])
|
||||
.then(() => {
|
||||
setInputText("")
|
||||
setInputOpen(false)
|
||||
})
|
||||
}
|
||||
disabled={readonly || !inputText}
|
||||
intent="primary"
|
||||
prefixIcon={<Plus />}
|
||||
onClick={() => setInputOpen(true)}
|
||||
>
|
||||
Advertise routes
|
||||
Advertise new routes
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setInputOpen(true)} disabled={readonly}>
|
||||
<Plus />
|
||||
Advertise new route
|
||||
</Button>
|
||||
)}
|
||||
))}
|
||||
<div className="-mx-5 mt-10">
|
||||
{advertisedRoutes.length > 0 ? (
|
||||
{hasRoutes ? (
|
||||
<>
|
||||
<div className="px-5 py-3 bg-white rounded-lg border border-gray-200">
|
||||
<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-neutral-800 leading-snug">{r.Route}</div>
|
||||
<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 ? (
|
||||
@@ -96,50 +130,69 @@ export default function SubnetRouterView({
|
||||
<Clock className="w-4 h-4" />
|
||||
)}
|
||||
{r.Approved ? (
|
||||
<div className="text-emerald-800 text-sm leading-tight">
|
||||
<div className="text-green-500 text-sm leading-tight">
|
||||
Approved
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 text-sm leading-tight">
|
||||
<div className="text-gray-500 text-sm leading-tight">
|
||||
Pending approval
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
intent="secondary"
|
||||
className="text-sm font-medium"
|
||||
onClick={() =>
|
||||
nodeUpdaters.postSubnetRoutes(
|
||||
advertisedRoutes
|
||||
.map((it) => it.Route)
|
||||
.filter((it) => it !== r.Route)
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
>
|
||||
Stop advertising…
|
||||
</Button>
|
||||
{!readonly && (
|
||||
<StopAdvertisingDialog
|
||||
onSubmit={() =>
|
||||
api({
|
||||
action: "update-routes",
|
||||
data: advertisedRoutes.filter(
|
||||
(it) => it.Route !== r.Route
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Control.AdminContainer
|
||||
className="mt-3 w-full text-center text-neutral-500 text-sm leading-tight"
|
||||
node={node}
|
||||
>
|
||||
To approve routes, in the admin console go to{" "}
|
||||
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
|
||||
the machine’s route settings
|
||||
</Control.AdminLink>
|
||||
.
|
||||
</Control.AdminContainer>
|
||||
</Card>
|
||||
{hasUnapprovedRoutes && (
|
||||
<Control.AdminContainer
|
||||
className="mt-3 w-full text-center text-gray-500 text-sm leading-tight"
|
||||
node={node}
|
||||
>
|
||||
To approve routes, in the admin console go to{" "}
|
||||
<Control.AdminLink node={node} path={`/machines/${node.IPv4}`}>
|
||||
the machine’s route settings
|
||||
</Control.AdminLink>
|
||||
.
|
||||
</Control.AdminContainer>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-5 py-4 bg-stone-50 rounded-lg border border-gray-200 text-center text-neutral-500">
|
||||
Not advertising any routes
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@ import React from "react"
|
||||
import { ReactComponent as CheckCircleIcon } from "src/assets/icons/check-circle.svg"
|
||||
import { ReactComponent as XCircleIcon } from "src/assets/icons/x-circle.svg"
|
||||
import { ChangelogText } from "src/components/update-available"
|
||||
import {
|
||||
UpdateState,
|
||||
useInstallUpdate,
|
||||
VersionInfo,
|
||||
} from "src/hooks/self-update"
|
||||
import { UpdateState, useInstallUpdate } from "src/hooks/self-update"
|
||||
import { VersionInfo } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Spinner from "src/ui/spinner"
|
||||
import { Link } from "wouter"
|
||||
import { useLocation } from "wouter"
|
||||
|
||||
/**
|
||||
* UpdatingView is rendered when the user initiates a Tailscale update, and
|
||||
@@ -24,6 +22,7 @@ export function UpdatingView({
|
||||
versionInfo?: VersionInfo
|
||||
currentVersion: string
|
||||
}) {
|
||||
const [, setLocation] = useLocation()
|
||||
const { updateState, updateLog } = useInstallUpdate(
|
||||
currentVersion,
|
||||
versionInfo
|
||||
@@ -51,9 +50,13 @@ export function UpdatingView({
|
||||
: null}
|
||||
. <ChangelogText version={versionInfo?.LatestVersion} />
|
||||
</p>
|
||||
<Link className="button button-blue text-sm m-3" to="/">
|
||||
<Button
|
||||
className="m-3"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/")}
|
||||
>
|
||||
Log in to access
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : updateState === UpdateState.UpToDate ? (
|
||||
<>
|
||||
@@ -63,9 +66,13 @@ export function UpdatingView({
|
||||
You are already running Tailscale {currentVersion}, which is the
|
||||
newest version available.
|
||||
</p>
|
||||
<Link className="button button-blue text-sm m-3" to="/">
|
||||
<Button
|
||||
className="m-3"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/")}
|
||||
>
|
||||
Return
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
/* TODO(naman,sonia): Figure out the body copy and design for this view. */
|
||||
@@ -79,9 +86,13 @@ export function UpdatingView({
|
||||
: null}{" "}
|
||||
failed.
|
||||
</p>
|
||||
<Link className="button button-blue text-sm m-3" to="/">
|
||||
<Button
|
||||
className="m-3"
|
||||
sizeVariant="small"
|
||||
onClick={() => setLocation("/")}
|
||||
>
|
||||
Return
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<pre className="h-64 overflow-scroll m-3">
|
||||
|
||||
@@ -12,6 +12,7 @@ export enum AuthType {
|
||||
export type AuthResponse = {
|
||||
authNeeded?: AuthType
|
||||
canManageNode: boolean
|
||||
serverMode: "login" | "manage"
|
||||
viewerIdentity?: {
|
||||
loginName: string
|
||||
nodeName: string
|
||||
@@ -25,19 +26,20 @@ export type AuthResponse = {
|
||||
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("/auth", "GET")
|
||||
.then((r) => r.json())
|
||||
return apiFetch<AuthResponse>("/auth", "GET")
|
||||
.then((d) => {
|
||||
setData(d)
|
||||
switch ((d as AuthResponse).authNeeded) {
|
||||
switch (d.authNeeded) {
|
||||
case AuthType.synology:
|
||||
fetch("/webman/login.cgi")
|
||||
.then((r) => r.json())
|
||||
.then((a) => {
|
||||
setSynoToken(a.SynoToken)
|
||||
setRanSynoAuth(true)
|
||||
setLoading(false)
|
||||
})
|
||||
break
|
||||
@@ -53,15 +55,16 @@ export default function useAuth() {
|
||||
}, [])
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
return apiFetch("/auth/session/new", "GET")
|
||||
.then((r) => r.json())
|
||||
return apiFetch<{ authUrl?: string }>("/auth/session/new", "GET")
|
||||
.then((d) => {
|
||||
if (d.authUrl) {
|
||||
window.open(d.authUrl, "_blank")
|
||||
return apiFetch("/auth/session/wait", "GET")
|
||||
}
|
||||
})
|
||||
.then(() => loadAuth())
|
||||
.then(() => {
|
||||
loadAuth()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
@@ -70,7 +73,7 @@ export default function useAuth() {
|
||||
useEffect(() => {
|
||||
loadAuth().then((d) => {
|
||||
if (
|
||||
!d.canManageNode &&
|
||||
!d?.canManageNode &&
|
||||
new URLSearchParams(window.location.search).get("check") === "now"
|
||||
) {
|
||||
newSession()
|
||||
@@ -79,6 +82,11 @@ export default function useAuth() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAuth() // Refresh auth state after syno auth runs
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ranSynoAuth])
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
|
||||
@@ -1,44 +1,18 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
CityCode,
|
||||
CountryCode,
|
||||
ExitNode,
|
||||
ExitNodeLocation,
|
||||
NodeData,
|
||||
} from "src/types"
|
||||
import useSWR from "swr"
|
||||
|
||||
export type ExitNode = {
|
||||
ID: string
|
||||
Name: string
|
||||
Location?: ExitNodeLocation
|
||||
Online?: boolean
|
||||
}
|
||||
|
||||
type ExitNodeLocation = {
|
||||
Country: string
|
||||
CountryCode: CountryCode
|
||||
City: string
|
||||
CityCode: CityCode
|
||||
Priority: number
|
||||
}
|
||||
|
||||
type CountryCode = string
|
||||
type CityCode = string
|
||||
|
||||
export type ExitNodeGroup = {
|
||||
id: string
|
||||
name?: string
|
||||
nodes: ExitNode[]
|
||||
}
|
||||
|
||||
export default function useExitNodes(tailnetName: string, filter?: string) {
|
||||
const [data, setData] = useState<ExitNode[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch("/exit-nodes", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((r) => setData(r))
|
||||
.catch((err) => {
|
||||
alert("Failed operation: " + err.message)
|
||||
})
|
||||
}, [])
|
||||
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:
|
||||
@@ -47,6 +21,14 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
|
||||
let tailnetNodes: ExitNode[] = []
|
||||
const locationNodes = new Map<CountryCode, Map<CityCode, ExitNode[]>>()
|
||||
|
||||
if (!node.Features["use-exit-node"]) {
|
||||
// early-return
|
||||
return {
|
||||
tailnetNodesSorted: tailnetNodes,
|
||||
locationNodesMap: locationNodes,
|
||||
}
|
||||
}
|
||||
|
||||
data?.forEach((n) => {
|
||||
const loc = n.Location
|
||||
if (!loc) {
|
||||
@@ -55,7 +37,7 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
|
||||
// Only Mullvad exit nodes have locations filled.
|
||||
tailnetNodes.push({
|
||||
...n,
|
||||
Name: trimDNSSuffix(n.Name, tailnetName),
|
||||
Name: trimDNSSuffix(n.Name, node.TailnetName),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -70,12 +52,15 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
|
||||
tailnetNodesSorted: tailnetNodes.sort(compareByName),
|
||||
locationNodesMap: locationNodes,
|
||||
}
|
||||
}, [data, tailnetName])
|
||||
}, [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`.
|
||||
@@ -123,14 +108,27 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
|
||||
}
|
||||
|
||||
return nodes.sort(compareByName)
|
||||
}, [hasFilter, locationNodesMap])
|
||||
}, [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 [
|
||||
{ id: "self", nodes: filter ? [] : [noExitNode, runAsExitNode] },
|
||||
selfGroup,
|
||||
{
|
||||
id: "tailnet",
|
||||
nodes: filterLower
|
||||
@@ -149,7 +147,7 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
|
||||
: mullvadNodesSorted,
|
||||
},
|
||||
]
|
||||
}, [tailnetNodesSorted, mullvadNodesSorted, filter])
|
||||
}, [filter, node.Features, tailnetNodesSorted, mullvadNodesSorted])
|
||||
|
||||
return { data: exitNodeGroups }
|
||||
}
|
||||
@@ -197,8 +195,10 @@ export function trimDNSSuffix(s: string, tailnetDNSName: string): string {
|
||||
return s
|
||||
}
|
||||
|
||||
export const noExitNode: ExitNode = { ID: "NONE", Name: "None" }
|
||||
// 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…",
|
||||
Name: "Run as exit node",
|
||||
Online: true,
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { apiFetch, setUnraidCsrfToken } from "src/api"
|
||||
import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes"
|
||||
import { VersionInfo } from "src/hooks/self-update"
|
||||
|
||||
export type NodeData = {
|
||||
Profile: UserProfile
|
||||
Status: NodeState
|
||||
DeviceName: string
|
||||
OS: string
|
||||
IP: string
|
||||
IPv6: string
|
||||
ID: string
|
||||
KeyExpiry: string
|
||||
KeyExpired: boolean
|
||||
UsingExitNode?: ExitNode
|
||||
AdvertisingExitNode: boolean
|
||||
AdvertisedRoutes?: SubnetRoute[]
|
||||
TUNMode: boolean
|
||||
IsSynology: boolean
|
||||
DSMVersion: number
|
||||
IsUnraid: boolean
|
||||
UnraidToken: string
|
||||
IPNVersion: string
|
||||
ClientVersion?: VersionInfo
|
||||
URLPrefix: string
|
||||
DomainName: string
|
||||
TailnetName: string
|
||||
IsTagged: boolean
|
||||
Tags: string[]
|
||||
RunningSSHServer: boolean
|
||||
ControlAdminURL: string
|
||||
LicensesURL: string
|
||||
}
|
||||
|
||||
type NodeState =
|
||||
| "NoState"
|
||||
| "NeedsLogin"
|
||||
| "NeedsMachineAuth"
|
||||
| "Stopped"
|
||||
| "Starting"
|
||||
| "Running"
|
||||
|
||||
export type UserProfile = {
|
||||
LoginName: string
|
||||
DisplayName: string
|
||||
ProfilePicURL: string
|
||||
}
|
||||
|
||||
export type SubnetRoute = {
|
||||
Route: string
|
||||
Approved: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* NodeUpdaters provides a set of mutation functions for a node.
|
||||
*
|
||||
* These functions handle both making the requested change, as well as
|
||||
* refreshing the app's node data state upon completion to reflect any
|
||||
* relevant changes in the UI.
|
||||
*/
|
||||
export type NodeUpdaters = {
|
||||
/**
|
||||
* patchPrefs updates node preferences.
|
||||
* Only provided preferences will be updated.
|
||||
* Similar to running the tailscale set command in the CLI.
|
||||
*/
|
||||
patchPrefs: (d: PrefsPATCHData) => Promise<void>
|
||||
/**
|
||||
* postExitNode updates the node's status as either using or
|
||||
* running as an exit node.
|
||||
*/
|
||||
postExitNode: (d: ExitNode) => Promise<void>
|
||||
/**
|
||||
* postSubnetRoutes updates the node's advertised subnet routes.
|
||||
*/
|
||||
postSubnetRoutes: (d: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
type PrefsPATCHData = {
|
||||
RunSSHSet?: boolean
|
||||
RunSSH?: boolean
|
||||
}
|
||||
|
||||
type RoutesPOSTData = {
|
||||
UseExitNode?: string
|
||||
AdvertiseExitNode?: boolean
|
||||
AdvertiseRoutes?: string[]
|
||||
}
|
||||
|
||||
// useNodeData returns basic data about the current node.
|
||||
export default function useNodeData() {
|
||||
const [data, setData] = useState<NodeData>()
|
||||
const [isPosting, setIsPosting] = useState<boolean>(false)
|
||||
|
||||
const refreshData = useCallback(
|
||||
() =>
|
||||
apiFetch("/data", "GET")
|
||||
.then((r) => r.json())
|
||||
.then((d: NodeData) => {
|
||||
setData(d)
|
||||
setUnraidCsrfToken(d.IsUnraid ? d.UnraidToken : undefined)
|
||||
})
|
||||
.catch((error) => console.error(error)),
|
||||
[setData]
|
||||
)
|
||||
|
||||
const prefsPATCH = useCallback(
|
||||
(d: PrefsPATCHData) => {
|
||||
setIsPosting(true)
|
||||
if (data) {
|
||||
const optimisticUpdates = data
|
||||
if (d.RunSSHSet) {
|
||||
optimisticUpdates.RunningSSHServer = Boolean(d.RunSSH)
|
||||
}
|
||||
// Reflect the pref change immediatley on the frontend,
|
||||
// then make the prefs PATCH. If the request fails,
|
||||
// data will be updated to it's previous value in
|
||||
// onComplete below.
|
||||
setData(optimisticUpdates)
|
||||
}
|
||||
|
||||
const onComplete = () => {
|
||||
setIsPosting(false)
|
||||
refreshData() // refresh data after PATCH finishes
|
||||
}
|
||||
|
||||
return apiFetch("/local/v0/prefs", "PATCH", d)
|
||||
.then(onComplete)
|
||||
.catch((err) => {
|
||||
onComplete()
|
||||
alert("Failed to update prefs")
|
||||
throw err
|
||||
})
|
||||
},
|
||||
[setIsPosting, refreshData, setData, data]
|
||||
)
|
||||
|
||||
const routesPOST = useCallback(
|
||||
(d: RoutesPOSTData) => {
|
||||
setIsPosting(true)
|
||||
const onComplete = () => {
|
||||
setIsPosting(false)
|
||||
refreshData() // refresh data after POST finishes
|
||||
}
|
||||
|
||||
return apiFetch("/routes", "POST", d)
|
||||
.then(onComplete)
|
||||
.catch((err) => {
|
||||
onComplete()
|
||||
alert("Failed to update routes")
|
||||
throw err
|
||||
})
|
||||
},
|
||||
[setIsPosting, refreshData]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
// Initial data load.
|
||||
refreshData()
|
||||
|
||||
// Refresh on browser tab focus.
|
||||
const onVisibilityChange = () => {
|
||||
document.visibilityState === "visible" && refreshData()
|
||||
}
|
||||
window.addEventListener("visibilitychange", onVisibilityChange)
|
||||
return () => {
|
||||
// Cleanup browser tab listener.
|
||||
window.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
}
|
||||
},
|
||||
// Run once.
|
||||
[refreshData]
|
||||
)
|
||||
|
||||
const nodeUpdaters: NodeUpdaters = useMemo(
|
||||
() => ({
|
||||
patchPrefs: prefsPATCH,
|
||||
postExitNode: (node) =>
|
||||
routesPOST({
|
||||
AdvertiseExitNode: node.ID === runAsExitNode.ID,
|
||||
UseExitNode:
|
||||
node.ID === noExitNode.ID || node.ID === runAsExitNode.ID
|
||||
? undefined
|
||||
: node.ID,
|
||||
AdvertiseRoutes: data?.AdvertisedRoutes?.map((r) => r.Route), // unchanged
|
||||
}),
|
||||
postSubnetRoutes: (routes) =>
|
||||
routesPOST({
|
||||
AdvertiseRoutes: routes,
|
||||
AdvertiseExitNode: data?.AdvertisingExitNode, // unchanged
|
||||
UseExitNode: data?.UsingExitNode?.ID, // unchanged
|
||||
}),
|
||||
}),
|
||||
[
|
||||
data?.AdvertisingExitNode,
|
||||
data?.AdvertisedRoutes,
|
||||
data?.UsingExitNode?.ID,
|
||||
prefsPATCH,
|
||||
routesPOST,
|
||||
]
|
||||
)
|
||||
|
||||
return { data, refreshData, nodeUpdaters, isPosting }
|
||||
}
|
||||
@@ -3,13 +3,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
|
||||
// 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
|
||||
}
|
||||
import { VersionInfo } from "src/types"
|
||||
|
||||
// see ipnstate.UpdateProgress
|
||||
export type UpdateProgress = {
|
||||
@@ -58,12 +52,11 @@ export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) {
|
||||
let tsAwayForPolls = 0
|
||||
let updateMessagesRead = 0
|
||||
|
||||
let timer = 0
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
|
||||
function poll() {
|
||||
apiFetch("/local/v0/update/progress", "GET")
|
||||
.then((res) => res.json())
|
||||
.then((res: UpdateProgress[]) => {
|
||||
apiFetch<UpdateProgress[]>("/local/v0/update/progress", "GET")
|
||||
.then((res) => {
|
||||
// res contains a list of UpdateProgresses that is strictly increasing
|
||||
// in size, so updateMessagesRead keeps track (across calls of poll())
|
||||
// of how many of those we have already read. This is why it is not
|
||||
@@ -123,7 +116,7 @@ export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) {
|
||||
// useEffect cleanup function
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = 0
|
||||
timer = undefined
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
17
client/web/src/hooks/toaster.ts
Normal file
17
client/web/src/hooks/toaster.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// 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
|
||||
@@ -14,38 +14,182 @@
|
||||
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-neutral-800 text-[22px] font-medium leading-[30.80px];
|
||||
@apply text-gray-800 text-[22px] font-medium leading-[30.80px];
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-neutral-500 text-sm font-medium uppercase leading-tight tracking-wide;
|
||||
@apply text-gray-500 text-sm font-medium uppercase leading-tight tracking-wide;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply p-5 bg-white rounded-lg border border-gray-200;
|
||||
.details-card h1 {
|
||||
@apply text-gray-800 text-lg font-medium leading-snug;
|
||||
}
|
||||
.card h1 {
|
||||
@apply text-neutral-800 text-lg font-medium leading-snug;
|
||||
.details-card h2 {
|
||||
@apply text-gray-500 text-xs font-semibold uppercase tracking-wide;
|
||||
}
|
||||
.card h2 {
|
||||
@apply text-neutral-500 text-xs font-semibold uppercase tracking-wide;
|
||||
.details-card table {
|
||||
@apply w-full;
|
||||
}
|
||||
.card tbody {
|
||||
.details-card tbody {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
.card td:first-child {
|
||||
@apply w-40 text-neutral-500 text-sm leading-tight flex-shrink-0;
|
||||
.details-card tr {
|
||||
@apply grid grid-flow-col grid-cols-3 gap-2;
|
||||
}
|
||||
.card td:last-child {
|
||||
@apply text-neutral-800 text-sm leading-tight;
|
||||
.details-card td:first-child {
|
||||
@apply text-gray-500 text-sm leading-tight truncate;
|
||||
}
|
||||
.details-card td:last-child {
|
||||
@apply col-span-2 text-gray-800 text-sm leading-tight;
|
||||
}
|
||||
|
||||
.description {
|
||||
@apply text-neutral-500 leading-snug;
|
||||
@apply text-gray-500 leading-snug;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,21 +197,21 @@
|
||||
* You can use the -large and -small modifiers for size variants.
|
||||
*/
|
||||
.toggle {
|
||||
@apply appearance-none relative w-10 h-5 rounded-full bg-neutral-300 cursor-pointer;
|
||||
@apply appearance-none relative w-10 h-5 rounded-full bg-gray-300 cursor-pointer;
|
||||
transition: background-color 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.toggle:disabled {
|
||||
@apply bg-neutral-200;
|
||||
@apply bg-gray-200;
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
.toggle:checked {
|
||||
@apply bg-indigo-500;
|
||||
@apply bg-blue-500;
|
||||
}
|
||||
|
||||
.toggle:checked:disabled {
|
||||
@apply bg-indigo-300;
|
||||
@apply bg-blue-300;
|
||||
}
|
||||
|
||||
.toggle:focus {
|
||||
@@ -86,7 +230,7 @@
|
||||
}
|
||||
|
||||
.toggle:checked:disabled::after {
|
||||
@apply bg-indigo-50;
|
||||
@apply bg-blue-50;
|
||||
}
|
||||
|
||||
.toggle:enabled:active::after {
|
||||
@@ -145,6 +289,39 @@
|
||||
@apply w-[0.675rem] translate-x-[0.55rem];
|
||||
}
|
||||
|
||||
/**
|
||||
* .button encapsulates all the base button styles we use across the app.
|
||||
*/
|
||||
|
||||
.button {
|
||||
@apply relative inline-flex flex-nowrap items-center justify-center font-medium py-2 px-4 rounded-md border border-transparent text-center whitespace-nowrap;
|
||||
transition-property: background-color, border-color, color, box-shadow;
|
||||
transition-duration: 120ms;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.button:focus-visible {
|
||||
@apply outline-none ring;
|
||||
}
|
||||
.button:disabled {
|
||||
@apply pointer-events-none select-none;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
|
||||
.button-group .button {
|
||||
@apply min-w-[60px];
|
||||
}
|
||||
|
||||
.button-group .button:not(:first-child) {
|
||||
@apply rounded-l-none;
|
||||
}
|
||||
|
||||
.button-group .button:not(:last-child) {
|
||||
@apply rounded-r-none border-r-0;
|
||||
}
|
||||
|
||||
/**
|
||||
* .input defines default text input field styling. These styles should
|
||||
* correspond to .button, sharing a similar height and rounding, since .input
|
||||
@@ -180,6 +357,104 @@
|
||||
.input-error {
|
||||
@apply border-red-200;
|
||||
}
|
||||
|
||||
/**
|
||||
* .loading-dots creates a set of three dots that pulse for indicating loading
|
||||
* states where a more horizontal appearance is helpful.
|
||||
*/
|
||||
|
||||
.loading-dots {
|
||||
@apply inline-flex items-center;
|
||||
}
|
||||
|
||||
.loading-dots span {
|
||||
@apply inline-block w-[0.35rem] h-[0.35rem] rounded-full bg-current mx-[0.15em];
|
||||
animation-name: loading-dots-blink;
|
||||
animation-duration: 1.4s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(2) {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(3) {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
@keyframes loading-dots-blink {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* .spinner creates a circular animated spinner, most often used to indicate a
|
||||
* loading state. The .spinner element must define a width, height, and
|
||||
* border-width for the spinner to apply.
|
||||
*/
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply border-transparent border-t-current border-l-current rounded-full;
|
||||
animation: spin 700ms linear infinite;
|
||||
}
|
||||
|
||||
/**
|
||||
* .link applies standard styling to links across the app. By default we unstyle
|
||||
* all anchor tags. While this might sound crazy for a website, it's _very_
|
||||
* helpful in an app, since anchor tags can be used to wrap buttons, icons,
|
||||
* and all manner of UI component. As a result, all anchor tags intended to look
|
||||
* like links should have a .link class.
|
||||
*/
|
||||
|
||||
.link {
|
||||
@apply text-text-primary;
|
||||
}
|
||||
|
||||
.link:hover,
|
||||
.link:active {
|
||||
@apply text-blue-700;
|
||||
}
|
||||
|
||||
.link-destructive {
|
||||
@apply text-text-danger;
|
||||
}
|
||||
|
||||
.link-destructive:hover,
|
||||
.link-destructive:active {
|
||||
@apply text-red-700;
|
||||
}
|
||||
|
||||
.link-fade {
|
||||
}
|
||||
|
||||
.link-fade:hover {
|
||||
@apply opacity-75;
|
||||
}
|
||||
|
||||
.link-underline {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
.link-underline:hover {
|
||||
@apply opacity-75;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -187,150 +462,3 @@
|
||||
@apply h-[2.375rem];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-Tailwind styles begin here.
|
||||
*/
|
||||
|
||||
.bg-gray-0 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
html {
|
||||
letter-spacing: -0.015em;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.link {
|
||||
--text-opacity: 1;
|
||||
color: #4b70cc;
|
||||
color: rgba(75, 112, 204, var(--text-opacity));
|
||||
}
|
||||
|
||||
.link:hover,
|
||||
.link:active {
|
||||
--text-opacity: 1;
|
||||
color: #19224a;
|
||||
color: rgba(25, 34, 74, var(--text-opacity));
|
||||
}
|
||||
|
||||
.link-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link-underline:hover,
|
||||
.link-underline:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-muted {
|
||||
/* same as text-gray-500 */
|
||||
--tw-text-opacity: 1;
|
||||
color: rgba(112, 110, 109, var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.link-muted:hover,
|
||||
.link-muted:active {
|
||||
/* same as text-gray-500 */
|
||||
--tw-text-opacity: 1;
|
||||
color: rgba(68, 67, 66, var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.button {
|
||||
font-weight: 500;
|
||||
padding-top: 0.45rem;
|
||||
padding-bottom: 0.45rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
border-color: transparent;
|
||||
transition-property: background-color, border-color, color, box-shadow;
|
||||
transition-duration: 120ms;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.button-blue {
|
||||
--bg-opacity: 1;
|
||||
background-color: #4b70cc;
|
||||
background-color: rgba(75, 112, 204, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #4b70cc;
|
||||
border-color: rgba(75, 112, 204, var(--border-opacity));
|
||||
--text-opacity: 1;
|
||||
color: #fff;
|
||||
color: rgba(255, 255, 255, var(--text-opacity));
|
||||
}
|
||||
|
||||
.button-blue:enabled:hover {
|
||||
--bg-opacity: 1;
|
||||
background-color: #3f5db3;
|
||||
background-color: rgba(63, 93, 179, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #3f5db3;
|
||||
border-color: rgba(63, 93, 179, var(--border-opacity));
|
||||
}
|
||||
|
||||
.button-blue:disabled {
|
||||
--text-opacity: 1;
|
||||
color: #cedefd;
|
||||
color: rgba(206, 222, 253, var(--text-opacity));
|
||||
--bg-opacity: 1;
|
||||
background-color: #6c94ec;
|
||||
background-color: rgba(108, 148, 236, var(--bg-opacity));
|
||||
--border-opacity: 1;
|
||||
border-color: #6c94ec;
|
||||
border-color: rgba(108, 148, 236, var(--border-opacity));
|
||||
}
|
||||
|
||||
.button-red {
|
||||
background-color: #d04841;
|
||||
border-color: #d04841;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-red:enabled:hover {
|
||||
background-color: #b22d30;
|
||||
border-color: #b22d30;
|
||||
}
|
||||
|
||||
/**
|
||||
* .spinner creates a circular animated spinner, most often used to indicate a
|
||||
* loading state. The .spinner element must define a width, height, and
|
||||
* border-width for the spinner to apply.
|
||||
*/
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply border-transparent border-t-current border-l-current rounded-full;
|
||||
animation: spin 700ms linear infinite;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
|
||||
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.
|
||||
@@ -25,6 +28,10 @@ const root = createRoot(rootEl)
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<SWRConfig value={swrConfig}>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</SWRConfig>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
113
client/web/src/types.ts
Normal file
113
client/web/src/types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { assertNever } from "src/utils/util"
|
||||
|
||||
export type NodeData = {
|
||||
Profile: UserProfile
|
||||
Status: NodeState
|
||||
DeviceName: string
|
||||
OS: string
|
||||
IPv4: string
|
||||
IPv6: string
|
||||
ID: string
|
||||
KeyExpiry: string
|
||||
KeyExpired: boolean
|
||||
UsingExitNode?: ExitNode
|
||||
AdvertisingExitNode: boolean
|
||||
AdvertisingExitNodeApproved: boolean
|
||||
AdvertisedRoutes?: SubnetRoute[]
|
||||
TUNMode: boolean
|
||||
IsSynology: boolean
|
||||
DSMVersion: number
|
||||
IsUnraid: boolean
|
||||
UnraidToken: string
|
||||
IPNVersion: string
|
||||
ClientVersion?: VersionInfo
|
||||
URLPrefix: string
|
||||
DomainName: string
|
||||
TailnetName: string
|
||||
IsTagged: boolean
|
||||
Tags: string[]
|
||||
RunningSSHServer: boolean
|
||||
ControlAdminURL: string
|
||||
LicensesURL: string
|
||||
Features: { [key in Feature]: boolean } // value is true if given feature is available on this client
|
||||
ACLAllowsAnyIncomingTraffic: boolean
|
||||
}
|
||||
|
||||
export type NodeState =
|
||||
| "NoState"
|
||||
| "NeedsLogin"
|
||||
| "NeedsMachineAuth"
|
||||
| "Stopped"
|
||||
| "Starting"
|
||||
| "Running"
|
||||
|
||||
export type UserProfile = {
|
||||
LoginName: string
|
||||
DisplayName: string
|
||||
ProfilePicURL: string
|
||||
}
|
||||
|
||||
export type SubnetRoute = {
|
||||
Route: string
|
||||
Approved: boolean
|
||||
}
|
||||
|
||||
export type ExitNode = {
|
||||
ID: string
|
||||
Name: string
|
||||
Location?: ExitNodeLocation
|
||||
Online?: boolean
|
||||
}
|
||||
|
||||
export type ExitNodeLocation = {
|
||||
Country: string
|
||||
CountryCode: CountryCode
|
||||
City: string
|
||||
CityCode: CityCode
|
||||
Priority: number
|
||||
}
|
||||
|
||||
export type CountryCode = string
|
||||
export type CityCode = string
|
||||
|
||||
export type ExitNodeGroup = {
|
||||
id: string
|
||||
name?: string
|
||||
nodes: ExitNode[]
|
||||
}
|
||||
|
||||
export type Feature =
|
||||
| "advertise-exit-node"
|
||||
| "advertise-routes"
|
||||
| "use-exit-node"
|
||||
| "ssh"
|
||||
| "auto-update"
|
||||
|
||||
export const featureDescription = (f: Feature) => {
|
||||
switch (f) {
|
||||
case "advertise-exit-node":
|
||||
return "Advertising as an exit node"
|
||||
case "advertise-routes":
|
||||
return "Advertising subnet routes"
|
||||
case "use-exit-node":
|
||||
return "Using an exit node"
|
||||
case "ssh":
|
||||
return "Running a Tailscale SSH server"
|
||||
case "auto-update":
|
||||
return "Auto updating client versions"
|
||||
default:
|
||||
assertNever(f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VersionInfo type is deserialized from tailcfg.ClientVersion,
|
||||
* so it should not include fields not included in that type.
|
||||
*/
|
||||
export type VersionInfo = {
|
||||
RunningLatest: boolean
|
||||
LatestVersion?: string
|
||||
}
|
||||
@@ -2,32 +2,148 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { ButtonHTMLAttributes } from "react"
|
||||
import React, { HTMLProps } from "react"
|
||||
import LoadingDots from "src/ui/loading-dots"
|
||||
|
||||
type Props = {
|
||||
intent?: "primary" | "secondary"
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>
|
||||
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
|
||||
|
||||
export default function Button(props: Props) {
|
||||
const { intent = "primary", className, disabled, children, ...rest } = props
|
||||
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(
|
||||
"px-3 py-2 rounded shadow justify-center items-center gap-2.5 inline-flex font-medium",
|
||||
"button",
|
||||
{
|
||||
"bg-indigo-500 text-white": intent === "primary" && !disabled,
|
||||
"bg-indigo-400 text-indigo-200": intent === "primary" && disabled,
|
||||
"bg-stone-50 shadow border border-stone-200 text-neutral-800":
|
||||
intent === "secondary",
|
||||
"cursor-not-allowed": disabled,
|
||||
// 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}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
{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
|
||||
|
||||
40
client/web/src/ui/card.tsx
Normal file
40
client/web/src/ui/card.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export default function Collapsible(props: CollapsibleProps) {
|
||||
onOpenChange?.(open)
|
||||
}}
|
||||
>
|
||||
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-stone-100 rounded text-sm font-medium pr-3 py-1 transition-colors">
|
||||
<Primitive.Trigger className="inline-flex items-center text-gray-600 cursor-pointer hover:bg-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>
|
||||
|
||||
370
client/web/src/ui/dialog.tsx
Normal file
370
client/web/src/ui/dialog.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
// 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 { ReactComponent as X } from "src/assets/icons/x.svg"
|
||||
import Button from "src/ui/button"
|
||||
import PortalContainerContext from "src/ui/portal-container-context"
|
||||
import { isObject } from "src/utils/util"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
44
client/web/src/ui/empty-state.tsx
Normal file
44
client/web/src/ui/empty-state.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// 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
|
||||
}
|
||||
23
client/web/src/ui/loading-dots.tsx
Normal file
23
client/web/src/ui/loading-dots.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { HTMLAttributes } from "react"
|
||||
|
||||
type Props = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
/**
|
||||
* LoadingDots provides a set of horizontal dots to indicate a loading state.
|
||||
* These dots are helpful in horizontal contexts (like buttons) where a spinner
|
||||
* doesn't fit as well.
|
||||
*/
|
||||
export default function LoadingDots(props: Props) {
|
||||
const { className, ...rest } = props
|
||||
return (
|
||||
<div className={cx(className, "loading-dots")} {...rest}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
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
|
||||
@@ -103,7 +104,3 @@ export default function Popover(props: Props) {
|
||||
Popover.defaultProps = {
|
||||
sideOffset: 10,
|
||||
}
|
||||
|
||||
const PortalContainerContext = React.createContext<HTMLElement | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
9
client/web/src/ui/portal-container-context.tsx
Normal file
9
client/web/src/ui/portal-container-context.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
// 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
|
||||
160
client/web/src/ui/quick-copy.tsx
Normal file
160
client/web/src/ui/quick-copy.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -17,10 +17,10 @@ const SearchInput = forwardRef<HTMLInputElement, Props>((props, ref) => {
|
||||
const { className, inputClassName, ...rest } = props
|
||||
return (
|
||||
<div className={cx("relative", className)}>
|
||||
<Search className="absolute w-[1.25em] h-full ml-2" />
|
||||
<Search className="absolute text-gray-400 w-[1.25em] h-full ml-2" />
|
||||
<input
|
||||
type="text"
|
||||
className={cx("input px-8", inputClassName)}
|
||||
className={cx("input pl-9 pr-8", inputClassName)}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
280
client/web/src/ui/toaster.tsx
Normal file
280
client/web/src/ui/toaster.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
// 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 { ReactComponent as X } from "src/assets/icons/x.svg"
|
||||
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)
|
||||
)
|
||||
}
|
||||
77
client/web/src/utils/clipboard.ts
Normal file
77
client/web/src/utils/clipboard.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// 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()
|
||||
}
|
||||
21
client/web/src/utils/util.test.ts
Normal file
21
client/web/src/utils/util.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
51
client/web/src/utils/util.ts
Normal file
51
client/web/src/utils/util.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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
|
||||
}
|
||||
85
client/web/styles.json
Normal file
85
client/web/styles.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"colors": {
|
||||
"transparent": "transparent",
|
||||
"current": "currentColor",
|
||||
"white": "rgb(var(--color-white) / <alpha-value>)",
|
||||
"gray": {
|
||||
"0": "rgb(var(--color-gray-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-gray-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-gray-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-gray-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-gray-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-gray-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-gray-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-gray-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-gray-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-gray-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-gray-900) / <alpha-value>)"
|
||||
},
|
||||
"blue": {
|
||||
"0": "rgb(var(--color-blue-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-blue-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-blue-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-blue-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-blue-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-blue-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-blue-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-blue-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-blue-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-blue-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-blue-900) / <alpha-value>)"
|
||||
},
|
||||
"green": {
|
||||
"0": "rgb(var(--color-green-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-green-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-green-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-green-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-green-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-green-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-green-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-green-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-green-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-green-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-green-900) / <alpha-value>)"
|
||||
},
|
||||
"red": {
|
||||
"0": "rgb(var(--color-red-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-red-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-red-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-red-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-red-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-red-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-red-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-red-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-red-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-red-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-red-900) / <alpha-value>)"
|
||||
},
|
||||
"yellow": {
|
||||
"0": "rgb(var(--color-yellow-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-yellow-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-yellow-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-yellow-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-yellow-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-yellow-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-yellow-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-yellow-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-yellow-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-yellow-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-yellow-900) / <alpha-value>)"
|
||||
},
|
||||
"orange": {
|
||||
"0": "rgb(var(--color-orange-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-orange-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-orange-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-orange-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-orange-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-orange-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-orange-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-orange-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-orange-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-orange-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-orange-900) / <alpha-value>)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
const plugin = require("tailwindcss/plugin")
|
||||
const styles = require("./styles.json")
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
screens: {
|
||||
sm: "420px",
|
||||
md: "768px",
|
||||
lg: "1024px",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Inter",
|
||||
@@ -29,8 +33,66 @@ module.exports = {
|
||||
semibold: "600",
|
||||
bold: "700",
|
||||
},
|
||||
extend: {},
|
||||
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", [
|
||||
@@ -41,6 +103,13 @@ module.exports = {
|
||||
'&[data-state="closed"]',
|
||||
'[data-state="closed"] &',
|
||||
])
|
||||
addVariant("state-delayed-open", [
|
||||
'&[data-state="delayed-open"]',
|
||||
'[data-state="delayed-open"] &',
|
||||
])
|
||||
addVariant("state-active", ['&[data-state="active"]'])
|
||||
addVariant("state-inactive", ['&[data-state="inactive"]'])
|
||||
}),
|
||||
],
|
||||
content: ["./src/**/*.html", "./src/**/*.{ts,tsx}", "./index.html"],
|
||||
}
|
||||
|
||||
@@ -15,16 +15,19 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/tailscale/csrf"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/clientupdate"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/licenses"
|
||||
@@ -33,6 +36,7 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
@@ -171,6 +175,14 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
||||
newAuthURL: opts.NewAuthURL,
|
||||
waitAuthURL: opts.WaitAuthURL,
|
||||
}
|
||||
if opts.PathPrefix != "" {
|
||||
// Enforce that path prefix always has a single leading '/'
|
||||
// so that it is treated as a relative URL path.
|
||||
// We strip multiple leading '/' to prevent schema-less offsite URLs like "//example.com".
|
||||
//
|
||||
// See https://github.com/tailscale/corp/issues/16268.
|
||||
s.pathPrefix = "/" + strings.TrimLeft(path.Clean(opts.PathPrefix), "/\\")
|
||||
}
|
||||
if s.mode == ManageServerMode {
|
||||
if opts.NewAuthURL == nil {
|
||||
return nil, fmt.Errorf("must provide a NewAuthURL implementation")
|
||||
@@ -226,7 +238,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
handler := s.serve
|
||||
|
||||
// if path prefix is defined, strip it from requests.
|
||||
if s.pathPrefix != "" {
|
||||
if s.cgiMode && s.pathPrefix != "" {
|
||||
handler = enforcePrefix(s.pathPrefix, handler)
|
||||
}
|
||||
|
||||
@@ -248,9 +260,13 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if !s.devMode {
|
||||
// This hash corresponds to the inline script in index.html that runs when the react app is unavailable.
|
||||
// It was generated from https://csplite.com/csp/sha/.
|
||||
// If the contents of the script are changed, this hash must be updated.
|
||||
const indexScriptHash = "sha384-CW2AYVfS14P7QHZN27thEkMLKiCj3YNURPoLc1elwiEkMVHeuYTWkJOEki1F3nZc"
|
||||
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
// TODO: use CSP nonce or hash to eliminate need for unsafe-inline
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; img-src * data:")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src * data:; script-src 'self' '"+indexScriptHash+"'")
|
||||
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
}
|
||||
}
|
||||
@@ -275,9 +291,6 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
s.apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !s.devMode {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_page_load", 1)
|
||||
}
|
||||
s.assetsHandler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -302,24 +315,63 @@ func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (han
|
||||
return true
|
||||
}
|
||||
|
||||
var ipv4 string // store the first IPv4 address we see for redirect later
|
||||
for _, ip := range st.Self.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
if r.Host == fmt.Sprintf("%s:%d", ip, ListenPort) {
|
||||
return false
|
||||
}
|
||||
ipv4 = ip.String()
|
||||
}
|
||||
if ip.Is6() && r.Host == fmt.Sprintf("[%s]:%d", ip, ListenPort) {
|
||||
return false
|
||||
}
|
||||
ipv4, ipv6 := s.selfNodeAddresses(r, st)
|
||||
if r.Host == fmt.Sprintf("%s:%d", ipv4.String(), ListenPort) {
|
||||
return false // already accessing over Tailscale IP
|
||||
}
|
||||
if r.Host == fmt.Sprintf("[%s]:%d", ipv6.String(), ListenPort) {
|
||||
return false // already accessing over Tailscale IP
|
||||
}
|
||||
|
||||
// Not currently accessing via Tailscale IP,
|
||||
// redirect them.
|
||||
|
||||
var preferV6 bool
|
||||
if ap, err := netip.ParseAddrPort(r.Host); err == nil {
|
||||
// If Host was already ipv6, keep them on same protocol.
|
||||
preferV6 = ap.Addr().Is6()
|
||||
}
|
||||
|
||||
newURL := *r.URL
|
||||
newURL.Host = fmt.Sprintf("%s:%d", ipv4, ListenPort)
|
||||
if (preferV6 && ipv6.IsValid()) || !ipv4.IsValid() {
|
||||
newURL.Host = fmt.Sprintf("[%s]:%d", ipv6.String(), ListenPort)
|
||||
} else {
|
||||
newURL.Host = fmt.Sprintf("%s:%d", ipv4.String(), ListenPort)
|
||||
}
|
||||
http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently)
|
||||
return true
|
||||
}
|
||||
|
||||
// selfNodeAddresses return the Tailscale IPv4 and IPv6 addresses for the self node.
|
||||
// st is expected to be a status with peers included.
|
||||
func (s *Server) selfNodeAddresses(r *http.Request, st *ipnstate.Status) (ipv4, ipv6 netip.Addr) {
|
||||
for _, ip := range st.Self.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
ipv4 = ip
|
||||
} else if ip.Is6() {
|
||||
ipv6 = ip
|
||||
}
|
||||
if ipv4.IsValid() && ipv6.IsValid() {
|
||||
break // found both IPs
|
||||
}
|
||||
}
|
||||
if whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil {
|
||||
// The source peer connecting to this node may know it by a different
|
||||
// IP than the node knows itself as. Specifically, this may be the case
|
||||
// if the peer is coming from a different tailnet (sharee node), as IPs
|
||||
// are specific to each tailnet.
|
||||
// Here, we check if the source peer knows the node by a different IP,
|
||||
// and return the peer's version if so.
|
||||
if knownIPv4 := whois.Node.SelfNodeV4MasqAddrForThisPeer; knownIPv4 != nil {
|
||||
ipv4 = *knownIPv4
|
||||
}
|
||||
if knownIPv6 := whois.Node.SelfNodeV6MasqAddrForThisPeer; knownIPv6 != nil {
|
||||
ipv6 = *knownIPv6
|
||||
}
|
||||
}
|
||||
return ipv4, ipv6
|
||||
}
|
||||
|
||||
// authorizeRequest reports whether the request from the web client
|
||||
// is authorized to be completed.
|
||||
// It reports true if the request is authorized, and false otherwise.
|
||||
@@ -327,7 +379,7 @@ func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (han
|
||||
// errors to the ResponseWriter itself.
|
||||
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
||||
if s.mode == ManageServerMode { // client using tailscale auth
|
||||
session, _, err := s.getSession(r)
|
||||
session, _, _, err := s.getSession(r)
|
||||
switch {
|
||||
case errors.Is(err, errNotUsingTailscale):
|
||||
// All requests must be made over tailscale.
|
||||
@@ -336,6 +388,9 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
|
||||
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
||||
// Readonly endpoint allowed without valid browser session.
|
||||
return true
|
||||
case r.URL.Path == "/api/device-details-click" && r.Method == httpm.POST:
|
||||
// Special case metric endpoint that is allowed without a browser session.
|
||||
return true
|
||||
case strings.HasPrefix(r.URL.Path, "/api/"):
|
||||
// All other /api/ endpoints require a valid browser session.
|
||||
if err != nil || !session.isAuthorized(s.timeNow()) {
|
||||
@@ -371,6 +426,8 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.serveGetNodeData(w, r)
|
||||
case r.URL.Path == "/api/up" && r.Method == httpm.POST:
|
||||
s.serveTailscaleUp(w, r)
|
||||
case r.URL.Path == "/api/device-details-click" && r.Method == httpm.POST:
|
||||
s.serveDeviceDetailsClick(w, r)
|
||||
default:
|
||||
http.Error(w, "invalid endpoint or method", http.StatusNotFound)
|
||||
}
|
||||
@@ -387,6 +444,7 @@ type authResponse struct {
|
||||
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
|
||||
CanManageNode bool `json:"canManageNode"`
|
||||
ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
|
||||
ServerMode ServerMode `json:"serverMode"`
|
||||
}
|
||||
|
||||
// viewerIdentity is the Tailscale identity of the source node
|
||||
@@ -402,46 +460,8 @@ type viewerIdentity struct {
|
||||
// and returns an authResponse indicating the current auth state and any steps the user needs to take.
|
||||
func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var resp authResponse
|
||||
|
||||
session, whois, err := s.getSession(r)
|
||||
switch {
|
||||
case err != nil && errors.Is(err, errNotUsingTailscale):
|
||||
// not using tailscale, so perform platform auth
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
authorized, err := authorizeSynology(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !authorized {
|
||||
resp.AuthNeeded = synoAuth
|
||||
}
|
||||
case distro.QNAP:
|
||||
if _, err := authorizeQNAP(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
default:
|
||||
// no additional auth for this distro
|
||||
}
|
||||
case err != nil && (errors.Is(err, errNotOwner) ||
|
||||
errors.Is(err, errNotUsingTailscale) ||
|
||||
errors.Is(err, errTaggedLocalSource) ||
|
||||
errors.Is(err, errTaggedRemoteSource)):
|
||||
// These cases are all restricted to the readonly view.
|
||||
// No auth action to take.
|
||||
resp.AuthNeeded = ""
|
||||
case err != nil && !errors.Is(err, errNoSession):
|
||||
// Any other error.
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
case session.isAuthorized(s.timeNow()):
|
||||
resp.CanManageNode = true
|
||||
resp.AuthNeeded = ""
|
||||
default:
|
||||
resp.AuthNeeded = tailscaleAuth
|
||||
}
|
||||
resp.ServerMode = s.mode
|
||||
session, whois, status, sErr := s.getSession(r)
|
||||
|
||||
if whois != nil {
|
||||
resp.ViewerIdentity = &viewerIdentity{
|
||||
@@ -453,6 +473,71 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()
|
||||
}
|
||||
}
|
||||
|
||||
// First verify platform auth.
|
||||
// If platform auth is needed, this should happen first.
|
||||
if s.mode == LoginServerMode {
|
||||
switch distro.Get() {
|
||||
case distro.Synology:
|
||||
authorized, err := authorizeSynology(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !authorized {
|
||||
resp.AuthNeeded = synoAuth
|
||||
writeJSON(w, resp)
|
||||
return
|
||||
}
|
||||
case distro.QNAP:
|
||||
if _, err := authorizeQNAP(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
default:
|
||||
// no additional auth for this distro
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
|
||||
// Restricted to the readonly view, no auth action to take.
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
|
||||
resp.AuthNeeded = ""
|
||||
case sErr != nil && errors.Is(sErr, errNotOwner):
|
||||
// Restricted to the readonly view, no auth action to take.
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1)
|
||||
resp.AuthNeeded = ""
|
||||
case sErr != nil && errors.Is(sErr, errTaggedLocalSource):
|
||||
// Restricted to the readonly view, no auth action to take.
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1)
|
||||
resp.AuthNeeded = ""
|
||||
case sErr != nil && errors.Is(sErr, errTaggedRemoteSource):
|
||||
// Restricted to the readonly view, no auth action to take.
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1)
|
||||
resp.AuthNeeded = ""
|
||||
case sErr != nil && !errors.Is(sErr, errNoSession):
|
||||
// Any other error.
|
||||
http.Error(w, sErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
case session.isAuthorized(s.timeNow()):
|
||||
if whois.Node.StableID == status.Self.ID {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_managing_local", 1)
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_managing_remote", 1)
|
||||
}
|
||||
resp.CanManageNode = true
|
||||
resp.AuthNeeded = ""
|
||||
default:
|
||||
// whois being nil implies local as the request did not come over Tailscale
|
||||
if whois == nil || (whois.Node.StableID == status.Self.ID) {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote", 1)
|
||||
}
|
||||
resp.AuthNeeded = tailscaleAuth
|
||||
}
|
||||
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
@@ -462,7 +547,7 @@ type newSessionAuthResponse struct {
|
||||
|
||||
// serveAPIAuthSessionNew handles requests to the /api/auth/session/new endpoint.
|
||||
func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request) {
|
||||
session, whois, err := s.getSession(r)
|
||||
session, whois, _, err := s.getSession(r)
|
||||
if err != nil && !errors.Is(err, errNoSession) {
|
||||
// Source associated with request not allowed to create
|
||||
// a session for this web client.
|
||||
@@ -479,11 +564,19 @@ func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
// Set the cookie on browser.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: session.ID,
|
||||
Raw: session.ID,
|
||||
Path: "/",
|
||||
Expires: session.expires(),
|
||||
Name: sessionCookieName,
|
||||
Value: session.ID,
|
||||
Raw: session.ID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: session.expires(),
|
||||
// We can't set Secure to true because we serve over HTTP
|
||||
// (but only on Tailscale IPs, hence over encrypted
|
||||
// connections that a LAN-local attacker cannot sniff).
|
||||
// In the future, we could support HTTPS requests using
|
||||
// the full MagicDNS hostname, and could set this.
|
||||
// Secure: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -492,7 +585,7 @@ func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// serveAPIAuthSessionWait handles requests to the /api/auth/session/wait endpoint.
|
||||
func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request) {
|
||||
session, _, err := s.getSession(r)
|
||||
session, _, _, err := s.getSession(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
@@ -522,6 +615,9 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
case path == "/routes" && r.Method == httpm.POST:
|
||||
s.servePostRoutes(w, r)
|
||||
return
|
||||
case path == "/device-details-click" && r.Method == httpm.POST:
|
||||
s.serveDeviceDetailsClick(w, r)
|
||||
return
|
||||
case strings.HasPrefix(path, "/local/"):
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
@@ -535,7 +631,7 @@ type nodeData struct {
|
||||
DeviceName string
|
||||
TailnetName string // TLS cert name
|
||||
DomainName string
|
||||
IP string // IPv4
|
||||
IPv4 string
|
||||
IPv6 string
|
||||
OS string
|
||||
IPNVersion string
|
||||
@@ -554,15 +650,28 @@ type nodeData struct {
|
||||
UnraidToken string
|
||||
URLPrefix string // if set, the URL prefix the client is served behind
|
||||
|
||||
UsingExitNode *exitNode
|
||||
AdvertisingExitNode bool
|
||||
AdvertisedRoutes []subnetRoute // excludes exit node routes
|
||||
RunningSSHServer bool
|
||||
UsingExitNode *exitNode
|
||||
AdvertisingExitNode bool
|
||||
AdvertisingExitNodeApproved bool // whether running this node as an exit node has been approved by an admin
|
||||
AdvertisedRoutes []subnetRoute // excludes exit node routes
|
||||
RunningSSHServer bool
|
||||
|
||||
ClientVersion *tailcfg.ClientVersion
|
||||
|
||||
// whether tailnet ACLs allow access to port 5252 on this device
|
||||
ACLAllowsAnyIncomingTraffic bool
|
||||
|
||||
ControlAdminURL string
|
||||
LicensesURL string
|
||||
|
||||
// Features is the set of available features for use on the
|
||||
// current platform. e.g. "ssh", "advertise-exit-node", etc.
|
||||
// Map value is true if the given feature key is available.
|
||||
//
|
||||
// See web.availableFeatures func for population of this field.
|
||||
// Contents are expected to match values defined in node-data.ts
|
||||
// on the frontend.
|
||||
Features map[string]bool
|
||||
}
|
||||
|
||||
type subnetRoute struct {
|
||||
@@ -581,6 +690,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
filterRules, _ := s.lc.DebugPacketFilterRules(r.Context())
|
||||
data := &nodeData{
|
||||
ID: st.Self.ID,
|
||||
Status: st.BackendState,
|
||||
@@ -599,6 +709,19 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
|
||||
ControlAdminURL: prefs.AdminPageURL(),
|
||||
LicensesURL: licenses.LicensesURL(),
|
||||
Features: availableFeatures(),
|
||||
|
||||
ACLAllowsAnyIncomingTraffic: s.aclsAllowAccess(filterRules),
|
||||
}
|
||||
|
||||
ipv4, ipv6 := s.selfNodeAddresses(r, st)
|
||||
data.IPv4 = ipv4.String()
|
||||
data.IPv6 = ipv6.String()
|
||||
|
||||
if hostinfo.GetEnvType() == hostinfo.HomeAssistantAddOn && data.URLPrefix == "" {
|
||||
// X-Ingress-Path is the path prefix in use for Home Assistant
|
||||
// https://developers.home-assistant.io/docs/add-ons/presentation#ingress
|
||||
data.URLPrefix = r.Header.Get("X-Ingress-Path")
|
||||
}
|
||||
|
||||
cv, err := s.lc.CheckUpdate(r.Context())
|
||||
@@ -607,16 +730,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
data.ClientVersion = cv
|
||||
}
|
||||
for _, ip := range st.TailscaleIPs {
|
||||
if ip.Is4() {
|
||||
data.IP = ip.String()
|
||||
} else if ip.Is6() {
|
||||
data.IPv6 = ip.String()
|
||||
}
|
||||
if data.IP != "" && data.IPv6 != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if st.CurrentTailnet != nil {
|
||||
data.TailnetName = st.CurrentTailnet.MagicDNSSuffix
|
||||
data.DomainName = st.CurrentTailnet.Name
|
||||
@@ -636,6 +750,8 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
return p == route
|
||||
})
|
||||
}
|
||||
data.AdvertisingExitNodeApproved = routeApproved(exitNodeRouteV4) || routeApproved(exitNodeRouteV6)
|
||||
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
data.AdvertisingExitNode = true
|
||||
@@ -671,6 +787,52 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, *data)
|
||||
}
|
||||
|
||||
func availableFeatures() map[string]bool {
|
||||
env := hostinfo.GetEnvType()
|
||||
features := map[string]bool{
|
||||
"advertise-exit-node": true, // available on all platforms
|
||||
"advertise-routes": true, // available on all platforms
|
||||
"use-exit-node": canUseExitNode(env) == nil,
|
||||
"ssh": envknob.CanRunTailscaleSSH() == nil,
|
||||
"auto-update": version.IsUnstableBuild() && clientupdate.CanAutoUpdate(),
|
||||
}
|
||||
if env == hostinfo.HomeAssistantAddOn {
|
||||
// Setting SSH on Home Assistant causes trouble on startup
|
||||
// (since the flag is not being passed to `tailscale up`).
|
||||
// Although Tailscale SSH does work here,
|
||||
// it's not terribly useful since it's running in a separate container.
|
||||
features["ssh"] = false
|
||||
}
|
||||
return features
|
||||
}
|
||||
|
||||
func canUseExitNode(env hostinfo.EnvType) error {
|
||||
switch dist := distro.Get(); dist {
|
||||
case distro.Synology, // see https://github.com/tailscale/tailscale/issues/1995
|
||||
distro.QNAP,
|
||||
distro.Unraid:
|
||||
return fmt.Errorf("Tailscale exit nodes cannot be used on %s.", dist)
|
||||
}
|
||||
if env == hostinfo.HomeAssistantAddOn {
|
||||
return errors.New("Tailscale exit nodes cannot be used on Home Assistant.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// aclsAllowAccess returns whether tailnet ACLs (as expressed in the provided filter rules)
|
||||
// permit any devices to access the local web client.
|
||||
// This does not currently check whether a specific device can connect, just any device.
|
||||
func (s *Server) aclsAllowAccess(rules []tailcfg.FilterRule) bool {
|
||||
for _, rule := range rules {
|
||||
for _, dp := range rule.DstPorts {
|
||||
if dp.Ports.Contains(ListenPort) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type exitNode struct {
|
||||
ID tailcfg.StableNodeID
|
||||
Name string
|
||||
@@ -693,15 +855,18 @@ func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
|
||||
ID: ps.ID,
|
||||
Name: ps.DNSName,
|
||||
Location: ps.Location,
|
||||
Online: ps.Online,
|
||||
})
|
||||
}
|
||||
writeJSON(w, exitNodes)
|
||||
}
|
||||
|
||||
type postRoutesRequest struct {
|
||||
SetExitNode bool // when set, UseExitNode and AdvertiseExitNode values are applied
|
||||
SetRoutes bool // when set, AdvertiseRoutes value is applied
|
||||
UseExitNode tailcfg.StableNodeID
|
||||
AdvertiseRoutes []string
|
||||
AdvertiseExitNode bool
|
||||
AdvertiseRoutes []string
|
||||
}
|
||||
|
||||
func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -712,12 +877,27 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
oldPrefs, err := s.lc.GetPrefs(r.Context())
|
||||
prefs, err := s.lc.GetPrefs(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var currNonExitRoutes []string
|
||||
var currAdvertisingExitNode bool
|
||||
for _, r := range prefs.AdvertiseRoutes {
|
||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||
currAdvertisingExitNode = true
|
||||
continue
|
||||
}
|
||||
currNonExitRoutes = append(currNonExitRoutes, r.String())
|
||||
}
|
||||
// Set non-edited fields to their current values.
|
||||
if data.SetExitNode {
|
||||
data.AdvertiseRoutes = currNonExitRoutes
|
||||
} else if data.SetRoutes {
|
||||
data.AdvertiseExitNode = currAdvertisingExitNode
|
||||
data.UseExitNode = prefs.ExitNodeID
|
||||
}
|
||||
|
||||
// Calculate routes.
|
||||
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
||||
@@ -751,15 +931,6 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Report metrics.
|
||||
if data.AdvertiseExitNode != hasExitNodeRoute(oldPrefs.AdvertiseRoutes) {
|
||||
if data.AdvertiseExitNode {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_enable", 1)
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_disable", 1)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -880,6 +1051,21 @@ func (s *Server) serveTailscaleUp(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// serveDeviceDetailsClick increments the web_client_device_details_click metric
|
||||
// by one.
|
||||
//
|
||||
// Metric logging from the frontend typically is proxied to the localapi. This event
|
||||
// has been special cased as access to the localapi is gated upon having a valid
|
||||
// session which is not always the case when we want to be logging this metric (e.g.,
|
||||
// when in readonly mode).
|
||||
//
|
||||
// Other metrics should not be logged in this way without a good reason.
|
||||
func (s *Server) serveDeviceDetailsClick(w http.ResponseWriter, r *http.Request) {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_device_details_click", 1)
|
||||
|
||||
io.WriteString(w, "{}")
|
||||
}
|
||||
|
||||
// proxyRequestToLocalAPI proxies the web API request to the localapi.
|
||||
//
|
||||
// The web API request path is expected to exactly match a localapi path,
|
||||
@@ -893,6 +1079,13 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Method == httpm.PATCH {
|
||||
// enforce that PATCH requests are always application/json
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !slices.Contains(localapiAllowlist, path) {
|
||||
http.Error(w, fmt.Sprintf("%s not allowed from localapi proxy", path), http.StatusForbidden)
|
||||
return
|
||||
@@ -933,6 +1126,7 @@ var localapiAllowlist = []string{
|
||||
"/v0/update/check",
|
||||
"/v0/update/install",
|
||||
"/v0/update/progress",
|
||||
"/v0/upload-client-metrics",
|
||||
}
|
||||
|
||||
// csrfKey returns a key that can be used for CSRF protection.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -99,29 +100,44 @@ func TestServeAPI(t *testing.T) {
|
||||
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reqPath string
|
||||
wantResp string
|
||||
wantStatus int
|
||||
name string
|
||||
reqMethod string
|
||||
reqPath string
|
||||
reqContentType string
|
||||
wantResp string
|
||||
wantStatus int
|
||||
}{{
|
||||
name: "invalid_endpoint",
|
||||
reqMethod: httpm.POST,
|
||||
reqPath: "/not-an-endpoint",
|
||||
wantResp: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}, {
|
||||
name: "not_in_localapi_allowlist",
|
||||
reqMethod: httpm.POST,
|
||||
reqPath: "/local/v0/not-allowlisted",
|
||||
wantResp: "/v0/not-allowlisted not allowed from localapi proxy",
|
||||
wantStatus: http.StatusForbidden,
|
||||
}, {
|
||||
name: "in_localapi_allowlist",
|
||||
reqMethod: httpm.POST,
|
||||
reqPath: "/local/v0/logout",
|
||||
wantResp: "success", // Successfully allowed to hit localapi.
|
||||
wantStatus: http.StatusOK,
|
||||
}, {
|
||||
name: "patch_bad_contenttype",
|
||||
reqMethod: httpm.PATCH,
|
||||
reqPath: "/local/v0/prefs",
|
||||
reqContentType: "multipart/form-data",
|
||||
wantResp: "invalid request",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/api"+tt.reqPath, nil)
|
||||
r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, nil)
|
||||
if tt.reqContentType != "" {
|
||||
r.Header.Add("Content-Type", tt.reqContentType)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.serveAPI(w, r)
|
||||
@@ -168,7 +184,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil)
|
||||
localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }, nil, nil)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
@@ -305,7 +321,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
|
||||
if tt.cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
}
|
||||
session, _, err := s.getSession(r)
|
||||
session, _, _, err := s.getSession(r)
|
||||
if !errors.Is(err, tt.wantError) {
|
||||
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
|
||||
}
|
||||
@@ -336,6 +352,7 @@ func TestAuthorizeRequest(t *testing.T) {
|
||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
@@ -445,6 +462,7 @@ func TestServeAuth(t *testing.T) {
|
||||
func() *ipn.Prefs {
|
||||
return &ipn.Prefs{ControlURL: *testControlURL}
|
||||
},
|
||||
nil,
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
@@ -505,7 +523,7 @@ func TestServeAuth(t *testing.T) {
|
||||
name: "no-session",
|
||||
path: "/api/auth",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi},
|
||||
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantNewCookie: false,
|
||||
wantSession: nil,
|
||||
},
|
||||
@@ -530,7 +548,7 @@ func TestServeAuth(t *testing.T) {
|
||||
path: "/api/auth",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi},
|
||||
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
@@ -578,7 +596,7 @@ func TestServeAuth(t *testing.T) {
|
||||
path: "/api/auth",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi},
|
||||
wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
@@ -713,6 +731,286 @@ func TestServeAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeAPIAuthMetricLogging specifically tests metric logging in the serveAPIAuth function.
|
||||
// For each given test case, we assert that the local API received a request to log the expected metric.
|
||||
func TestServeAPIAuthMetricLogging(t *testing.T) {
|
||||
user := &tailcfg.UserProfile{LoginName: "user@example.com", ID: tailcfg.UserID(1)}
|
||||
otherUser := &tailcfg.UserProfile{LoginName: "user2@example.com", ID: tailcfg.UserID(2)}
|
||||
self := &ipnstate.PeerStatus{
|
||||
ID: "self",
|
||||
UserID: user.ID,
|
||||
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.1.2.3")},
|
||||
}
|
||||
remoteIP := "100.100.100.101"
|
||||
remoteNode := &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{
|
||||
Name: "remote-managed",
|
||||
ID: 1,
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix(remoteIP + "/32")},
|
||||
},
|
||||
UserProfile: user,
|
||||
}
|
||||
remoteTaggedIP := "100.123.100.213"
|
||||
remoteTaggedNode := &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{
|
||||
Name: "remote-tagged",
|
||||
ID: 2,
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix(remoteTaggedIP + "/32")},
|
||||
Tags: []string{"dev-machine"},
|
||||
},
|
||||
UserProfile: user,
|
||||
}
|
||||
localIP := "100.1.2.3"
|
||||
localNode := &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{
|
||||
Name: "local-managed",
|
||||
ID: 3,
|
||||
StableID: "self",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix(localIP + "/32")},
|
||||
},
|
||||
UserProfile: user,
|
||||
}
|
||||
localTaggedIP := "100.1.2.133"
|
||||
localTaggedNode := &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{
|
||||
Name: "local-tagged",
|
||||
ID: 4,
|
||||
StableID: "self",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix(localTaggedIP + "/32")},
|
||||
Tags: []string{"prod-machine"},
|
||||
},
|
||||
UserProfile: user,
|
||||
}
|
||||
otherIP := "100.100.2.3"
|
||||
otherNode := &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{
|
||||
Name: "other-node",
|
||||
ID: 5,
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix(otherIP + "/32")},
|
||||
},
|
||||
UserProfile: otherUser,
|
||||
}
|
||||
nonTailscaleIP := "10.100.2.3"
|
||||
|
||||
testControlURL := &defaultControlURL
|
||||
var loggedMetrics []string
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t,
|
||||
map[string]*apitype.WhoIsResponse{remoteIP: remoteNode, localIP: localNode, otherIP: otherNode, localTaggedIP: localTaggedNode, remoteTaggedIP: remoteTaggedNode},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
func() *ipn.Prefs {
|
||||
return &ipn.Prefs{ControlURL: *testControlURL}
|
||||
},
|
||||
func(metricName string) {
|
||||
loggedMetrics = append(loggedMetrics, metricName)
|
||||
},
|
||||
)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
timeNow := time.Now()
|
||||
oneHourAgo := timeNow.Add(-time.Hour)
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: func() time.Time { return timeNow },
|
||||
newAuthURL: mockNewAuthURL,
|
||||
waitAuthURL: mockWaitAuthURL,
|
||||
}
|
||||
|
||||
authenticatedRemoteNodeCookie := "ts-cookie-remote-node-authenticated"
|
||||
s.browserSessions.Store(authenticatedRemoteNodeCookie, &browserSession{
|
||||
ID: authenticatedRemoteNodeCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: *testControlURL + testAuthPathSuccess,
|
||||
Authenticated: true,
|
||||
})
|
||||
authenticatedLocalNodeCookie := "ts-cookie-local-node-authenticated"
|
||||
s.browserSessions.Store(authenticatedLocalNodeCookie, &browserSession{
|
||||
ID: authenticatedLocalNodeCookie,
|
||||
SrcNode: localNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: *testControlURL + testAuthPathSuccess,
|
||||
Authenticated: true,
|
||||
})
|
||||
unauthenticatedRemoteNodeCookie := "ts-cookie-remote-node-unauthenticated"
|
||||
s.browserSessions.Store(unauthenticatedRemoteNodeCookie, &browserSession{
|
||||
ID: unauthenticatedRemoteNodeCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: *testControlURL + testAuthPathSuccess,
|
||||
Authenticated: false,
|
||||
})
|
||||
unauthenticatedLocalNodeCookie := "ts-cookie-local-node-unauthenticated"
|
||||
s.browserSessions.Store(unauthenticatedLocalNodeCookie, &browserSession{
|
||||
ID: unauthenticatedLocalNodeCookie,
|
||||
SrcNode: localNode.Node.ID,
|
||||
SrcUser: user.ID,
|
||||
Created: oneHourAgo,
|
||||
AuthID: testAuthPathSuccess,
|
||||
AuthURL: *testControlURL + testAuthPathSuccess,
|
||||
Authenticated: false,
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cookie string // cookie attached to request
|
||||
remoteAddr string // remote address to hit
|
||||
|
||||
wantLoggedMetric string // expected metric to be logged
|
||||
}{
|
||||
{
|
||||
name: "managing-remote",
|
||||
cookie: authenticatedRemoteNodeCookie,
|
||||
remoteAddr: remoteIP,
|
||||
wantLoggedMetric: "web_client_managing_remote",
|
||||
},
|
||||
{
|
||||
name: "managing-local",
|
||||
cookie: authenticatedLocalNodeCookie,
|
||||
remoteAddr: localIP,
|
||||
wantLoggedMetric: "web_client_managing_local",
|
||||
},
|
||||
{
|
||||
name: "viewing-not-owner",
|
||||
cookie: authenticatedRemoteNodeCookie,
|
||||
remoteAddr: otherIP,
|
||||
wantLoggedMetric: "web_client_viewing_not_owner",
|
||||
},
|
||||
{
|
||||
name: "viewing-local-tagged",
|
||||
cookie: authenticatedLocalNodeCookie,
|
||||
remoteAddr: localTaggedIP,
|
||||
wantLoggedMetric: "web_client_viewing_local_tag",
|
||||
},
|
||||
{
|
||||
name: "viewing-remote-tagged",
|
||||
cookie: authenticatedRemoteNodeCookie,
|
||||
remoteAddr: remoteTaggedIP,
|
||||
wantLoggedMetric: "web_client_viewing_remote_tag",
|
||||
},
|
||||
{
|
||||
name: "viewing-local-non-tailscale",
|
||||
cookie: authenticatedLocalNodeCookie,
|
||||
remoteAddr: nonTailscaleIP,
|
||||
wantLoggedMetric: "web_client_viewing_local",
|
||||
},
|
||||
{
|
||||
name: "viewing-local-unauthenticated",
|
||||
cookie: unauthenticatedLocalNodeCookie,
|
||||
remoteAddr: localIP,
|
||||
wantLoggedMetric: "web_client_viewing_local",
|
||||
},
|
||||
{
|
||||
name: "viewing-remote-unauthenticated",
|
||||
cookie: unauthenticatedRemoteNodeCookie,
|
||||
remoteAddr: remoteIP,
|
||||
wantLoggedMetric: "web_client_viewing_remote",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testControlURL = &defaultControlURL
|
||||
|
||||
r := httptest.NewRequest("GET", "http://100.1.2.3:5252/api/auth", nil)
|
||||
r.RemoteAddr = tt.remoteAddr
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
|
||||
w := httptest.NewRecorder()
|
||||
s.serveAPIAuth(w, r)
|
||||
|
||||
if !slices.Contains(loggedMetrics, tt.wantLoggedMetric) {
|
||||
t.Errorf("expected logged metrics to contain: '%s' but was: '%v'", tt.wantLoggedMetric, loggedMetrics)
|
||||
}
|
||||
loggedMetrics = []string{}
|
||||
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathPrefix tests that the provided path prefix is normalized correctly.
|
||||
// If a leading '/' is missing, one should be added.
|
||||
// If multiple leading '/' are present, they should be collapsed to one.
|
||||
// Additionally verify that this prevents open redirects when enforcing the path prefix.
|
||||
func TestPathPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
wantPrefix string
|
||||
wantLocation string
|
||||
}{
|
||||
{
|
||||
name: "no-leading-slash",
|
||||
prefix: "javascript:alert(1)",
|
||||
wantPrefix: "/javascript:alert(1)",
|
||||
wantLocation: "/javascript:alert(1)/",
|
||||
},
|
||||
{
|
||||
name: "2-slashes",
|
||||
prefix: "//evil.example.com/goat",
|
||||
// We must also get the trailing slash added:
|
||||
wantPrefix: "/evil.example.com/goat",
|
||||
wantLocation: "/evil.example.com/goat/",
|
||||
},
|
||||
{
|
||||
name: "absolute-url",
|
||||
prefix: "http://evil.example.com",
|
||||
// We must also get the trailing slash added:
|
||||
wantPrefix: "/http:/evil.example.com",
|
||||
wantLocation: "/http:/evil.example.com/",
|
||||
},
|
||||
{
|
||||
name: "double-dot",
|
||||
prefix: "/../.././etc/passwd",
|
||||
// We must also get the trailing slash added:
|
||||
wantPrefix: "/etc/passwd",
|
||||
wantLocation: "/etc/passwd/",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
options := ServerOpts{
|
||||
Mode: LoginServerMode,
|
||||
PathPrefix: tt.prefix,
|
||||
CGIMode: true,
|
||||
}
|
||||
s, err := NewServer(options)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// verify provided prefix was normalized correctly
|
||||
if s.pathPrefix != tt.wantPrefix {
|
||||
t.Errorf("prefix was not normalized correctly; want=%q, got=%q", tt.wantPrefix, s.pathPrefix)
|
||||
}
|
||||
|
||||
s.logf = t.Logf
|
||||
r := httptest.NewRequest(httpm.GET, "http://localhost/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
if location != tt.wantLocation {
|
||||
t.Errorf("request got wrong location; want=%q, got=%q", tt.wantLocation, location)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireTailscaleIP(t *testing.T) {
|
||||
self := &ipnstate.PeerStatus{
|
||||
TailscaleIPs: []netip.Addr{
|
||||
@@ -723,7 +1021,7 @@ func TestRequireTailscaleIP(t *testing.T) {
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil)
|
||||
localapi := mockLocalAPI(t, nil, func() *ipnstate.PeerStatus { return self }, nil, nil)
|
||||
defer localapi.Close()
|
||||
go localapi.Serve(lal)
|
||||
|
||||
@@ -781,7 +1079,7 @@ func TestRequireTailscaleIP(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.target, func(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s.logf = t.Logf
|
||||
r := httptest.NewRequest(httpm.GET, tt.target, nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -812,7 +1110,7 @@ var (
|
||||
// self accepts a function that resolves to a self node status,
|
||||
// so that tests may swap out the /localapi/v0/status response
|
||||
// as desired.
|
||||
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs) *http.Server {
|
||||
func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus, prefs func() *ipn.Prefs, metricCapture func(string)) *http.Server {
|
||||
return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/localapi/v0/whois":
|
||||
@@ -832,6 +1130,19 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
|
||||
case "/localapi/v0/prefs":
|
||||
writeJSON(w, prefs())
|
||||
return
|
||||
case "/localapi/v0/upload-client-metrics":
|
||||
type metricName struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var metricNames []metricName
|
||||
if err := json.NewDecoder(r.Body).Decode(&metricNames); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
metricCapture(metricNames[0].Name)
|
||||
writeJSON(w, struct{}{})
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
|
||||
}
|
||||
|
||||
@@ -1629,6 +1629,27 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-dialog@^1.0.5":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz#71657b1b116de6c7a0b03242d7d43e01062c7300"
|
||||
integrity sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "1.0.1"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-context" "1.0.1"
|
||||
"@radix-ui/react-dismissable-layer" "1.0.5"
|
||||
"@radix-ui/react-focus-guards" "1.0.1"
|
||||
"@radix-ui/react-focus-scope" "1.0.4"
|
||||
"@radix-ui/react-id" "1.0.1"
|
||||
"@radix-ui/react-portal" "1.0.4"
|
||||
"@radix-ui/react-presence" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-slot" "1.0.2"
|
||||
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||
aria-hidden "^1.1.1"
|
||||
react-remove-scroll "2.5.5"
|
||||
|
||||
"@radix-ui/react-dismissable-layer@1.0.5":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4"
|
||||
@@ -2187,6 +2208,13 @@ acorn@^8.10.0, acorn@^8.9.0:
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5"
|
||||
integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==
|
||||
|
||||
agent-base@^7.0.2, agent-base@^7.1.0:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.0.tgz#536802b76bc0b34aa50195eb2442276d613e3434"
|
||||
integrity sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==
|
||||
dependencies:
|
||||
debug "^4.3.4"
|
||||
|
||||
ajv@^6.12.4:
|
||||
version "6.12.6"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
|
||||
@@ -2354,6 +2382,11 @@ asynciterator.prototype@^1.0.0:
|
||||
dependencies:
|
||||
has-symbols "^1.0.3"
|
||||
|
||||
asynckit@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
||||
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
|
||||
|
||||
autoprefixer@^10.4.15:
|
||||
version "10.4.15"
|
||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.15.tgz#a1230f4aeb3636b89120b34a1f513e2f6834d530"
|
||||
@@ -2587,6 +2620,11 @@ classnames@^2.3.1:
|
||||
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
|
||||
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
|
||||
|
||||
client-only@^0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
|
||||
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
|
||||
|
||||
color-convert@^1.9.0:
|
||||
version "1.9.3"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
|
||||
@@ -2611,6 +2649,13 @@ color-name@~1.1.4:
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
||||
combined-stream@^1.0.8:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
|
||||
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
|
||||
dependencies:
|
||||
delayed-stream "~1.0.0"
|
||||
|
||||
commander@^4.0.0:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
|
||||
@@ -2683,6 +2728,13 @@ cssesc@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||
|
||||
cssstyle@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-3.0.0.tgz#17ca9c87d26eac764bb8cfd00583cff21ce0277a"
|
||||
integrity sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==
|
||||
dependencies:
|
||||
rrweb-cssom "^0.6.0"
|
||||
|
||||
csstype@^3.0.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
|
||||
@@ -2693,6 +2745,21 @@ damerau-levenshtein@^1.0.8:
|
||||
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
|
||||
integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==
|
||||
|
||||
data-urls@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-5.0.0.tgz#2f76906bce1824429ffecb6920f45a0b30f00dde"
|
||||
integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==
|
||||
dependencies:
|
||||
whatwg-mimetype "^4.0.0"
|
||||
whatwg-url "^14.0.0"
|
||||
|
||||
debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4:
|
||||
version "4.3.4"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
|
||||
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
|
||||
dependencies:
|
||||
ms "2.1.2"
|
||||
|
||||
debug@^3.2.7:
|
||||
version "3.2.7"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
|
||||
@@ -2700,12 +2767,10 @@ debug@^3.2.7:
|
||||
dependencies:
|
||||
ms "^2.1.1"
|
||||
|
||||
debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4:
|
||||
version "4.3.4"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
|
||||
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
|
||||
dependencies:
|
||||
ms "2.1.2"
|
||||
decimal.js@^10.4.3:
|
||||
version "10.4.3"
|
||||
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23"
|
||||
integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==
|
||||
|
||||
deep-eql@^4.1.2:
|
||||
version "4.1.3"
|
||||
@@ -2737,6 +2802,11 @@ define-properties@^1.1.3, define-properties@^1.1.4, define-properties@^1.2.0, de
|
||||
has-property-descriptors "^1.0.0"
|
||||
object-keys "^1.1.1"
|
||||
|
||||
delayed-stream@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
|
||||
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
|
||||
|
||||
dequal@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
|
||||
@@ -3273,6 +3343,15 @@ for-each@^0.3.3:
|
||||
dependencies:
|
||||
is-callable "^1.1.3"
|
||||
|
||||
form-data@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
|
||||
integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.8"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
fraction.js@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950"
|
||||
@@ -3490,6 +3569,36 @@ hasown@^2.0.0:
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
html-encoding-sniffer@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448"
|
||||
integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==
|
||||
dependencies:
|
||||
whatwg-encoding "^3.1.1"
|
||||
|
||||
http-proxy-agent@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz#e9096c5afd071a3fce56e6252bb321583c124673"
|
||||
integrity sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==
|
||||
dependencies:
|
||||
agent-base "^7.1.0"
|
||||
debug "^4.3.4"
|
||||
|
||||
https-proxy-agent@^7.0.2:
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz#e2645b846b90e96c6e6f347fb5b2e41f1590b09b"
|
||||
integrity sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==
|
||||
dependencies:
|
||||
agent-base "^7.0.2"
|
||||
debug "4"
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
version "0.6.3"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
|
||||
integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
|
||||
dependencies:
|
||||
safer-buffer ">= 2.1.2 < 3.0.0"
|
||||
|
||||
ignore@^5.2.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78"
|
||||
@@ -3659,6 +3768,11 @@ is-path-inside@^3.0.3:
|
||||
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
|
||||
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
|
||||
|
||||
is-potential-custom-element-name@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
|
||||
integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
|
||||
|
||||
is-regex@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
|
||||
@@ -3758,6 +3872,33 @@ js-yaml@^4.1.0:
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
jsdom@^23.0.1:
|
||||
version "23.0.1"
|
||||
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-23.0.1.tgz#ede7ff76e89ca035b11178d200710d8982ebfee0"
|
||||
integrity sha512-2i27vgvlUsGEBO9+/kJQRbtqtm+191b5zAZrU/UezVmnC2dlDAFLgDYJvAEi94T4kjsRKkezEtLQTgsNEsW2lQ==
|
||||
dependencies:
|
||||
cssstyle "^3.0.0"
|
||||
data-urls "^5.0.0"
|
||||
decimal.js "^10.4.3"
|
||||
form-data "^4.0.0"
|
||||
html-encoding-sniffer "^4.0.0"
|
||||
http-proxy-agent "^7.0.0"
|
||||
https-proxy-agent "^7.0.2"
|
||||
is-potential-custom-element-name "^1.0.1"
|
||||
nwsapi "^2.2.7"
|
||||
parse5 "^7.1.2"
|
||||
rrweb-cssom "^0.6.0"
|
||||
saxes "^6.0.0"
|
||||
symbol-tree "^3.2.4"
|
||||
tough-cookie "^4.1.3"
|
||||
w3c-xmlserializer "^5.0.0"
|
||||
webidl-conversions "^7.0.0"
|
||||
whatwg-encoding "^3.1.1"
|
||||
whatwg-mimetype "^4.0.0"
|
||||
whatwg-url "^14.0.0"
|
||||
ws "^8.14.2"
|
||||
xml-name-validator "^5.0.0"
|
||||
|
||||
jsesc@^2.5.1:
|
||||
version "2.5.2"
|
||||
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
|
||||
@@ -3927,6 +4068,18 @@ micromatch@^4.0.4, micromatch@^4.0.5:
|
||||
braces "^3.0.2"
|
||||
picomatch "^2.3.1"
|
||||
|
||||
mime-db@1.52.0:
|
||||
version "1.52.0"
|
||||
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
|
||||
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
|
||||
|
||||
mime-types@^2.1.12:
|
||||
version "2.1.35"
|
||||
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
|
||||
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
|
||||
dependencies:
|
||||
mime-db "1.52.0"
|
||||
|
||||
minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
|
||||
@@ -3998,6 +4151,11 @@ normalize-range@^0.1.2:
|
||||
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
|
||||
integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==
|
||||
|
||||
nwsapi@^2.2.7:
|
||||
version "2.2.7"
|
||||
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30"
|
||||
integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==
|
||||
|
||||
object-assign@^4.0.1, object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
@@ -4130,6 +4288,13 @@ parse-json@^5.0.0:
|
||||
json-parse-even-better-errors "^2.3.0"
|
||||
lines-and-columns "^1.1.6"
|
||||
|
||||
parse5@^7.1.2:
|
||||
version "7.1.2"
|
||||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32"
|
||||
integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==
|
||||
dependencies:
|
||||
entities "^4.4.0"
|
||||
|
||||
path-exists@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||
@@ -4280,11 +4445,21 @@ prop-types@^15.8.1:
|
||||
object-assign "^4.1.1"
|
||||
react-is "^16.13.1"
|
||||
|
||||
punycode@^2.1.0:
|
||||
psl@^1.1.33:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
|
||||
integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==
|
||||
|
||||
punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
querystringify@^2.1.1:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6"
|
||||
integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==
|
||||
|
||||
queue-microtask@^1.2.2:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
|
||||
@@ -4432,6 +4607,11 @@ regjsparser@^0.9.1:
|
||||
dependencies:
|
||||
jsesc "~0.5.0"
|
||||
|
||||
requires-port@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
|
||||
integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==
|
||||
|
||||
resolve-from@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
||||
@@ -4483,6 +4663,11 @@ rollup@^3.27.1:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
rrweb-cssom@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1"
|
||||
integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==
|
||||
|
||||
run-parallel@^1.1.9:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
|
||||
@@ -4509,6 +4694,18 @@ safe-regex-test@^1.0.0:
|
||||
get-intrinsic "^1.1.3"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
"safer-buffer@>= 2.1.2 < 3.0.0":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
|
||||
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
|
||||
|
||||
saxes@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5"
|
||||
integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==
|
||||
dependencies:
|
||||
xmlchars "^2.2.0"
|
||||
|
||||
scheduler@^0.23.0:
|
||||
version "0.23.0"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
|
||||
@@ -4701,6 +4898,19 @@ svg-parser@^2.0.4:
|
||||
resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5"
|
||||
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
|
||||
|
||||
swr@^2.2.4:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/swr/-/swr-2.2.4.tgz#03ec4c56019902fbdc904d78544bd7a9a6fa3f07"
|
||||
integrity sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==
|
||||
dependencies:
|
||||
client-only "^0.0.1"
|
||||
use-sync-external-store "^1.2.0"
|
||||
|
||||
symbol-tree@^3.2.4:
|
||||
version "3.2.4"
|
||||
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
|
||||
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
|
||||
|
||||
tailwindcss@^3.3.3:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf"
|
||||
@@ -4775,6 +4985,23 @@ to-regex-range@^5.0.1:
|
||||
dependencies:
|
||||
is-number "^7.0.0"
|
||||
|
||||
tough-cookie@^4.1.3:
|
||||
version "4.1.3"
|
||||
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf"
|
||||
integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==
|
||||
dependencies:
|
||||
psl "^1.1.33"
|
||||
punycode "^2.1.1"
|
||||
universalify "^0.2.0"
|
||||
url-parse "^1.5.3"
|
||||
|
||||
tr46@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.0.0.tgz#3b46d583613ec7283020d79019f1335723801cec"
|
||||
integrity sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==
|
||||
dependencies:
|
||||
punycode "^2.3.1"
|
||||
|
||||
ts-interface-checker@^0.1.9:
|
||||
version "0.1.13"
|
||||
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
|
||||
@@ -4915,6 +5142,11 @@ unicode-property-aliases-ecmascript@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd"
|
||||
integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==
|
||||
|
||||
universalify@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"
|
||||
integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==
|
||||
|
||||
update-browserslist-db@^1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940"
|
||||
@@ -4938,6 +5170,14 @@ uri-js@^4.2.2:
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
url-parse@^1.5.3:
|
||||
version "1.5.10"
|
||||
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1"
|
||||
integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==
|
||||
dependencies:
|
||||
querystringify "^2.1.1"
|
||||
requires-port "^1.0.0"
|
||||
|
||||
use-callback-ref@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.0.tgz#772199899b9c9a50526fedc4993fc7fa1f7e32d5"
|
||||
@@ -4953,7 +5193,7 @@ use-sidecar@^1.1.2:
|
||||
detect-node-es "^1.1.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sync-external-store@^1.0.0:
|
||||
use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
|
||||
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
||||
@@ -5042,6 +5282,38 @@ vitest@^0.32.0:
|
||||
vite-node "0.32.4"
|
||||
why-is-node-running "^2.2.2"
|
||||
|
||||
w3c-xmlserializer@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c"
|
||||
integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==
|
||||
dependencies:
|
||||
xml-name-validator "^5.0.0"
|
||||
|
||||
webidl-conversions@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
|
||||
integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
|
||||
|
||||
whatwg-encoding@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5"
|
||||
integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==
|
||||
dependencies:
|
||||
iconv-lite "0.6.3"
|
||||
|
||||
whatwg-mimetype@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a"
|
||||
integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==
|
||||
|
||||
whatwg-url@^14.0.0:
|
||||
version "14.0.0"
|
||||
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.0.0.tgz#00baaa7fd198744910c4b1ef68378f2200e4ceb6"
|
||||
integrity sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==
|
||||
dependencies:
|
||||
tr46 "^5.0.0"
|
||||
webidl-conversions "^7.0.0"
|
||||
|
||||
which-boxed-primitive@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
|
||||
@@ -5119,6 +5391,21 @@ wrappy@1:
|
||||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
|
||||
|
||||
ws@^8.14.2:
|
||||
version "8.14.2"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f"
|
||||
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==
|
||||
|
||||
xml-name-validator@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673"
|
||||
integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==
|
||||
|
||||
xmlchars@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
|
||||
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
|
||||
|
||||
yallist@^3.0.2:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
|
||||
@@ -5148,3 +5435,10 @@ yocto-queue@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
|
||||
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
|
||||
|
||||
zustand@^4.4.7:
|
||||
version "4.4.7"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.7.tgz#355406be6b11ab335f59a66d2cf9815e8f24038c"
|
||||
integrity sha512-QFJWJMdlETcI69paJwhSMJz7PPWjVP8Sjhclxmxmxv/RYI7ZOvR5BHX+ktH0we9gTWQMxcne8q1OY8xxz604gw==
|
||||
dependencies:
|
||||
use-sync-external-store "1.2.0"
|
||||
|
||||
@@ -63,16 +63,19 @@ func versionToTrack(v string) (string, error) {
|
||||
|
||||
// Arguments contains arguments needed to run an update.
|
||||
type Arguments struct {
|
||||
// Version can be a specific version number or one of the predefined track
|
||||
// constants:
|
||||
// Version is the specific version to install.
|
||||
// Mutually exclusive with Track.
|
||||
Version string
|
||||
// Track is the release track to use:
|
||||
//
|
||||
// - CurrentTrack will use the latest version from the same track as the
|
||||
// running binary
|
||||
// - StableTrack and UnstableTrack will use the latest versions of the
|
||||
// corresponding tracks
|
||||
//
|
||||
// Leaving this empty is the same as using CurrentTrack.
|
||||
Version string
|
||||
// Leaving this empty will use Version or fall back to CurrentTrack if both
|
||||
// Track and Version are empty.
|
||||
Track string
|
||||
// Logf is a logger for update progress messages.
|
||||
Logf logger.Logf
|
||||
// Stdout and Stderr should be used for output instead of os.Stdout and
|
||||
@@ -99,12 +102,20 @@ func (args Arguments) validate() error {
|
||||
if args.Logf == nil {
|
||||
return errors.New("missing Logf callback in Arguments")
|
||||
}
|
||||
if args.Version != "" && args.Track != "" {
|
||||
return fmt.Errorf("only one of Version(%q) or Track(%q) can be set", args.Version, args.Track)
|
||||
}
|
||||
switch args.Track {
|
||||
case StableTrack, UnstableTrack, CurrentTrack:
|
||||
// All valid values.
|
||||
default:
|
||||
return fmt.Errorf("unsupported track %q", args.Track)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Updater struct {
|
||||
Arguments
|
||||
track string
|
||||
// Update is a platform-specific method that updates the installation. May be
|
||||
// nil (not all platforms support updates from within Tailscale).
|
||||
Update func() error
|
||||
@@ -128,20 +139,18 @@ func NewUpdater(args Arguments) (*Updater, error) {
|
||||
if args.ForAutoUpdate && !canAutoUpdate {
|
||||
return nil, errors.ErrUnsupported
|
||||
}
|
||||
switch up.Version {
|
||||
case StableTrack, UnstableTrack:
|
||||
up.track = up.Version
|
||||
case CurrentTrack:
|
||||
if version.IsUnstableBuild() {
|
||||
up.track = UnstableTrack
|
||||
} else {
|
||||
up.track = StableTrack
|
||||
}
|
||||
default:
|
||||
var err error
|
||||
up.track, err = versionToTrack(args.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if up.Track == CurrentTrack {
|
||||
switch {
|
||||
case up.Version != "":
|
||||
var err error
|
||||
up.Track, err = versionToTrack(args.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case version.IsUnstableBuild():
|
||||
up.Track = UnstableTrack
|
||||
default:
|
||||
up.Track = StableTrack
|
||||
}
|
||||
}
|
||||
if up.Arguments.PkgsAddr == "" {
|
||||
@@ -222,6 +231,13 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// CanAutoUpdate reports whether auto-updating via the clientupdate package
|
||||
// is supported for the current os/distro.
|
||||
func CanAutoUpdate() bool {
|
||||
_, canAutoUpdate := (&Updater{}).getUpdateFunction()
|
||||
return canAutoUpdate
|
||||
}
|
||||
|
||||
// Update runs a single update attempt using the platform-specific mechanism.
|
||||
//
|
||||
// On Windows, this copies the calling binary and re-executes it to apply the
|
||||
@@ -241,10 +257,10 @@ func Update(args Arguments) error {
|
||||
func (up *Updater) confirm(ver string) bool {
|
||||
switch cmpver.Compare(version.Short(), ver) {
|
||||
case 0:
|
||||
up.Logf("already running %v version %v; no update needed", up.track, ver)
|
||||
up.Logf("already running %v version %v; no update needed", up.Track, ver)
|
||||
return false
|
||||
case 1:
|
||||
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.track, version.Short(), ver)
|
||||
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.Track, version.Short(), ver)
|
||||
return false
|
||||
}
|
||||
if up.Confirm != nil {
|
||||
@@ -270,7 +286,7 @@ func (up *Updater) updateSynology() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latest, err := latestPackages(up.track)
|
||||
latest, err := latestPackages(up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -289,7 +305,7 @@ func (up *Updater) updateSynology() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkgsPath := fmt.Sprintf("%s/%s", up.track, spkName)
|
||||
pkgsPath := fmt.Sprintf("%s/%s", up.Track, spkName)
|
||||
spkPath := filepath.Join(spkDir, path.Base(pkgsPath))
|
||||
if err := up.downloadURLToFile(pkgsPath, spkPath); err != nil {
|
||||
return err
|
||||
@@ -388,7 +404,7 @@ func (up *Updater) updateDebLike() error {
|
||||
// instead.
|
||||
return up.updateLinuxBinary()
|
||||
}
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.track)
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -396,10 +412,10 @@ func (up *Updater) updateDebLike() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if updated, err := updateDebianAptSourcesList(up.track); err != nil {
|
||||
if updated, err := updateDebianAptSourcesList(up.Track); err != nil {
|
||||
return err
|
||||
} else if updated {
|
||||
up.Logf("Updated %s to use the %s track", aptSourcesFile, up.track)
|
||||
up.Logf("Updated %s to use the %s track", aptSourcesFile, up.Track)
|
||||
}
|
||||
|
||||
cmd := exec.Command("apt-get", "update",
|
||||
@@ -527,7 +543,7 @@ func (up *Updater) updateFedoraLike(packageManager string) func() error {
|
||||
}
|
||||
}()
|
||||
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.track)
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -535,10 +551,10 @@ func (up *Updater) updateFedoraLike(packageManager string) func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.track); err != nil {
|
||||
if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.Track); err != nil {
|
||||
return err
|
||||
} else if updated {
|
||||
up.Logf("Updated %s to use the %s track", yumRepoConfigFile, up.track)
|
||||
up.Logf("Updated %s to use the %s track", yumRepoConfigFile, up.Track)
|
||||
}
|
||||
|
||||
cmd := exec.Command(packageManager, "install", "--assumeyes", fmt.Sprintf("tailscale-%s-1", ver))
|
||||
@@ -684,9 +700,8 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
verifyAuthenticode func(string) error // or nil on non-Windows
|
||||
markTempFileFunc func(string) error // or nil on non-Windows
|
||||
launchTailscaleAsWinGUIUser func(string) error // or nil on non-Windows
|
||||
verifyAuthenticode func(string) error // set non-nil only on Windows
|
||||
markTempFileFunc func(string) error // set non-nil only on Windows
|
||||
)
|
||||
|
||||
func (up *Updater) updateWindows() error {
|
||||
@@ -706,16 +721,6 @@ func (up *Updater) updateWindows() error {
|
||||
up.Logf("MSI install failed: %v", err)
|
||||
return err
|
||||
}
|
||||
up.Logf("relaunching tailscale-ipn.exe...")
|
||||
exePath := os.Getenv(winExePathEnv)
|
||||
if exePath == "" {
|
||||
up.Logf("env var %q not passed to installer binary copy", winExePathEnv)
|
||||
return fmt.Errorf("env var %q not passed to installer binary copy", winExePathEnv)
|
||||
}
|
||||
if err := launchTailscaleAsWinGUIUser(exePath); err != nil {
|
||||
up.Logf("Failed to re-launch tailscale after update: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
up.Logf("success.")
|
||||
return nil
|
||||
@@ -729,7 +734,7 @@ you can run the command prompt as Administrator one of these ways:
|
||||
* press Windows+x, then press a
|
||||
* press Windows+r, type in "cmd", then press Ctrl+Shift+Enter`)
|
||||
}
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.track)
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -752,7 +757,7 @@ you can run the command prompt as Administrator one of these ways:
|
||||
return err
|
||||
}
|
||||
up.cleanupOldDownloads(filepath.Join(msiDir, "*.msi"))
|
||||
pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s-%s.msi", up.track, ver, arch)
|
||||
pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s-%s.msi", up.Track, ver, arch)
|
||||
msiTarget := filepath.Join(msiDir, path.Base(pkgsPath))
|
||||
if err := up.downloadURLToFile(pkgsPath, msiTarget); err != nil {
|
||||
return err
|
||||
@@ -765,6 +770,7 @@ you can run the command prompt as Administrator one of these ways:
|
||||
up.Logf("authenticode verification succeeded")
|
||||
|
||||
up.Logf("making tailscale.exe copy to switch to...")
|
||||
up.cleanupOldDownloads(filepath.Join(os.TempDir(), "tailscale-updater-*.exe"))
|
||||
selfOrig, selfCopy, err := makeSelfCopy()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -812,9 +818,7 @@ func (up *Updater) switchOutputToFile() (io.Closer, error) {
|
||||
func (up *Updater) installMSI(msi string) error {
|
||||
var err error
|
||||
for tries := 0; tries < 2; tries++ {
|
||||
// TS_NOLAUNCH: don't automatically launch the app after install.
|
||||
// We will launch it explicitly as the current GUI user afterwards.
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn", "TS_NOLAUNCH=true")
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
|
||||
cmd.Dir = filepath.Dir(msi)
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
@@ -963,7 +967,7 @@ func (up *Updater) updateLinuxBinary() error {
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.track)
|
||||
ver, err := requestedTailscaleVersion(up.Version, up.Track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1004,7 +1008,7 @@ func (up *Updater) downloadLinuxTarball(ver string) (string, error) {
|
||||
if err := os.MkdirAll(dlDir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
pkgsPath := fmt.Sprintf("%s/tailscale_%s_%s.tgz", up.track, ver, runtime.GOARCH)
|
||||
pkgsPath := fmt.Sprintf("%s/tailscale_%s_%s.tgz", up.Track, ver, runtime.GOARCH)
|
||||
dlPath := filepath.Join(dlDir, path.Base(pkgsPath))
|
||||
if err := up.downloadURLToFile(pkgsPath, dlPath); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -7,14 +7,6 @@
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"tailscale.com/util/winutil/authenticode"
|
||||
)
|
||||
@@ -22,7 +14,6 @@ import (
|
||||
func init() {
|
||||
markTempFileFunc = markTempFileWindows
|
||||
verifyAuthenticode = verifyTailscale
|
||||
launchTailscaleAsWinGUIUser = launchTailscaleAsGUIUser
|
||||
}
|
||||
|
||||
func markTempFileWindows(name string) error {
|
||||
@@ -35,50 +26,3 @@ const certSubjectTailscale = "Tailscale Inc."
|
||||
func verifyTailscale(path string) error {
|
||||
return authenticode.Verify(path, certSubjectTailscale)
|
||||
}
|
||||
|
||||
func launchTailscaleAsGUIUser(exePath string) error {
|
||||
exePath = filepath.Join(filepath.Dir(exePath), "tailscale-ipn.exe")
|
||||
|
||||
var token windows.Token
|
||||
if u, err := user.Current(); err == nil && u.Name == "SYSTEM" {
|
||||
sessionID, err := wtsGetActiveSessionID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wtsGetActiveSessionID(): %w", err)
|
||||
}
|
||||
if err := windows.WTSQueryUserToken(sessionID, &token); err != nil {
|
||||
return fmt.Errorf("WTSQueryUserToken (0x%x): %w", sessionID, err)
|
||||
}
|
||||
defer token.Close()
|
||||
}
|
||||
|
||||
cmd := exec.Command(exePath)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Token: syscall.Token(token),
|
||||
HideWindow: true,
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func wtsGetActiveSessionID() (uint32, error) {
|
||||
var (
|
||||
sessionInfo *windows.WTS_SESSION_INFO
|
||||
count uint32 = 0
|
||||
)
|
||||
|
||||
const WTS_CURRENT_SERVER_HANDLE = 0
|
||||
if err := windows.WTSEnumerateSessions(WTS_CURRENT_SERVER_HANDLE, 0, 1, &sessionInfo, &count); err != nil {
|
||||
return 0, fmt.Errorf("WTSEnumerateSessions: %w", err)
|
||||
}
|
||||
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessionInfo)))
|
||||
|
||||
current := unsafe.Pointer(sessionInfo)
|
||||
for i := uint32(0); i < count; i++ {
|
||||
session := (*windows.WTS_SESSION_INFO)(current)
|
||||
if session.State == windows.WTSActive {
|
||||
return session.SessionID, nil
|
||||
}
|
||||
current = unsafe.Add(current, unsafe.Sizeof(windows.WTS_SESSION_INFO{}))
|
||||
}
|
||||
|
||||
return 0, errors.New("no active desktop sessions found")
|
||||
}
|
||||
|
||||
95
cmd/build-webclient/build-webclient.go
Normal file
95
cmd/build-webclient/build-webclient.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The build-webclient tool generates the static resources needed for the
|
||||
// web client (code at client/web).
|
||||
//
|
||||
// # Running
|
||||
//
|
||||
// Meant to be invoked from the tailscale/web-client-prebuilt repo when
|
||||
// updating the production built web client assets. To run it manually,
|
||||
// you can use `./tool/go run ./misc/build-webclient`
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"tailscale.com/util/precompress"
|
||||
)
|
||||
|
||||
var (
|
||||
outDir = flag.String("outDir", "build/", "path to output directory")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// The toolDir flag is relative to the current working directory,
|
||||
// so we need to resolve it to an absolute path.
|
||||
toolDir, err := filepath.Abs("./tool")
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot resolve tool-dir: %v", err)
|
||||
}
|
||||
|
||||
if err := build(toolDir, "client/web"); err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func build(toolDir, appDir string) error {
|
||||
if err := os.Chdir(appDir); err != nil {
|
||||
return fmt.Errorf("Cannot change cwd: %w", err)
|
||||
}
|
||||
|
||||
if err := yarn(toolDir); err != nil {
|
||||
return fmt.Errorf("install failed: %w", err)
|
||||
}
|
||||
|
||||
if err := yarn(toolDir, "lint"); err != nil {
|
||||
return fmt.Errorf("lint failed: %w", err)
|
||||
}
|
||||
|
||||
if err := yarn(toolDir, "build", "--outDir="+*outDir, "--emptyOutDir"); err != nil {
|
||||
return fmt.Errorf("build failed: %w", err)
|
||||
}
|
||||
|
||||
var compressedFiles []string
|
||||
if err := precompress.PrecompressDir(*outDir, precompress.Options{
|
||||
ProgressFn: func(path string) {
|
||||
log.Printf("Pre-compressing %v\n", path)
|
||||
compressedFiles = append(compressedFiles, path)
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("Cannot precompress: %w", err)
|
||||
}
|
||||
|
||||
// Cleanup pre-compressed files.
|
||||
for _, f := range compressedFiles {
|
||||
if err := os.Remove(f); err != nil {
|
||||
log.Printf("Failed to cleanup %q: %v", f, err)
|
||||
}
|
||||
// Removing intermediate ".br" version, we use ".gz" asset.
|
||||
if err := os.Remove(f + ".br"); err != nil {
|
||||
log.Printf("Failed to cleanup %q: %v", f+".gz", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func yarn(toolDir string, args ...string) error {
|
||||
args = append([]string{"--silent", "--non-interactive"}, args...)
|
||||
return run(filepath.Join(toolDir, "yarn"), args...)
|
||||
}
|
||||
|
||||
func run(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
15
cmd/connector-gen/README.md
Normal file
15
cmd/connector-gen/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# connector-gen
|
||||
|
||||
Generate Tailscale app connector configuration details from third party data.
|
||||
|
||||
Tailscale app connectors are used to dynamically route traffic for domain names
|
||||
via specific nodes on a tailnet. For larger upstream domains this may involve a
|
||||
large number of domains or routes, and fully dynamic discovery may be slower or
|
||||
involve more manual labor than ideal. This can be accelerated by
|
||||
pre-configuration of the associated routes, based on data provided by the
|
||||
target providers, which can be used to set precise `autoApprovers` routes, and
|
||||
also to pre-populate the subnet routes via `--advertise-routes` avoiding
|
||||
frequent routing reconfiguration that may otherwise occur while routes are
|
||||
first being discovered and advertised by the connectors.
|
||||
|
||||
|
||||
22
cmd/connector-gen/advertise-routes.go
Normal file
22
cmd/connector-gen/advertise-routes.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go4.org/netipx"
|
||||
)
|
||||
|
||||
func advertiseRoutes(set *netipx.IPSet) {
|
||||
fmt.Println()
|
||||
prefixes := set.Prefixes()
|
||||
pfxs := make([]string, 0, len(prefixes))
|
||||
for _, pfx := range prefixes {
|
||||
pfxs = append(pfxs, pfx.String())
|
||||
}
|
||||
fmt.Printf("--advertise-routes=%s", strings.Join(pfxs, ","))
|
||||
fmt.Println()
|
||||
}
|
||||
68
cmd/connector-gen/aws.go
Normal file
68
cmd/connector-gen/aws.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
||||
"go4.org/netipx"
|
||||
)
|
||||
|
||||
// See https://docs.aws.amazon.com/vpc/latest/userguide/aws-ip-ranges.html
|
||||
|
||||
type AWSMeta struct {
|
||||
SyncToken string `json:"syncToken"`
|
||||
CreateDate string `json:"createDate"`
|
||||
Prefixes []struct {
|
||||
IPPrefix string `json:"ip_prefix"`
|
||||
Region string `json:"region"`
|
||||
Service string `json:"service"`
|
||||
NetworkBorderGroup string `json:"network_border_group"`
|
||||
} `json:"prefixes"`
|
||||
Ipv6Prefixes []struct {
|
||||
Ipv6Prefix string `json:"ipv6_prefix"`
|
||||
Region string `json:"region"`
|
||||
Service string `json:"service"`
|
||||
NetworkBorderGroup string `json:"network_border_group"`
|
||||
} `json:"ipv6_prefixes"`
|
||||
}
|
||||
|
||||
func aws() {
|
||||
r, err := http.Get("https://ip-ranges.amazonaws.com/ip-ranges.json")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var aws AWSMeta
|
||||
if err := json.NewDecoder(r.Body).Decode(&aws); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var ips netipx.IPSetBuilder
|
||||
|
||||
for _, prefix := range aws.Prefixes {
|
||||
ips.AddPrefix(netip.MustParsePrefix(prefix.IPPrefix))
|
||||
}
|
||||
for _, prefix := range aws.Ipv6Prefixes {
|
||||
ips.AddPrefix(netip.MustParsePrefix(prefix.Ipv6Prefix))
|
||||
}
|
||||
|
||||
set, err := ips.IPSet()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println(`"routes": [`)
|
||||
for _, pfx := range set.Prefixes() {
|
||||
fmt.Printf(`"%s": ["tag:connector"],%s`, pfx.String(), "\n")
|
||||
}
|
||||
fmt.Println(`]`)
|
||||
|
||||
advertiseRoutes(set)
|
||||
}
|
||||
34
cmd/connector-gen/connector-gen.go
Normal file
34
cmd/connector-gen/connector-gen.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// connector-gen is a tool to generate app connector configuration and flags from service provider address data.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func help() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s [help|github|aws] [subcommand-arguments]\n", os.Args[0])
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
help()
|
||||
os.Exit(128)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "help", "-h", "--help":
|
||||
help()
|
||||
os.Exit(0)
|
||||
case "github":
|
||||
github()
|
||||
case "aws":
|
||||
aws()
|
||||
default:
|
||||
help()
|
||||
os.Exit(128)
|
||||
}
|
||||
}
|
||||
116
cmd/connector-gen/github.go
Normal file
116
cmd/connector-gen/github.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"go4.org/netipx"
|
||||
)
|
||||
|
||||
// See https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-githubs-ip-addresses
|
||||
|
||||
type GithubMeta struct {
|
||||
VerifiablePasswordAuthentication bool `json:"verifiable_password_authentication"`
|
||||
SSHKeyFingerprints struct {
|
||||
Sha256Ecdsa string `json:"SHA256_ECDSA"`
|
||||
Sha256Ed25519 string `json:"SHA256_ED25519"`
|
||||
Sha256Rsa string `json:"SHA256_RSA"`
|
||||
} `json:"ssh_key_fingerprints"`
|
||||
SSHKeys []string `json:"ssh_keys"`
|
||||
Hooks []string `json:"hooks"`
|
||||
Web []string `json:"web"`
|
||||
API []string `json:"api"`
|
||||
Git []string `json:"git"`
|
||||
GithubEnterpriseImporter []string `json:"github_enterprise_importer"`
|
||||
Packages []string `json:"packages"`
|
||||
Pages []string `json:"pages"`
|
||||
Importer []string `json:"importer"`
|
||||
Actions []string `json:"actions"`
|
||||
Dependabot []string `json:"dependabot"`
|
||||
Domains struct {
|
||||
Website []string `json:"website"`
|
||||
Codespaces []string `json:"codespaces"`
|
||||
Copilot []string `json:"copilot"`
|
||||
Packages []string `json:"packages"`
|
||||
} `json:"domains"`
|
||||
}
|
||||
|
||||
func github() {
|
||||
r, err := http.Get("https://api.github.com/meta")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var ghm GithubMeta
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&ghm); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
r.Body.Close()
|
||||
|
||||
var ips netipx.IPSetBuilder
|
||||
|
||||
var lists []string
|
||||
lists = append(lists, ghm.Hooks...)
|
||||
lists = append(lists, ghm.Web...)
|
||||
lists = append(lists, ghm.API...)
|
||||
lists = append(lists, ghm.Git...)
|
||||
lists = append(lists, ghm.GithubEnterpriseImporter...)
|
||||
lists = append(lists, ghm.Packages...)
|
||||
lists = append(lists, ghm.Pages...)
|
||||
lists = append(lists, ghm.Importer...)
|
||||
lists = append(lists, ghm.Actions...)
|
||||
lists = append(lists, ghm.Dependabot...)
|
||||
|
||||
for _, s := range lists {
|
||||
ips.AddPrefix(netip.MustParsePrefix(s))
|
||||
}
|
||||
|
||||
set, err := ips.IPSet()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println(`"routes": [`)
|
||||
for _, pfx := range set.Prefixes() {
|
||||
fmt.Printf(`"%s": ["tag:connector"],%s`, pfx.String(), "\n")
|
||||
}
|
||||
fmt.Println(`]`)
|
||||
|
||||
fmt.Println()
|
||||
|
||||
var domains []string
|
||||
domains = append(domains, ghm.Domains.Website...)
|
||||
domains = append(domains, ghm.Domains.Codespaces...)
|
||||
domains = append(domains, ghm.Domains.Copilot...)
|
||||
domains = append(domains, ghm.Domains.Packages...)
|
||||
slices.Sort(domains)
|
||||
domains = slices.Compact(domains)
|
||||
|
||||
var bareDomains []string
|
||||
for _, domain := range domains {
|
||||
trimmed := strings.TrimPrefix(domain, "*.")
|
||||
if trimmed != domain {
|
||||
bareDomains = append(bareDomains, trimmed)
|
||||
}
|
||||
}
|
||||
domains = append(domains, bareDomains...)
|
||||
slices.Sort(domains)
|
||||
domains = slices.Compact(domains)
|
||||
|
||||
fmt.Println(`"domains": [`)
|
||||
for _, domain := range domains {
|
||||
fmt.Printf(`"%s",%s`, domain, "\n")
|
||||
}
|
||||
fmt.Println(`]`)
|
||||
|
||||
advertiseRoutes(set)
|
||||
}
|
||||
@@ -13,7 +13,10 @@
|
||||
//
|
||||
// - TS_AUTHKEY: the authkey to use for login.
|
||||
// - TS_HOSTNAME: the hostname to request for the node.
|
||||
// - TS_ROUTES: subnet routes to advertise. To accept routes, use TS_EXTRA_ARGS to pass in --accept-routes.
|
||||
// - TS_ROUTES: subnet routes to advertise. Explicitly setting it to an empty
|
||||
// value will cause containerboot to stop acting as a subnet router for any
|
||||
// previously advertised routes. To accept routes, use TS_EXTRA_ARGS to pass
|
||||
// in --accept-routes.
|
||||
// - TS_DEST_IP: proxy all incoming Tailscale traffic to the given
|
||||
// destination.
|
||||
// - TS_TAILNET_TARGET_IP: proxy all incoming non-Tailscale traffic to the given
|
||||
@@ -45,6 +48,13 @@
|
||||
// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN.
|
||||
// It cannot be used in conjunction with TS_DEST_IP. The file is watched for changes,
|
||||
// and will be re-applied when it changes.
|
||||
// - EXPERIMENTAL_TS_CONFIGFILE_PATH: if specified, a path to tailscaled
|
||||
// config. If this is set, TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY,
|
||||
// TS_ROUTES, TS_ACCEPT_DNS env vars must not be set. If this is set,
|
||||
// containerboot only runs `tailscaled --config <path-to-this-configfile>`
|
||||
// and not `tailscale up` or `tailscale set`.
|
||||
// The config file contents are currently read once on container start.
|
||||
// NB: This env var is currently experimental and the logic will likely change!
|
||||
//
|
||||
// When running on Kubernetes, containerboot defaults to storing state in the
|
||||
// "tailscale" kube secret. To store state on local disk instead, set
|
||||
@@ -80,6 +90,7 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/ptr"
|
||||
@@ -91,7 +102,7 @@ func newNetfilterRunner(logf logger.Logf) (linuxfw.NetfilterRunner, error) {
|
||||
if defaultBool("TS_TEST_FAKE_NETFILTER", false) {
|
||||
return linuxfw.NewFakeIPTablesRunner(), nil
|
||||
}
|
||||
return linuxfw.New(logf)
|
||||
return linuxfw.New(logf, "")
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -99,46 +110,36 @@ func main() {
|
||||
tailscale.I_Acknowledge_This_API_Is_Unstable = true
|
||||
|
||||
cfg := &settings{
|
||||
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
|
||||
Hostname: defaultEnv("TS_HOSTNAME", ""),
|
||||
Routes: defaultEnv("TS_ROUTES", ""),
|
||||
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
|
||||
ProxyTo: defaultEnv("TS_DEST_IP", ""),
|
||||
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
|
||||
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
|
||||
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
|
||||
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
|
||||
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
|
||||
UserspaceMode: defaultBool("TS_USERSPACE", true),
|
||||
StateDir: defaultEnv("TS_STATE_DIR", ""),
|
||||
AcceptDNS: defaultBool("TS_ACCEPT_DNS", false),
|
||||
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
|
||||
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
|
||||
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
|
||||
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
|
||||
Hostname: defaultEnv("TS_HOSTNAME", ""),
|
||||
Routes: defaultEnvStringPointer("TS_ROUTES"),
|
||||
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
|
||||
ProxyTo: defaultEnv("TS_DEST_IP", ""),
|
||||
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
|
||||
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
|
||||
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
|
||||
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
|
||||
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
|
||||
UserspaceMode: defaultBool("TS_USERSPACE", true),
|
||||
StateDir: defaultEnv("TS_STATE_DIR", ""),
|
||||
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
|
||||
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
|
||||
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
|
||||
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
|
||||
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
TailscaledConfigFilePath: defaultEnv("EXPERIMENTAL_TS_CONFIGFILE_PATH", ""),
|
||||
}
|
||||
|
||||
if cfg.ProxyTo != "" && cfg.UserspaceMode {
|
||||
log.Fatal("TS_DEST_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
|
||||
if cfg.TailnetTargetIP != "" && cfg.UserspaceMode {
|
||||
log.Fatal("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
if cfg.TailnetTargetFQDN != "" && cfg.UserspaceMode {
|
||||
log.Fatal("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
|
||||
}
|
||||
if cfg.TailnetTargetFQDN != "" && cfg.TailnetTargetIP != "" {
|
||||
log.Fatal("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
|
||||
if err := cfg.validate(); err != nil {
|
||||
log.Fatalf("invalid configuration: %v", err)
|
||||
}
|
||||
|
||||
if !cfg.UserspaceMode {
|
||||
if err := ensureTunFile(cfg.Root); err != nil {
|
||||
log.Fatalf("Unable to create tuntap device file: %v", err)
|
||||
}
|
||||
if cfg.ProxyTo != "" || cfg.Routes != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" {
|
||||
if cfg.ProxyTo != "" || cfg.Routes != nil || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" {
|
||||
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTo, cfg.TailnetTargetIP, cfg.TailnetTargetFQDN, cfg.Routes); err != nil {
|
||||
log.Printf("Failed to enable IP forwarding: %v", err)
|
||||
log.Printf("To run tailscale as a proxy or router container, IP forwarding must be enabled.")
|
||||
@@ -168,7 +169,7 @@ func main() {
|
||||
}
|
||||
cfg.KubernetesCanPatch = canPatch
|
||||
|
||||
if cfg.AuthKey == "" {
|
||||
if cfg.AuthKey == "" && !isOneStepConfig(cfg) {
|
||||
key, err := findKeyInKubeSecret(bootCtx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("Getting authkey from kube secret: %v", err)
|
||||
@@ -208,6 +209,24 @@ func main() {
|
||||
log.Fatalf("failed to watch tailscaled for updates: %v", err)
|
||||
}
|
||||
|
||||
// Now that we've started tailscaled, we can symlink the socket to the
|
||||
// default location if needed.
|
||||
const defaultTailscaledSocketPath = "/var/run/tailscale/tailscaled.sock"
|
||||
if cfg.Socket != "" && cfg.Socket != defaultTailscaledSocketPath {
|
||||
// If we were given a socket path, symlink it to the default location so
|
||||
// that the CLI can find it without any extra flags.
|
||||
// See #6849.
|
||||
|
||||
dir := filepath.Dir(defaultTailscaledSocketPath)
|
||||
err := os.MkdirAll(dir, 0700)
|
||||
if err == nil {
|
||||
err = syscall.Symlink(cfg.Socket, defaultTailscaledSocketPath)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[warning] failed to symlink socket: %v\n\tTo interact with the Tailscale CLI please use `tailscale --socket=%q`", err, cfg.Socket)
|
||||
}
|
||||
}
|
||||
|
||||
// Because we're still shelling out to `tailscale up` to get access to its
|
||||
// flag parser, we have to stop watching the IPN bus so that we can block on
|
||||
// the subcommand without stalling anything. Then once it's done, we resume
|
||||
@@ -232,7 +251,7 @@ func main() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !cfg.AuthOnce {
|
||||
if isTwoStepConfigAlwaysAuth(cfg) {
|
||||
if err := authTailscale(); err != nil {
|
||||
log.Fatalf("failed to auth tailscale: %v", err)
|
||||
}
|
||||
@@ -248,6 +267,13 @@ authLoop:
|
||||
if n.State != nil {
|
||||
switch *n.State {
|
||||
case ipn.NeedsLogin:
|
||||
if isOneStepConfig(cfg) {
|
||||
// This could happen if this is the
|
||||
// first time tailscaled was run for
|
||||
// this device and the auth key was not
|
||||
// passed via the configfile.
|
||||
log.Fatalf("invalid state: tailscaled daemon started with a config file, but tailscale is not logged in: ensure you pass a valid auth key in the config file.")
|
||||
}
|
||||
if err := authTailscale(); err != nil {
|
||||
log.Fatalf("failed to auth tailscale: %v", err)
|
||||
}
|
||||
@@ -272,7 +298,7 @@ authLoop:
|
||||
ctx, cancel := contextWithExitSignalWatch()
|
||||
defer cancel()
|
||||
|
||||
if cfg.AuthOnce {
|
||||
if isTwoStepConfigAuthOnce(cfg) {
|
||||
// Now that we are authenticated, we can set/reset any of the
|
||||
// settings that we need to.
|
||||
if err := tailscaleSet(ctx, cfg); err != nil {
|
||||
@@ -288,7 +314,7 @@ authLoop:
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch && cfg.AuthOnce {
|
||||
if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch && isTwoStepConfigAuthOnce(cfg) {
|
||||
// We were told to only auth once, so any secret-bound
|
||||
// authkey is no longer needed. We don't strictly need to
|
||||
// wipe it, but it's good hygiene.
|
||||
@@ -613,6 +639,9 @@ func tailscaledArgs(cfg *settings) []string {
|
||||
if cfg.HTTPProxyAddr != "" {
|
||||
args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr)
|
||||
}
|
||||
if cfg.TailscaledConfigFilePath != "" {
|
||||
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
|
||||
}
|
||||
if cfg.DaemonExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
|
||||
}
|
||||
@@ -623,7 +652,7 @@ func tailscaledArgs(cfg *settings) []string {
|
||||
// if TS_AUTH_ONCE is set, only the first time containerboot starts.
|
||||
func tailscaleUp(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "up"}
|
||||
if cfg.AcceptDNS {
|
||||
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
|
||||
args = append(args, "--accept-dns=true")
|
||||
} else {
|
||||
args = append(args, "--accept-dns=false")
|
||||
@@ -631,8 +660,12 @@ func tailscaleUp(ctx context.Context, cfg *settings) error {
|
||||
if cfg.AuthKey != "" {
|
||||
args = append(args, "--authkey="+cfg.AuthKey)
|
||||
}
|
||||
if cfg.Routes != "" {
|
||||
args = append(args, "--advertise-routes="+cfg.Routes)
|
||||
// --advertise-routes can be passed an empty string to configure a
|
||||
// device (that might have previously advertised subnet routes) to not
|
||||
// advertise any routes. Respect an empty string passed by a user and
|
||||
// use it to explicitly unset the routes.
|
||||
if cfg.Routes != nil {
|
||||
args = append(args, "--advertise-routes="+*cfg.Routes)
|
||||
}
|
||||
if cfg.Hostname != "" {
|
||||
args = append(args, "--hostname="+cfg.Hostname)
|
||||
@@ -655,13 +688,17 @@ func tailscaleUp(ctx context.Context, cfg *settings) error {
|
||||
// node is in Running state and only if TS_AUTH_ONCE is set.
|
||||
func tailscaleSet(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "set"}
|
||||
if cfg.AcceptDNS {
|
||||
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
|
||||
args = append(args, "--accept-dns=true")
|
||||
} else {
|
||||
args = append(args, "--accept-dns=false")
|
||||
}
|
||||
if cfg.Routes != "" {
|
||||
args = append(args, "--advertise-routes="+cfg.Routes)
|
||||
// --advertise-routes can be passed an empty string to configure a
|
||||
// device (that might have previously advertised subnet routes) to not
|
||||
// advertise any routes. Respect an empty string passed by a user and
|
||||
// use it to explicitly unset the routes.
|
||||
if cfg.Routes != nil {
|
||||
args = append(args, "--advertise-routes="+*cfg.Routes)
|
||||
}
|
||||
if cfg.Hostname != "" {
|
||||
args = append(args, "--hostname="+cfg.Hostname)
|
||||
@@ -696,7 +733,7 @@ func ensureTunFile(root string) error {
|
||||
}
|
||||
|
||||
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
|
||||
func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, tailnetTargetFQDN, routes string) error {
|
||||
func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, tailnetTargetFQDN string, routes *string) error {
|
||||
var (
|
||||
v4Forwarding, v6Forwarding bool
|
||||
)
|
||||
@@ -727,8 +764,8 @@ func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, tailnetTarget
|
||||
if tailnetTargetFQDN != "" {
|
||||
v4Forwarding = true
|
||||
}
|
||||
if routes != "" {
|
||||
for _, route := range strings.Split(routes, ",") {
|
||||
if routes != nil && *routes != "" {
|
||||
for _, route := range strings.Split(*routes, ",") {
|
||||
cidr, err := netip.ParsePrefix(route)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid subnet route: %v", err)
|
||||
@@ -832,7 +869,7 @@ func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []ne
|
||||
type settings struct {
|
||||
AuthKey string
|
||||
Hostname string
|
||||
Routes string
|
||||
Routes *string
|
||||
// ProxyTo is the destination IP to which all incoming
|
||||
// Tailscale traffic should be proxied. If empty, no proxying
|
||||
// is done. This is typically a locally reachable IP.
|
||||
@@ -844,21 +881,46 @@ type settings struct {
|
||||
// TailnetTargetFQDN is an MagicDNS name to which all incoming
|
||||
// non-Tailscale traffic should be proxied. This must be a full Tailnet
|
||||
// node FQDN.
|
||||
TailnetTargetFQDN string
|
||||
ServeConfigPath string
|
||||
DaemonExtraArgs string
|
||||
ExtraArgs string
|
||||
InKubernetes bool
|
||||
UserspaceMode bool
|
||||
StateDir string
|
||||
AcceptDNS bool
|
||||
KubeSecret string
|
||||
SOCKSProxyAddr string
|
||||
HTTPProxyAddr string
|
||||
Socket string
|
||||
AuthOnce bool
|
||||
Root string
|
||||
KubernetesCanPatch bool
|
||||
TailnetTargetFQDN string
|
||||
ServeConfigPath string
|
||||
DaemonExtraArgs string
|
||||
ExtraArgs string
|
||||
InKubernetes bool
|
||||
UserspaceMode bool
|
||||
StateDir string
|
||||
AcceptDNS *bool
|
||||
KubeSecret string
|
||||
SOCKSProxyAddr string
|
||||
HTTPProxyAddr string
|
||||
Socket string
|
||||
AuthOnce bool
|
||||
Root string
|
||||
KubernetesCanPatch bool
|
||||
TailscaledConfigFilePath string
|
||||
}
|
||||
|
||||
func (s *settings) validate() error {
|
||||
if s.TailscaledConfigFilePath != "" {
|
||||
if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil {
|
||||
return fmt.Errorf("error validating tailscaled configfile contents: %w", err)
|
||||
}
|
||||
}
|
||||
if s.ProxyTo != "" && s.UserspaceMode {
|
||||
return errors.New("TS_DEST_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.TailnetTargetIP != "" && s.UserspaceMode {
|
||||
return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.TailnetTargetFQDN != "" && s.UserspaceMode {
|
||||
return errors.New("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" {
|
||||
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
|
||||
}
|
||||
if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") {
|
||||
return errors.New("EXPERIMENTAL_TS_CONFIGFILE_PATH cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultEnv returns the value of the given envvar name, or defVal if
|
||||
@@ -870,6 +932,28 @@ func defaultEnv(name, defVal string) string {
|
||||
return defVal
|
||||
}
|
||||
|
||||
// defaultEnvStringPointer returns a pointer to the given envvar value if set, else
|
||||
// returns nil. This is useful in cases where we need to distinguish between a
|
||||
// variable being set to empty string vs unset.
|
||||
func defaultEnvStringPointer(name string) *string {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultEnvBoolPointer returns a pointer to the given envvar value if set, else
|
||||
// returns nil. This is useful in cases where we need to distinguish between a
|
||||
// variable being explicitly set to false vs unset.
|
||||
func defaultEnvBoolPointer(name string) *bool {
|
||||
v := os.Getenv(name)
|
||||
ret, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &ret
|
||||
}
|
||||
|
||||
func defaultEnvs(names []string, defVal string) string {
|
||||
for _, name := range names {
|
||||
if v, ok := os.LookupEnv(name); ok {
|
||||
@@ -911,3 +995,27 @@ func contextWithExitSignalWatch() (context.Context, func()) {
|
||||
}
|
||||
return ctx, f
|
||||
}
|
||||
|
||||
// isTwoStepConfigAuthOnce returns true if the Tailscale node should be configured
|
||||
// in two steps and login should only happen once.
|
||||
// Step 1: run 'tailscaled'
|
||||
// Step 2):
|
||||
// A) if this is the first time starting this node run 'tailscale up --authkey <authkey> <config opts>'
|
||||
// B) if this is not the first time starting this node run 'tailscale set <config opts>'.
|
||||
func isTwoStepConfigAuthOnce(cfg *settings) bool {
|
||||
return cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
|
||||
}
|
||||
|
||||
// isTwoStepConfigAlwaysAuth returns true if the Tailscale node should be configured
|
||||
// in two steps and we should log in every time it starts.
|
||||
// Step 1: run 'tailscaled'
|
||||
// Step 2): run 'tailscale up --authkey <authkey> <config opts>'
|
||||
func isTwoStepConfigAlwaysAuth(cfg *settings) bool {
|
||||
return !cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
|
||||
}
|
||||
|
||||
// isOneStepConfig returns true if the Tailscale node should always be ran and
|
||||
// configured in a single step by running 'tailscaled <config opts>'
|
||||
func isOneStepConfig(cfg *settings) bool {
|
||||
return cfg.TailscaledConfigFilePath != ""
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ func TestContainerBoot(t *testing.T) {
|
||||
}
|
||||
defer kube.Close()
|
||||
|
||||
tailscaledConf := &ipn.ConfigVAlpha{AuthKey: func(s string) *string { return &s }("foo"), Version: "alpha0"}
|
||||
tailscaledConfBytes, err := json.Marshal(tailscaledConf)
|
||||
if err != nil {
|
||||
t.Fatalf("error unmarshaling tailscaled config: %v", err)
|
||||
}
|
||||
|
||||
dirs := []string{
|
||||
"var/lib",
|
||||
"usr/bin",
|
||||
@@ -59,6 +65,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
"dev/net",
|
||||
"proc/sys/net/ipv4",
|
||||
"proc/sys/net/ipv6/conf/all",
|
||||
"etc",
|
||||
}
|
||||
for _, path := range dirs {
|
||||
if err := os.MkdirAll(filepath.Join(d, path), 0700); err != nil {
|
||||
@@ -73,6 +80,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
"dev/net/tun": []byte(""),
|
||||
"proc/sys/net/ipv4/ip_forward": []byte("0"),
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": []byte("0"),
|
||||
"etc/tailscaled": tailscaledConfBytes,
|
||||
}
|
||||
resetFiles := func() {
|
||||
for path, content := range files {
|
||||
@@ -218,6 +226,28 @@ func TestContainerBoot(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "empty routes",
|
||||
Env: map[string]string{
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_ROUTES": "",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
WantFiles: map[string]string{
|
||||
"proc/sys/net/ipv4/ip_forward": "0",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "routes_kernel_ipv4",
|
||||
Env: map[string]string{
|
||||
@@ -288,7 +318,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ingres proxy",
|
||||
Name: "ingress proxy",
|
||||
Env: map[string]string{
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_DEST_IP": "1.2.3.4",
|
||||
@@ -607,6 +637,21 @@ func TestContainerBoot(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "experimental tailscaled configfile",
|
||||
Env: map[string]string{
|
||||
"EXPERIMENTAL_TS_CONFIGFILE_PATH": filepath.Join(d, "etc/tailscaled"),
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --config=/etc/tailscaled",
|
||||
},
|
||||
}, {
|
||||
Notify: runningNotify,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -999,9 +1044,6 @@ func (k *kubeServer) serveSecret(w http.ResponseWriter, r *http.Request) {
|
||||
defer k.Unlock()
|
||||
for k, v := range k.secret {
|
||||
v := base64.StdEncoding.EncodeToString([]byte(v))
|
||||
if err != nil {
|
||||
panic("encode failed")
|
||||
}
|
||||
ret["data"][k] = v
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(ret); err != nil {
|
||||
|
||||
@@ -105,7 +105,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/net/netutil from tailscale.com/client/tailscale
|
||||
tailscale.com/net/packet from tailscale.com/wgengine/filter
|
||||
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/stun from tailscale.com/cmd/derper
|
||||
tailscale.com/net/stun from tailscale.com/net/stunserver
|
||||
tailscale.com/net/stunserver from tailscale.com/cmd/derper
|
||||
L tailscale.com/net/tcpinfo from tailscale.com/derp
|
||||
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/tsaddr from tailscale.com/ipn+
|
||||
@@ -152,6 +153,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/util/set from tailscale.com/health+
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache
|
||||
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
|
||||
tailscale.com/util/syspolicy from tailscale.com/ipn
|
||||
tailscale.com/util/vizerror from tailscale.com/tsweb+
|
||||
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
@@ -193,7 +195,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
golang.org/x/time/rate from tailscale.com/cmd/derper+
|
||||
bufio from compress/flate+
|
||||
bytes from bufio+
|
||||
cmp from slices
|
||||
cmp from slices+
|
||||
compress/flate from compress/gzip+
|
||||
compress/gzip from internal/profile+
|
||||
container/list from crypto/tls+
|
||||
@@ -231,7 +233,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
encoding/pem from crypto/tls+
|
||||
errors from bufio+
|
||||
expvar from tailscale.com/cmd/derper+
|
||||
flag from tailscale.com/cmd/derper
|
||||
flag from tailscale.com/cmd/derper+
|
||||
fmt from compress/flate+
|
||||
go/token from google.golang.org/protobuf/internal/strs
|
||||
hash from crypto+
|
||||
@@ -262,6 +264,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
net/url from crypto/x509+
|
||||
os from crypto/rand+
|
||||
os/exec from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+
|
||||
os/signal from tailscale.com/cmd/derper
|
||||
W os/user from tailscale.com/util/winutil
|
||||
path from golang.org/x/crypto/acme/autocert+
|
||||
path/filepath from crypto/x509+
|
||||
@@ -271,7 +274,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
runtime/debug from golang.org/x/crypto/acme+
|
||||
runtime/metrics from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/pprof from net/http/pprof
|
||||
runtime/trace from net/http/pprof
|
||||
runtime/trace from net/http/pprof+
|
||||
slices from tailscale.com/ipn/ipnstate+
|
||||
sort from compress/flate+
|
||||
strconv from compress/flate+
|
||||
@@ -279,6 +282,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
sync from compress/flate+
|
||||
sync/atomic from context+
|
||||
syscall from crypto/rand+
|
||||
testing from tailscale.com/util/syspolicy
|
||||
text/tabwriter from runtime/pprof
|
||||
time from compress/gzip+
|
||||
unicode from bytes+
|
||||
|
||||
@@ -17,11 +17,12 @@ import (
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
@@ -30,7 +31,7 @@ import (
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/derp/derphttp"
|
||||
"tailscale.com/metrics"
|
||||
"tailscale.com/net/stun"
|
||||
"tailscale.com/net/stunserver"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/util/cmpx"
|
||||
@@ -59,25 +60,11 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
stats = new(metrics.Set)
|
||||
stunDisposition = &metrics.LabelMap{Label: "disposition"}
|
||||
stunAddrFamily = &metrics.LabelMap{Label: "family"}
|
||||
tlsRequestVersion = &metrics.LabelMap{Label: "version"}
|
||||
tlsActiveVersion = &metrics.LabelMap{Label: "version"}
|
||||
|
||||
stunReadError = stunDisposition.Get("read_error")
|
||||
stunNotSTUN = stunDisposition.Get("not_stun")
|
||||
stunWriteError = stunDisposition.Get("write_error")
|
||||
stunSuccess = stunDisposition.Get("success")
|
||||
|
||||
stunIPv4 = stunAddrFamily.Get("ipv4")
|
||||
stunIPv6 = stunAddrFamily.Get("ipv6")
|
||||
)
|
||||
|
||||
func init() {
|
||||
stats.Set("counter_requests", stunDisposition)
|
||||
stats.Set("counter_addrfamily", stunAddrFamily)
|
||||
expvar.Publish("stun", stats)
|
||||
expvar.Publish("derper_tls_request_version", tlsRequestVersion)
|
||||
expvar.Publish("gauge_derper_tls_active_version", tlsActiveVersion)
|
||||
}
|
||||
@@ -135,6 +122,9 @@ func writeNewConfig() config {
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if *dev {
|
||||
*addr = ":3340" // above the keys DERP
|
||||
log.Printf("Running in dev mode.")
|
||||
@@ -146,6 +136,11 @@ func main() {
|
||||
log.Fatalf("invalid server address: %v", err)
|
||||
}
|
||||
|
||||
if *runSTUN {
|
||||
ss := stunserver.New(ctx)
|
||||
go ss.ListenAndServe(net.JoinHostPort(listenHost, fmt.Sprint(*stunPort)))
|
||||
}
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
serveTLS := tsweb.IsProd443(*addr) || *certMode == "manual"
|
||||
@@ -221,10 +216,6 @@ func main() {
|
||||
}))
|
||||
debug.Handle("traffic", "Traffic check", http.HandlerFunc(s.ServeDebugTraffic))
|
||||
|
||||
if *runSTUN {
|
||||
go serveSTUN(listenHost, *stunPort)
|
||||
}
|
||||
|
||||
quietLogger := log.New(logFilter{}, "", 0)
|
||||
httpsrv := &http.Server{
|
||||
Addr: *addr,
|
||||
@@ -241,6 +232,10 @@ func main() {
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
httpsrv.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
if serveTLS {
|
||||
log.Printf("derper: serving on %s with TLS", *addr)
|
||||
@@ -351,59 +346,6 @@ func probeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func serveSTUN(host string, port int) {
|
||||
pc, err := net.ListenPacket("udp", net.JoinHostPort(host, fmt.Sprint(port)))
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open STUN listener: %v", err)
|
||||
}
|
||||
log.Printf("running STUN server on %v", pc.LocalAddr())
|
||||
serverSTUNListener(context.Background(), pc.(*net.UDPConn))
|
||||
}
|
||||
|
||||
func serverSTUNListener(ctx context.Context, pc *net.UDPConn) {
|
||||
var buf [64 << 10]byte
|
||||
var (
|
||||
n int
|
||||
ua *net.UDPAddr
|
||||
err error
|
||||
)
|
||||
for {
|
||||
n, ua, err = pc.ReadFromUDP(buf[:])
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("STUN ReadFrom: %v", err)
|
||||
time.Sleep(time.Second)
|
||||
stunReadError.Add(1)
|
||||
continue
|
||||
}
|
||||
pkt := buf[:n]
|
||||
if !stun.Is(pkt) {
|
||||
stunNotSTUN.Add(1)
|
||||
continue
|
||||
}
|
||||
txid, err := stun.ParseBindingRequest(pkt)
|
||||
if err != nil {
|
||||
stunNotSTUN.Add(1)
|
||||
continue
|
||||
}
|
||||
if ua.IP.To4() != nil {
|
||||
stunIPv4.Add(1)
|
||||
} else {
|
||||
stunIPv6.Add(1)
|
||||
}
|
||||
addr, _ := netip.AddrFromSlice(ua.IP)
|
||||
res := stun.Response(txid, netip.AddrPortFrom(addr, uint16(ua.Port)))
|
||||
_, err = pc.WriteTo(res, ua)
|
||||
if err != nil {
|
||||
stunWriteError.Add(1)
|
||||
} else {
|
||||
stunSuccess.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
|
||||
|
||||
func prodAutocertHostPolicy(_ context.Context, host string) error {
|
||||
|
||||
@@ -5,13 +5,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/net/stun"
|
||||
"tailscale.com/tstest/deptest"
|
||||
)
|
||||
|
||||
@@ -39,38 +37,6 @@ func TestProdAutocertHostPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkServerSTUN(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer pc.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go serverSTUNListener(ctx, pc.(*net.UDPConn))
|
||||
addr := pc.LocalAddr().(*net.UDPAddr)
|
||||
|
||||
var resBuf [1500]byte
|
||||
cc, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
tx := stun.NewTxID()
|
||||
req := stun.Request(tx)
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := cc.WriteToUDP(req, addr); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
_, _, err := cc.ReadFromUDP(resBuf[:])
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestNoContent(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
260
cmd/k8s-operator/connector.go
Normal file
260
cmd/k8s-operator/connector.go
Normal file
@@ -0,0 +1,260 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
xslices "golang.org/x/exp/slices"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
reasonConnectorCreationFailed = "ConnectorCreationFailed"
|
||||
|
||||
reasonConnectorCreated = "ConnectorCreated"
|
||||
reasonConnectorCleanupFailed = "ConnectorCleanupFailed"
|
||||
reasonConnectorCleanupInProgress = "ConnectorCleanupInProgress"
|
||||
reasonConnectorInvalid = "ConnectorInvalid"
|
||||
|
||||
messageConnectorCreationFailed = "Failed creating Connector: %v"
|
||||
messageConnectorInvalid = "Connector is invalid: %v"
|
||||
|
||||
shortRequeue = time.Second * 5
|
||||
)
|
||||
|
||||
type ConnectorReconciler struct {
|
||||
client.Client
|
||||
|
||||
recorder record.EventRecorder
|
||||
ssr *tailscaleSTSReconciler
|
||||
logger *zap.SugaredLogger
|
||||
|
||||
tsnamespace string
|
||||
|
||||
clock tstime.Clock
|
||||
|
||||
mu sync.Mutex // protects following
|
||||
|
||||
subnetRouters set.Slice[types.UID] // for subnet routers gauge
|
||||
exitNodes set.Slice[types.UID] // for exit nodes gauge
|
||||
}
|
||||
|
||||
var (
|
||||
// gaugeConnectorResources tracks the overall number of Connectors currently managed by this operator instance.
|
||||
gaugeConnectorResources = clientmetric.NewGauge("k8s_connector_resources")
|
||||
// gaugeConnectorSubnetRouterResources tracks the number of Connectors managed by this operator instance that are subnet routers.
|
||||
gaugeConnectorSubnetRouterResources = clientmetric.NewGauge("k8s_connector_subnetrouter_resources")
|
||||
// gaugeConnectorExitNodeResources tracks the number of Connectors currently managed by this operator instance that are exit nodes.
|
||||
gaugeConnectorExitNodeResources = clientmetric.NewGauge("k8s_connector_exitnode_resources")
|
||||
)
|
||||
|
||||
func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
logger := a.logger.With("Connector", req.Name)
|
||||
logger.Debugf("starting reconcile")
|
||||
defer logger.Debugf("reconcile finished")
|
||||
|
||||
cn := new(tsapi.Connector)
|
||||
err = a.Get(ctx, req.NamespacedName, cn)
|
||||
if apierrors.IsNotFound(err) {
|
||||
logger.Debugf("Connector not found, assuming it was deleted")
|
||||
return reconcile.Result{}, nil
|
||||
} else if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com Connector: %w", err)
|
||||
}
|
||||
if !cn.DeletionTimestamp.IsZero() {
|
||||
logger.Debugf("Connector is being deleted or should not be exposed, cleaning up resources")
|
||||
ix := xslices.Index(cn.Finalizers, FinalizerName)
|
||||
if ix < 0 {
|
||||
logger.Debugf("no finalizer, nothing to do")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
if done, err := a.maybeCleanupConnector(ctx, logger, cn); err != nil {
|
||||
return reconcile.Result{}, err
|
||||
} else if !done {
|
||||
logger.Debugf("Connector resource cleanup not yet finished, will retry...")
|
||||
return reconcile.Result{RequeueAfter: shortRequeue}, nil
|
||||
}
|
||||
|
||||
cn.Finalizers = append(cn.Finalizers[:ix], cn.Finalizers[ix+1:]...)
|
||||
if err := a.Update(ctx, cn); err != nil {
|
||||
return reconcile.Result{}, err
|
||||
}
|
||||
logger.Infof("Connector resources cleaned up")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
oldCnStatus := cn.Status.DeepCopy()
|
||||
setStatus := func(cn *tsapi.Connector, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
tsoperator.SetConnectorCondition(cn, tsapi.ConnectorReady, status, reason, message, cn.Generation, a.clock, logger)
|
||||
if !apiequality.Semantic.DeepEqual(oldCnStatus, cn.Status) {
|
||||
// An error encountered here should get returned by the Reconcile function.
|
||||
if updateErr := a.Client.Status().Update(ctx, cn); updateErr != nil {
|
||||
err = errors.Wrap(err, updateErr.Error())
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
if !slices.Contains(cn.Finalizers, FinalizerName) {
|
||||
// This log line is printed exactly once during initial provisioning,
|
||||
// because once the finalizer is in place this block gets skipped. So,
|
||||
// this is a nice place to tell the operator that the high level,
|
||||
// multi-reconcile operation is underway.
|
||||
logger.Infof("ensuring Connector is set up")
|
||||
cn.Finalizers = append(cn.Finalizers, FinalizerName)
|
||||
if err := a.Update(ctx, cn); err != nil {
|
||||
logger.Errorf("error adding finalizer: %w", err)
|
||||
return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionFalse, reasonConnectorCreationFailed, reasonConnectorCreationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.validate(cn); err != nil {
|
||||
logger.Errorf("error validating Connector spec: %w", err)
|
||||
message := fmt.Sprintf(messageConnectorInvalid, err)
|
||||
a.recorder.Eventf(cn, corev1.EventTypeWarning, reasonConnectorInvalid, message)
|
||||
return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionFalse, reasonConnectorInvalid, message)
|
||||
}
|
||||
|
||||
if err = a.maybeProvisionConnector(ctx, logger, cn); err != nil {
|
||||
logger.Errorf("error creating Connector resources: %w", err)
|
||||
message := fmt.Sprintf(messageConnectorCreationFailed, err)
|
||||
a.recorder.Eventf(cn, corev1.EventTypeWarning, reasonConnectorCreationFailed, message)
|
||||
return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionFalse, reasonConnectorCreationFailed, message)
|
||||
}
|
||||
|
||||
logger.Info("Connector resources synced")
|
||||
cn.Status.IsExitNode = cn.Spec.ExitNode
|
||||
if cn.Spec.SubnetRouter != nil {
|
||||
cn.Status.SubnetRoutes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify()
|
||||
return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionTrue, reasonConnectorCreated, reasonConnectorCreated)
|
||||
}
|
||||
cn.Status.SubnetRoutes = ""
|
||||
return setStatus(cn, tsapi.ConnectorReady, metav1.ConditionTrue, reasonConnectorCreated, reasonConnectorCreated)
|
||||
}
|
||||
|
||||
// maybeProvisionConnector ensures that any new resources required for this
|
||||
// Connector instance are deployed to the cluster.
|
||||
func (a *ConnectorReconciler) maybeProvisionConnector(ctx context.Context, logger *zap.SugaredLogger, cn *tsapi.Connector) error {
|
||||
hostname := cn.Name + "-connector"
|
||||
if cn.Spec.Hostname != "" {
|
||||
hostname = string(cn.Spec.Hostname)
|
||||
}
|
||||
crl := childResourceLabels(cn.Name, a.tsnamespace, "connector")
|
||||
sts := &tailscaleSTSConfig{
|
||||
ParentResourceName: cn.Name,
|
||||
ParentResourceUID: string(cn.UID),
|
||||
Hostname: hostname,
|
||||
ChildResourceLabels: crl,
|
||||
Tags: cn.Spec.Tags.Stringify(),
|
||||
Connector: &connector{
|
||||
isExitNode: cn.Spec.ExitNode,
|
||||
},
|
||||
}
|
||||
|
||||
if cn.Spec.SubnetRouter != nil && len(cn.Spec.SubnetRouter.AdvertiseRoutes) > 0 {
|
||||
sts.Connector.routes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify()
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
if sts.Connector.isExitNode {
|
||||
a.exitNodes.Add(cn.UID)
|
||||
} else {
|
||||
a.exitNodes.Remove(cn.UID)
|
||||
}
|
||||
if sts.Connector.routes != "" {
|
||||
a.subnetRouters.Add(cn.GetUID())
|
||||
} else {
|
||||
a.subnetRouters.Remove(cn.GetUID())
|
||||
}
|
||||
a.mu.Unlock()
|
||||
gaugeConnectorSubnetRouterResources.Set(int64(a.subnetRouters.Len()))
|
||||
gaugeConnectorExitNodeResources.Set(int64(a.exitNodes.Len()))
|
||||
var connectors set.Slice[types.UID]
|
||||
connectors.AddSlice(a.exitNodes.Slice())
|
||||
connectors.AddSlice(a.subnetRouters.Slice())
|
||||
gaugeConnectorResources.Set(int64(connectors.Len()))
|
||||
|
||||
_, err := a.ssr.Provision(ctx, logger, sts)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *ConnectorReconciler) maybeCleanupConnector(ctx context.Context, logger *zap.SugaredLogger, cn *tsapi.Connector) (bool, error) {
|
||||
if done, err := a.ssr.Cleanup(ctx, logger, childResourceLabels(cn.Name, a.tsnamespace, "connector")); err != nil {
|
||||
return false, fmt.Errorf("failed to cleanup Connector resources: %w", err)
|
||||
} else if !done {
|
||||
logger.Debugf("Connector cleanup not done yet, waiting for next reconcile")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Unlike most log entries in the reconcile loop, this will get printed
|
||||
// exactly once at the very end of cleanup, because the final step of
|
||||
// cleanup removes the tailscale finalizer, which will make all future
|
||||
// reconciles exit early.
|
||||
logger.Infof("cleaned up Connector resources")
|
||||
a.mu.Lock()
|
||||
a.subnetRouters.Remove(cn.UID)
|
||||
a.exitNodes.Remove(cn.UID)
|
||||
a.mu.Unlock()
|
||||
gaugeConnectorExitNodeResources.Set(int64(a.exitNodes.Len()))
|
||||
gaugeConnectorSubnetRouterResources.Set(int64(a.subnetRouters.Len()))
|
||||
var connectors set.Slice[types.UID]
|
||||
connectors.AddSlice(a.exitNodes.Slice())
|
||||
connectors.AddSlice(a.subnetRouters.Slice())
|
||||
gaugeConnectorResources.Set(int64(connectors.Len()))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *ConnectorReconciler) validate(cn *tsapi.Connector) error {
|
||||
// Connector fields are already validated at apply time with CEL validation
|
||||
// on custom resource fields. The checks here are a backup in case the
|
||||
// CEL validation breaks without us noticing.
|
||||
if !(cn.Spec.SubnetRouter != nil || cn.Spec.ExitNode) {
|
||||
return errors.New("invalid spec: a Connector must expose subnet routes or act as an exit node (or both)")
|
||||
}
|
||||
if cn.Spec.SubnetRouter == nil {
|
||||
return nil
|
||||
}
|
||||
return validateSubnetRouter(cn.Spec.SubnetRouter)
|
||||
}
|
||||
|
||||
func validateSubnetRouter(sb *tsapi.SubnetRouter) error {
|
||||
if len(sb.AdvertiseRoutes) < 1 {
|
||||
return errors.New("invalid subnet router spec: no routes defined")
|
||||
}
|
||||
var err error
|
||||
for _, route := range sb.AdvertiseRoutes {
|
||||
pfx, e := netip.ParsePrefix(string(route))
|
||||
if e != nil {
|
||||
err = errors.Wrap(err, fmt.Sprintf("route %s is invalid: %v", route, err))
|
||||
continue
|
||||
}
|
||||
if pfx.Masked() != pfx {
|
||||
err = errors.Wrap(err, fmt.Sprintf("route %s has non-address bits set; expected %s", pfx, pfx.Masked()))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
185
cmd/k8s-operator/connector_test.go
Normal file
185
cmd/k8s-operator/connector_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstest"
|
||||
)
|
||||
|
||||
func TestConnector(t *testing.T) {
|
||||
// Create a Connector that defines a Tailscale node that advertises
|
||||
// 10.40.0.0/14 route and acts as an exit node.
|
||||
cn := &tsapi.Connector{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: tsapi.ConnectorKind,
|
||||
APIVersion: "tailscale.io/v1alpha1",
|
||||
},
|
||||
Spec: tsapi.ConnectorSpec{
|
||||
SubnetRouter: &tsapi.SubnetRouter{
|
||||
AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"},
|
||||
},
|
||||
ExitNode: true,
|
||||
},
|
||||
}
|
||||
fc := fake.NewClientBuilder().
|
||||
WithScheme(tsapi.GlobalScheme).
|
||||
WithObjects(cn).
|
||||
WithStatusSubresource(cn).
|
||||
Build()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||
cr := &ConnectorReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
clock: cl,
|
||||
logger: zl.Sugar(),
|
||||
}
|
||||
|
||||
expectReconciled(t, cr, "", "test")
|
||||
fullName, shortName := findGenName(t, fc, "", "test", "connector")
|
||||
|
||||
opts := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
parentType: "connector",
|
||||
hostname: "test-connector",
|
||||
shouldUseDeclarativeConfig: true,
|
||||
isExitNode: true,
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
confFileHash: "9321660203effb80983eaecc7b5ac5a8c53934926f46e895b9fe295dcfc5a904",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Add another route to be advertised.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.40.0.0/14", "10.44.0.0/20"}
|
||||
})
|
||||
opts.subnetRoutes = "10.40.0.0/14,10.44.0.0/20"
|
||||
opts.confFileHash = "fb6c4daf67425f983985750cd8d6f2beae77e614fcb34176604571f5623d6862"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Remove a route.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"10.44.0.0/20"}
|
||||
})
|
||||
opts.subnetRoutes = "10.44.0.0/20"
|
||||
opts.confFileHash = "bacba177bcfe3849065cf6fee53d658a9bb4144197ac5b861727d69ea99742bb"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Remove the subnet router.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.SubnetRouter = nil
|
||||
})
|
||||
opts.subnetRoutes = ""
|
||||
opts.confFileHash = "7c421a99128eb80e79a285a82702f19f8f720615542a15bd794858a6275d8079"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Re-add the subnet router.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.SubnetRouter = &tsapi.SubnetRouter{
|
||||
AdvertiseRoutes: []tsapi.Route{"10.44.0.0/20"},
|
||||
}
|
||||
})
|
||||
opts.subnetRoutes = "10.44.0.0/20"
|
||||
opts.confFileHash = "bacba177bcfe3849065cf6fee53d658a9bb4144197ac5b861727d69ea99742bb"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Delete the Connector.
|
||||
if err = fc.Delete(context.Background(), cn); err != nil {
|
||||
t.Fatalf("error deleting Connector: %v", err)
|
||||
}
|
||||
|
||||
expectRequeue(t, cr, "", "test")
|
||||
expectReconciled(t, cr, "", "test")
|
||||
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
|
||||
// Create a Connector that advertises a route and is not an exit node.
|
||||
cn = &tsapi.Connector{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: tsapi.ConnectorKind,
|
||||
APIVersion: "tailscale.io/v1alpha1",
|
||||
},
|
||||
Spec: tsapi.ConnectorSpec{
|
||||
SubnetRouter: &tsapi.SubnetRouter{
|
||||
AdvertiseRoutes: []tsapi.Route{"10.40.0.0/14"},
|
||||
},
|
||||
},
|
||||
}
|
||||
opts.subnetRoutes = "10.44.0.0/14"
|
||||
opts.isExitNode = false
|
||||
mustCreate(t, fc, cn)
|
||||
expectReconciled(t, cr, "", "test")
|
||||
fullName, shortName = findGenName(t, fc, "", "test", "connector")
|
||||
|
||||
opts = configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
parentType: "connector",
|
||||
shouldUseDeclarativeConfig: true,
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
hostname: "test-connector",
|
||||
confFileHash: "57d922331890c9b1c8c6ae664394cb254334c551d9cd9db14537b5d9da9fb17e",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Add an exit node.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
conn.Spec.ExitNode = true
|
||||
})
|
||||
opts.isExitNode = true
|
||||
opts.confFileHash = "1499b591fd97a50f0330db6ec09979792c49890cf31f5da5bb6a3f50dba1e77a"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Delete the Connector.
|
||||
if err = fc.Delete(context.Background(), cn); err != nil {
|
||||
t.Fatalf("error deleting Connector: %v", err)
|
||||
}
|
||||
|
||||
expectRequeue(t, cr, "", "test")
|
||||
expectReconciled(t, cr, "", "test")
|
||||
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
}
|
||||
12
cmd/k8s-operator/deploy/README.md
Normal file
12
cmd/k8s-operator/deploy/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Tailscale Kubernetes operator deployment manifests
|
||||
|
||||
./cmd/k8s-operator/deploy contain various Tailscale Kubernetes operator deployment manifests.
|
||||
|
||||
## Helm chart
|
||||
|
||||
`./cmd/k8s-operator/deploy/chart` contains Tailscale operator Helm chart templates.
|
||||
The chart templates are also used to generate the static manifest, so developers must ensure that any changes applied to the chart have been propagated to the static manifest by running `go generate tailscale.com/cmd/k8s-operator`
|
||||
|
||||
## Static manifests
|
||||
|
||||
`./cmd/k8s-operator/deploy/manifests/operator.yaml` is a static manifest for the operator generated from the Helm chart templates for the operator.
|
||||
@@ -59,6 +59,8 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: ENABLE_CONNECTOR
|
||||
value: "{{ .Values.enableConnector }}"
|
||||
- name: CLIENT_ID_FILE
|
||||
value: /oauth/client_id
|
||||
- name: CLIENT_SECRET_FILE
|
||||
|
||||
@@ -18,6 +18,9 @@ rules:
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses", "ingresses/status"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["connectors", "connectors/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
|
||||
@@ -8,6 +8,12 @@ oauth: {}
|
||||
# clientId: ""
|
||||
# clientSecret: ""
|
||||
|
||||
# enableConnector determines whether the operator should reconcile
|
||||
# connector.tailscale.com custom resources. If set to true you have to install
|
||||
# connector CRD in a separate step.
|
||||
# You can do so by running 'kubectl apply -f ./cmd/k8s-operator/deploy/crds'.
|
||||
enableConnector: "false"
|
||||
|
||||
operatorConfig:
|
||||
image:
|
||||
repo: tailscale/k8s-operator
|
||||
@@ -16,7 +22,7 @@ operatorConfig:
|
||||
tag: ""
|
||||
digest: ""
|
||||
pullPolicy: Always
|
||||
logging: "info"
|
||||
logging: "info" # info, debug, dev
|
||||
hostname: "tailscale-operator"
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
@@ -47,7 +53,9 @@ proxyConfig:
|
||||
# ACL tag that operator will tag proxies with. Operator must be made owner of
|
||||
# these tags
|
||||
# https://tailscale.com/kb/1236/kubernetes-operator/?q=operator#setting-up-the-kubernetes-operator
|
||||
defaultTags: tag:k8s
|
||||
# Multiple tags can be passed as a comma-separated string i.e 'tag:k8s-proxies,tag:prod'.
|
||||
# Note that if you pass multiple tags to this field via `--set` flag to helm upgrade/install commands you must escape the comma (for example, "tag:k8s-proxies\,tag:prod"). See https://github.com/helm/helm/issues/1556
|
||||
defaultTags: "tag:k8s"
|
||||
firewallMode: auto
|
||||
|
||||
# apiServerProxyConfig allows to configure whether the operator should expose
|
||||
|
||||
125
cmd/k8s-operator/deploy/crds/tailscale.com_connectors.yaml
Normal file
125
cmd/k8s-operator/deploy/crds/tailscale.com_connectors.yaml
Normal file
@@ -0,0 +1,125 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
name: connectors.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
names:
|
||||
kind: Connector
|
||||
listKind: ConnectorList
|
||||
plural: connectors
|
||||
shortNames:
|
||||
- cn
|
||||
singular: connector
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- description: CIDR ranges exposed to tailnet by a subnet router defined via this Connector instance.
|
||||
jsonPath: .status.subnetRoutes
|
||||
name: SubnetRoutes
|
||||
type: string
|
||||
- description: Whether this Connector instance defines an exit node.
|
||||
jsonPath: .status.isExitNode
|
||||
name: IsExitNode
|
||||
type: string
|
||||
- description: Status of the deployed Connector resources.
|
||||
jsonPath: .status.conditions[?(@.type == "ConnectorReady")].reason
|
||||
name: Status
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
properties:
|
||||
apiVersion:
|
||||
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
|
||||
type: string
|
||||
kind:
|
||||
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: ConnectorSpec describes the desired Tailscale component.
|
||||
type: object
|
||||
properties:
|
||||
exitNode:
|
||||
description: ExitNode defines whether the Connector node should act as a Tailscale exit node. Defaults to false. https://tailscale.com/kb/1103/exit-nodes
|
||||
type: boolean
|
||||
hostname:
|
||||
description: Hostname is the tailnet hostname that should be assigned to the Connector node. If unset, hostname defaults to <connector name>-connector. Hostname can contain lower case letters, numbers and dashes, it must not start or end with a dash and must be between 2 and 63 characters long.
|
||||
type: string
|
||||
pattern: ^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$
|
||||
subnetRouter:
|
||||
description: SubnetRouter defines subnet routes that the Connector node should expose to tailnet. If unset, none are exposed. https://tailscale.com/kb/1019/subnets/
|
||||
type: object
|
||||
required:
|
||||
- advertiseRoutes
|
||||
properties:
|
||||
advertiseRoutes:
|
||||
description: AdvertiseRoutes refer to CIDRs that the subnet router should make available. Route values must be strings that represent a valid IPv4 or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes. https://tailscale.com/kb/1201/4via6-subnets/
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: string
|
||||
format: cidr
|
||||
tags:
|
||||
description: Tags that the Tailscale node will be tagged with. Defaults to [tag:k8s]. To autoapprove the subnet routes or exit node defined by a Connector, you can configure Tailscale ACLs to give these tags the necessary permissions. See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes. If you specify custom tags here, you must also make the operator an owner of these tags. See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. Tags cannot be changed once a Connector node has been created. Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
|
||||
x-kubernetes-validations:
|
||||
- rule: has(self.subnetRouter) || self.exitNode == true
|
||||
message: A Connector needs to be either an exit node or a subnet router, or both.
|
||||
status:
|
||||
description: ConnectorStatus describes the status of the Connector. This is set and managed by the Tailscale operator.
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
description: List of status conditions to indicate the status of the Connector. Known condition types are `ConnectorReady`.
|
||||
type: array
|
||||
items:
|
||||
description: ConnectorCondition contains condition information for a Connector.
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- type
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
|
||||
type: string
|
||||
format: date-time
|
||||
message:
|
||||
description: Message is a human readable description of the details of the last transition, complementing reason.
|
||||
type: string
|
||||
observedGeneration:
|
||||
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
|
||||
type: integer
|
||||
format: int64
|
||||
reason:
|
||||
description: Reason is a brief machine readable explanation for the condition's last transition.
|
||||
type: string
|
||||
status:
|
||||
description: Status of the condition, one of ('True', 'False', 'Unknown').
|
||||
type: string
|
||||
type:
|
||||
description: Type of the condition, known values are (`SubnetRouterReady`).
|
||||
type: string
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
isExitNode:
|
||||
description: IsExitNode is set to true if the Connector acts as an exit node.
|
||||
type: boolean
|
||||
subnetRoutes:
|
||||
description: SubnetRoutes are the routes currently exposed to tailnet via this Connector instance.
|
||||
type: string
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
19
cmd/k8s-operator/deploy/examples/connector.yaml
Normal file
19
cmd/k8s-operator/deploy/examples/connector.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
# Before applying ensure that the operator owns tag:prod.
|
||||
# https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
|
||||
# To set up autoapproval set tag:prod as approver for 10.40.0.0/14 route and exit node.
|
||||
# Otherwise approve it manually in Machines panel once the
|
||||
# ts-prod Tailscale node has been created.
|
||||
# See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes
|
||||
apiVersion: tailscale.com/v1alpha1
|
||||
kind: Connector
|
||||
metadata:
|
||||
name: prod
|
||||
spec:
|
||||
tags:
|
||||
- "tag:prod"
|
||||
hostname: ts-prod
|
||||
subnetRouter:
|
||||
advertiseRoutes:
|
||||
- "10.40.0.0/14"
|
||||
- "192.168.0.0/14"
|
||||
exitNode: true
|
||||
@@ -7,94 +7,6 @@ metadata:
|
||||
name: tailscale
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["*"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: proxies
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: tailscale-operator
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["events", "services", "services/status"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["ingresses", "ingresses/status"]
|
||||
verbs: ["*"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: tailscale-operator
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: tailscale-operator
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["statefulsets"]
|
||||
verbs: ["*"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: operator
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: operator-oauth
|
||||
@@ -103,59 +15,176 @@ stringData:
|
||||
client_id: # SET CLIENT ID HERE
|
||||
client_secret: # SET CLIENT SECRET HERE
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: tailscale-operator
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- events
|
||||
- services
|
||||
- services/status
|
||||
verbs:
|
||||
- '*'
|
||||
- apiGroups:
|
||||
- networking.k8s.io
|
||||
resources:
|
||||
- ingresses
|
||||
- ingresses/status
|
||||
verbs:
|
||||
- '*'
|
||||
- apiGroups:
|
||||
- tailscale.com
|
||||
resources:
|
||||
- connectors
|
||||
- connectors/status
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: tailscale-operator
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: tailscale-operator
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- secrets
|
||||
verbs:
|
||||
- '*'
|
||||
- apiGroups:
|
||||
- apps
|
||||
resources:
|
||||
- statefulsets
|
||||
verbs:
|
||||
- '*'
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- secrets
|
||||
verbs:
|
||||
- '*'
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: operator
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: proxies
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: proxies
|
||||
namespace: tailscale
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
name: operator
|
||||
namespace: tailscale
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: operator
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: operator
|
||||
spec:
|
||||
serviceAccountName: operator
|
||||
volumes:
|
||||
- name: oauth
|
||||
secret:
|
||||
secretName: operator-oauth
|
||||
containers:
|
||||
- name: operator
|
||||
image: tailscale/k8s-operator:unstable
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 100Mi
|
||||
env:
|
||||
- name: OPERATOR_HOSTNAME
|
||||
value: tailscale-operator
|
||||
- name: OPERATOR_SECRET
|
||||
value: operator
|
||||
- name: OPERATOR_LOGGING
|
||||
value: info
|
||||
- name: OPERATOR_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: CLIENT_ID_FILE
|
||||
value: /oauth/client_id
|
||||
- name: CLIENT_SECRET_FILE
|
||||
value: /oauth/client_secret
|
||||
- name: PROXY_IMAGE
|
||||
value: tailscale/tailscale:unstable
|
||||
- name: PROXY_TAGS
|
||||
value: tag:k8s
|
||||
- name: APISERVER_PROXY
|
||||
value: "false"
|
||||
- name: PROXY_FIREWALL_MODE
|
||||
value: auto
|
||||
volumeMounts:
|
||||
- name: oauth
|
||||
mountPath: /oauth
|
||||
readOnly: true
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: operator
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: operator
|
||||
spec:
|
||||
containers:
|
||||
- env:
|
||||
- name: OPERATOR_HOSTNAME
|
||||
value: tailscale-operator
|
||||
- name: OPERATOR_SECRET
|
||||
value: operator
|
||||
- name: OPERATOR_LOGGING
|
||||
value: info
|
||||
- name: OPERATOR_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: ENABLE_CONNECTOR
|
||||
value: "false"
|
||||
- name: CLIENT_ID_FILE
|
||||
value: /oauth/client_id
|
||||
- name: CLIENT_SECRET_FILE
|
||||
value: /oauth/client_secret
|
||||
- name: PROXY_IMAGE
|
||||
value: tailscale/tailscale:unstable
|
||||
- name: PROXY_TAGS
|
||||
value: tag:k8s
|
||||
- name: APISERVER_PROXY
|
||||
value: "false"
|
||||
- name: PROXY_FIREWALL_MODE
|
||||
value: auto
|
||||
image: tailscale/k8s-operator:unstable
|
||||
imagePullPolicy: Always
|
||||
name: operator
|
||||
volumeMounts:
|
||||
- mountPath: /oauth
|
||||
name: oauth
|
||||
readOnly: true
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
serviceAccountName: operator
|
||||
volumes:
|
||||
- name: oauth
|
||||
secret:
|
||||
secretName: operator-oauth
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: tailscale
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: operator-oauth
|
||||
namespace: tailscale
|
||||
stringData:
|
||||
client_id: # SET CLIENT ID HERE
|
||||
client_secret: # SET CLIENT SECRET HERE
|
||||
---
|
||||
74
cmd/k8s-operator/generate/main.go
Normal file
74
cmd/k8s-operator/generate/main.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
repoRoot := "../../"
|
||||
cmd := exec.Command("./tool/helm", "template", "operator", "./cmd/k8s-operator/deploy/chart",
|
||||
"--namespace=tailscale")
|
||||
cmd.Dir = repoRoot
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Fatalf("error templating helm manifests: %v", err)
|
||||
}
|
||||
|
||||
var final bytes.Buffer
|
||||
|
||||
templatePath := filepath.Join(repoRoot, "cmd/k8s-operator/deploy/manifests/templates")
|
||||
fileInfos, err := os.ReadDir(templatePath)
|
||||
if err != nil {
|
||||
log.Fatalf("error reading templates: %v", err)
|
||||
}
|
||||
for _, fi := range fileInfos {
|
||||
templateBytes, err := os.ReadFile(filepath.Join(templatePath, fi.Name()))
|
||||
if err != nil {
|
||||
log.Fatalf("error reading template: %v", err)
|
||||
}
|
||||
final.Write(templateBytes)
|
||||
}
|
||||
decoder := yaml.NewDecoder(&out)
|
||||
for {
|
||||
var document any
|
||||
err := decoder.Decode(&document)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("failed read from input data: %v", err)
|
||||
}
|
||||
|
||||
bytes, err := yaml.Marshal(document)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to marshal YAML document: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(bytes)) == "null" {
|
||||
continue
|
||||
}
|
||||
if _, err = final.Write(bytes); err != nil {
|
||||
log.Fatalf("error marshaling yaml: %v", err)
|
||||
}
|
||||
fmt.Fprint(&final, "---\n")
|
||||
}
|
||||
finalString, _ := strings.CutSuffix(final.String(), "---\n")
|
||||
if err := os.WriteFile(filepath.Join(repoRoot, "cmd/k8s-operator/deploy/manifests/operator.yaml"), []byte(finalString), 0664); err != nil {
|
||||
log.Fatalf("error writing new file: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,19 @@ import (
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/kubestore"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
// Generate static manifests for deploying Tailscale operator on Kubernetes from the operator's Helm chart.
|
||||
//go:generate go run tailscale.com/cmd/k8s-operator/generate
|
||||
|
||||
// Generate Connector CustomResourceDefinition yaml from its Go types.
|
||||
//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen crd schemapatch:manifests=./deploy/crds output:dir=./deploy/crds paths=../../k8s-operator/apis/...
|
||||
|
||||
func main() {
|
||||
// Required to use our client API. We're fine with the instability since the
|
||||
// client lives in the same repo as this code.
|
||||
@@ -54,6 +62,7 @@ func main() {
|
||||
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
|
||||
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
|
||||
tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "")
|
||||
tsEnableConnector = defaultBool("ENABLE_CONNECTOR", false)
|
||||
)
|
||||
|
||||
var opts []kzap.Opts
|
||||
@@ -82,7 +91,9 @@ func main() {
|
||||
defer s.Close()
|
||||
restConfig := config.GetConfigOrDie()
|
||||
maybeLaunchAPIServerProxy(zlog, restConfig, s, mode)
|
||||
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode)
|
||||
// TODO (irbekrm): gather the reconciler options into an opts struct
|
||||
// rather than passing a million of them in one by one.
|
||||
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode, tsEnableConnector)
|
||||
}
|
||||
|
||||
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
|
||||
@@ -190,7 +201,7 @@ waitOnline:
|
||||
|
||||
// runReconcilers starts the controller-runtime manager and registers the
|
||||
// ServiceReconciler. It blocks forever.
|
||||
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string) {
|
||||
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string, enableConnector bool) {
|
||||
var (
|
||||
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
|
||||
)
|
||||
@@ -204,20 +215,25 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
nsFilter := cache.ByObject{
|
||||
Field: client.InNamespace(tsNamespace).AsSelector(),
|
||||
}
|
||||
mgr, err := manager.New(restConfig, manager.Options{
|
||||
mgrOpts := manager.Options{
|
||||
Cache: cache.Options{
|
||||
ByObject: map[client.Object]cache.ByObject{
|
||||
&corev1.Secret{}: nsFilter,
|
||||
&appsv1.StatefulSet{}: nsFilter,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if enableConnector {
|
||||
mgrOpts.Scheme = tsapi.GlobalScheme
|
||||
}
|
||||
mgr, err := manager.New(restConfig, mgrOpts)
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create manager: %v", err)
|
||||
}
|
||||
|
||||
svcFilter := handler.EnqueueRequestsFromMapFunc(serviceHandler)
|
||||
svcChildFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("svc"))
|
||||
|
||||
eventRecorder := mgr.GetEventRecorderFor("tailscale-operator")
|
||||
ssr := &tailscaleSTSReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
@@ -262,6 +278,23 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
startlog.Fatalf("could not create controller: %v", err)
|
||||
}
|
||||
|
||||
if enableConnector {
|
||||
connectorFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("subnetrouter"))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.Connector{}).
|
||||
Watches(&appsv1.StatefulSet{}, connectorFilter).
|
||||
Watches(&corev1.Secret{}, connectorFilter).
|
||||
Complete(&ConnectorReconciler{
|
||||
ssr: ssr,
|
||||
recorder: eventRecorder,
|
||||
Client: mgr.GetClient(),
|
||||
logger: zlog.Named("connector-reconciler"),
|
||||
clock: tstime.DefaultClock{},
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatal("could not create connector reconciler: %v", err)
|
||||
}
|
||||
}
|
||||
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
|
||||
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
|
||||
startlog.Fatalf("could not start manager: %v", err)
|
||||
|
||||
@@ -6,24 +6,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.uber.org/zap"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/types/ptr"
|
||||
)
|
||||
|
||||
@@ -66,16 +57,19 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
opts := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(opts))
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, then verify reconcile again and verify
|
||||
@@ -159,6 +153,7 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
}
|
||||
|
||||
func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
@@ -203,16 +198,18 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
o := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
tailnetTargetFQDN: tailnetTargetFQDN,
|
||||
hostname: "default-test",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
@@ -235,14 +232,8 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o = stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
tailnetTargetFQDN: tailnetTargetFQDN,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
// Change the tailscale-target-fqdn annotation which should update the
|
||||
@@ -272,6 +263,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
}
|
||||
|
||||
func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
@@ -316,16 +308,18 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
o := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
tailnetTargetIP: tailnetTargetIP,
|
||||
hostname: "default-test",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
@@ -348,14 +342,8 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o = stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
tailnetTargetIP: tailnetTargetIP,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
// Change the tailscale-target-ip annotation which should update the
|
||||
@@ -427,15 +415,18 @@ func TestAnnotations(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
o := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
@@ -532,15 +523,18 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
o := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
@@ -586,11 +580,6 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
// None of the proxy machinery should have changed...
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o = stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
// ... but the service should have a LoadBalancer status.
|
||||
|
||||
@@ -665,15 +654,18 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
o := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
@@ -737,11 +729,6 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o = stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
want = &corev1.Service{
|
||||
@@ -808,15 +795,18 @@ func TestCustomHostname(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "reindeer-flotilla",
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
o := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
hostname: "reindeer-flotilla",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
@@ -919,12 +909,15 @@ func TestCustomPriorityClassName(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
o := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
hostname: "tailscale-critical",
|
||||
priorityClassName: "custom-priority-class-name",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
@@ -969,14 +962,16 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
|
||||
expectEqual(t, fc, expectedSecret(fullName))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName))
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
o := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
}
|
||||
@@ -1021,335 +1016,20 @@ func TestProxyFirewallMode(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test")
|
||||
o := stsOpts{
|
||||
name: shortName,
|
||||
secretName: fullName,
|
||||
hostname: "default-test",
|
||||
firewallMode: "nftables",
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
o := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
firewallMode: "nftables",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(o))
|
||||
|
||||
}
|
||||
|
||||
func expectedSecret(name string) *corev1.Secret {
|
||||
return &corev1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Secret",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-ns": "default",
|
||||
"tailscale.com/parent-resource-type": "svc",
|
||||
},
|
||||
},
|
||||
StringData: map[string]string{
|
||||
"authkey": "secret-authkey",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func expectedHeadlessService(name string) *corev1.Service {
|
||||
return &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
GenerateName: "ts-test-",
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-ns": "default",
|
||||
"tailscale.com/parent-resource-type": "svc",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{
|
||||
"app": "1234-UID",
|
||||
},
|
||||
ClusterIP: "None",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func expectedSTS(opts stsOpts) *appsv1.StatefulSet {
|
||||
containerEnv := []corev1.EnvVar{
|
||||
{Name: "TS_USERSPACE", Value: "false"},
|
||||
{Name: "TS_AUTH_ONCE", Value: "true"},
|
||||
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
|
||||
{Name: "TS_HOSTNAME", Value: opts.hostname},
|
||||
}
|
||||
annots := map[string]string{
|
||||
"tailscale.com/operator-last-set-hostname": opts.hostname,
|
||||
}
|
||||
if opts.tailnetTargetIP != "" {
|
||||
annots["tailscale.com/operator-last-set-ts-tailnet-target-ip"] = opts.tailnetTargetIP
|
||||
containerEnv = append(containerEnv, corev1.EnvVar{
|
||||
Name: "TS_TAILNET_TARGET_IP",
|
||||
Value: opts.tailnetTargetIP,
|
||||
})
|
||||
} else if opts.tailnetTargetFQDN != "" {
|
||||
annots["tailscale.com/operator-last-set-ts-tailnet-target-fqdn"] = opts.tailnetTargetFQDN
|
||||
containerEnv = append(containerEnv, corev1.EnvVar{
|
||||
Name: "TS_TAILNET_TARGET_FQDN",
|
||||
Value: opts.tailnetTargetFQDN,
|
||||
})
|
||||
|
||||
} else {
|
||||
containerEnv = append(containerEnv, corev1.EnvVar{
|
||||
Name: "TS_DEST_IP",
|
||||
Value: "10.20.30.40",
|
||||
})
|
||||
|
||||
annots["tailscale.com/operator-last-set-cluster-ip"] = "10.20.30.40"
|
||||
|
||||
}
|
||||
if opts.firewallMode != "" {
|
||||
containerEnv = append(containerEnv, corev1.EnvVar{
|
||||
Name: "TS_DEBUG_FIREWALL_MODE",
|
||||
Value: opts.firewallMode,
|
||||
})
|
||||
}
|
||||
return &appsv1.StatefulSet{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "StatefulSet",
|
||||
APIVersion: "apps/v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: opts.name,
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-ns": "default",
|
||||
"tailscale.com/parent-resource-type": "svc",
|
||||
},
|
||||
},
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
Replicas: ptr.To[int32](1),
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{"app": "1234-UID"},
|
||||
},
|
||||
ServiceName: opts.name,
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: annots,
|
||||
DeletionGracePeriodSeconds: ptr.To[int64](10),
|
||||
Labels: map[string]string{"app": "1234-UID"},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
ServiceAccountName: "proxies",
|
||||
PriorityClassName: opts.priorityClassName,
|
||||
InitContainers: []corev1.Container{
|
||||
{
|
||||
Name: "sysctler",
|
||||
Image: "tailscale/tailscale",
|
||||
Command: []string{"/bin/sh"},
|
||||
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Privileged: ptr.To(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "tailscale",
|
||||
Image: "tailscale/tailscale",
|
||||
Env: containerEnv,
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Capabilities: &corev1.Capabilities{
|
||||
Add: []corev1.Capability{"NET_ADMIN"},
|
||||
},
|
||||
},
|
||||
ImagePullPolicy: "Always",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func findGenName(t *testing.T, client client.Client, ns, name string) (full, noSuffix string) {
|
||||
t.Helper()
|
||||
labels := map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelParentName: name,
|
||||
LabelParentNamespace: ns,
|
||||
LabelParentType: "svc",
|
||||
}
|
||||
s, err := getSingleObject[corev1.Secret](context.Background(), client, "operator-ns", labels)
|
||||
if err != nil {
|
||||
t.Fatalf("finding secret for %q: %v", name, err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatalf("no secret found for %q", name)
|
||||
}
|
||||
return s.GetName(), strings.TrimSuffix(s.GetName(), "-0")
|
||||
}
|
||||
|
||||
func mustCreate(t *testing.T, client client.Client, obj client.Object) {
|
||||
t.Helper()
|
||||
if err := client.Create(context.Background(), obj); err != nil {
|
||||
t.Fatalf("creating %q: %v", obj.GetName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustUpdate[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string, update func(O)) {
|
||||
t.Helper()
|
||||
obj := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
}, obj); err != nil {
|
||||
t.Fatalf("getting %q: %v", name, err)
|
||||
}
|
||||
update(obj)
|
||||
if err := client.Update(context.Background(), obj); err != nil {
|
||||
t.Fatalf("updating %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustUpdateStatus[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string, update func(O)) {
|
||||
t.Helper()
|
||||
obj := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
}, obj); err != nil {
|
||||
t.Fatalf("getting %q: %v", name, err)
|
||||
}
|
||||
update(obj)
|
||||
if err := client.Status().Update(context.Background(), obj); err != nil {
|
||||
t.Fatalf("updating %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func expectEqual[T any, O ptrObject[T]](t *testing.T, client client.Client, want O) {
|
||||
t.Helper()
|
||||
got := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
Name: want.GetName(),
|
||||
Namespace: want.GetNamespace(),
|
||||
}, got); err != nil {
|
||||
t.Fatalf("getting %q: %v", want.GetName(), err)
|
||||
}
|
||||
// The resource version changes eagerly whenever the operator does even a
|
||||
// no-op update. Asserting a specific value leads to overly brittle tests,
|
||||
// so just remove it from both got and want.
|
||||
got.SetResourceVersion("")
|
||||
want.SetResourceVersion("")
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Fatalf("unexpected object (-got +want):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func expectMissing[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string) {
|
||||
t.Helper()
|
||||
obj := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
}, obj); !apierrors.IsNotFound(err) {
|
||||
t.Fatalf("object %s/%s unexpectedly present, wanted missing", ns, name)
|
||||
}
|
||||
}
|
||||
|
||||
func expectReconciled(t *testing.T, sr *ServiceReconciler, ns, name string) {
|
||||
t.Helper()
|
||||
req := reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
},
|
||||
}
|
||||
res, err := sr.Reconcile(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile: unexpected error: %v", err)
|
||||
}
|
||||
if res.Requeue {
|
||||
t.Fatalf("unexpected immediate requeue")
|
||||
}
|
||||
if res.RequeueAfter != 0 {
|
||||
t.Fatalf("unexpected timed requeue (%v)", res.RequeueAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func expectRequeue(t *testing.T, sr *ServiceReconciler, ns, name string) {
|
||||
t.Helper()
|
||||
req := reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
},
|
||||
}
|
||||
res, err := sr.Reconcile(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile: unexpected error: %v", err)
|
||||
}
|
||||
if res.Requeue {
|
||||
t.Fatalf("unexpected immediate requeue")
|
||||
}
|
||||
if res.RequeueAfter == 0 {
|
||||
t.Fatalf("expected timed requeue, got success")
|
||||
}
|
||||
}
|
||||
|
||||
type stsOpts struct {
|
||||
name string
|
||||
secretName string
|
||||
hostname string
|
||||
priorityClassName string
|
||||
firewallMode string
|
||||
tailnetTargetIP string
|
||||
tailnetTargetFQDN string
|
||||
}
|
||||
|
||||
type fakeTSClient struct {
|
||||
sync.Mutex
|
||||
keyRequests []tailscale.KeyCapabilities
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.keyRequests = append(c.keyRequests, caps)
|
||||
k := &tailscale.Key{
|
||||
ID: "key",
|
||||
Created: time.Now(),
|
||||
Capabilities: caps,
|
||||
}
|
||||
return "secret-authkey", k, nil
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) DeleteDevice(ctx context.Context, deviceID string) error {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.deleted = append(c.deleted, deviceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) KeyRequests() []tailscale.KeyCapabilities {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
return c.keyRequests
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) Deleted() []string {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
return c.deleted
|
||||
}
|
||||
|
||||
func Test_isMagicDNSName(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
|
||||
@@ -218,7 +218,10 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLo
|
||||
}
|
||||
}
|
||||
|
||||
const capabilityName = "https://tailscale.com/cap/kubernetes"
|
||||
const (
|
||||
capabilityName = "tailscale.com/cap/kubernetes"
|
||||
oldCapabilityName = "https://" + capabilityName
|
||||
)
|
||||
|
||||
type capRule struct {
|
||||
// Impersonate is a list of rules that specify how to impersonate the caller
|
||||
@@ -239,6 +242,10 @@ func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error {
|
||||
log = log.With("remote", r.RemoteAddr)
|
||||
who := whoIsFromRequest(r)
|
||||
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, capabilityName)
|
||||
if len(rules) == 0 && err == nil {
|
||||
// Try the old capability name for backwards compatibility.
|
||||
rules, err = tailcfg.UnmarshalCapJSON[capRule](who.CapMap, oldCapabilityName)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal capability: %v", err)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"sigs.k8s.io/yaml"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/types/opt"
|
||||
@@ -54,11 +56,17 @@ const (
|
||||
AnnotationFunnel = "tailscale.com/funnel"
|
||||
|
||||
// Annotations set by the operator on pods to trigger restarts when the
|
||||
// hostname, IP or FQDN changes.
|
||||
// hostname, IP, FQDN or tailscaled config changes.
|
||||
podAnnotationLastSetClusterIP = "tailscale.com/operator-last-set-cluster-ip"
|
||||
podAnnotationLastSetHostname = "tailscale.com/operator-last-set-hostname"
|
||||
podAnnotationLastSetTailnetTargetIP = "tailscale.com/operator-last-set-ts-tailnet-target-ip"
|
||||
podAnnotationLastSetTailnetTargetFQDN = "tailscale.com/operator-last-set-ts-tailnet-target-fqdn"
|
||||
// podAnnotationLastSetConfigFileHash is sha256 hash of the current tailscaled configuration contents.
|
||||
podAnnotationLastSetConfigFileHash = "tailscale.com/operator-last-set-config-file-hash"
|
||||
|
||||
// tailscaledConfigKey is the name of the key in proxy Secret Data that
|
||||
// holds the tailscaled config contents.
|
||||
tailscaledConfigKey = "tailscaled"
|
||||
)
|
||||
|
||||
type tailscaleSTSConfig struct {
|
||||
@@ -66,18 +74,26 @@ type tailscaleSTSConfig struct {
|
||||
ParentResourceUID string
|
||||
ChildResourceLabels map[string]string
|
||||
|
||||
ServeConfig *ipn.ServeConfig
|
||||
// Tailscale target in cluster we are setting up ingress for
|
||||
ClusterTargetIP string
|
||||
ServeConfig *ipn.ServeConfig
|
||||
ClusterTargetIP string // ingress target
|
||||
|
||||
// Tailscale IP of a Tailscale service we are setting up egress for
|
||||
TailnetTargetIP string
|
||||
TailnetTargetIP string // egress target IP
|
||||
|
||||
// Tailscale FQDN of a Tailscale service we are setting up egress for
|
||||
TailnetTargetFQDN string
|
||||
TailnetTargetFQDN string // egress target FQDN
|
||||
|
||||
Hostname string
|
||||
Tags []string // if empty, use defaultTags
|
||||
|
||||
// Connector specifies a configuration of a Connector instance if that's
|
||||
// what this StatefulSet should be created for.
|
||||
Connector *connector
|
||||
}
|
||||
|
||||
type connector struct {
|
||||
// routes is a list of subnet routes that this Connector should expose.
|
||||
routes string
|
||||
// isExitNode defines whether this Connector should act as an exit node.
|
||||
isExitNode bool
|
||||
}
|
||||
|
||||
type tailscaleSTSReconciler struct {
|
||||
@@ -107,16 +123,17 @@ func (a *tailscaleSTSReconciler) IsHTTPSEnabledOnTailnet() bool {
|
||||
// up to date.
|
||||
func (a *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
|
||||
// Do full reconcile.
|
||||
// TODO (don't create Service for the Connector)
|
||||
hsvc, err := a.reconcileHeadlessService(ctx, logger, sts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reconcile headless service: %w", err)
|
||||
}
|
||||
|
||||
secretName, err := a.createOrGetSecret(ctx, logger, sts, hsvc)
|
||||
secretName, tsConfigHash, err := a.createOrGetSecret(ctx, logger, sts, hsvc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create or get API key secret: %w", err)
|
||||
}
|
||||
_, err = a.reconcileSTS(ctx, logger, sts, hsvc, secretName)
|
||||
_, err = a.reconcileSTS(ctx, logger, sts, hsvc, secretName, tsConfigHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reconcile statefulset: %w", err)
|
||||
}
|
||||
@@ -230,7 +247,7 @@ func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, l
|
||||
return createOrUpdate(ctx, a.Client, a.operatorNamespace, hsvc, func(svc *corev1.Service) { svc.Spec = hsvc.Spec })
|
||||
}
|
||||
|
||||
func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *zap.SugaredLogger, stsC *tailscaleSTSConfig, hsvc *corev1.Service) (string, error) {
|
||||
func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *zap.SugaredLogger, stsC *tailscaleSTSConfig, hsvc *corev1.Service) (string, string, error) {
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
// Hardcode a -0 suffix so that in future, if we support
|
||||
@@ -246,22 +263,25 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
|
||||
logger.Debugf("secret %s/%s already exists", secret.GetNamespace(), secret.GetName())
|
||||
orig = secret.DeepCopy()
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var (
|
||||
authKey, hash string
|
||||
)
|
||||
if orig == nil {
|
||||
// Secret doesn't exist yet, create one. Initially it contains
|
||||
// only the Tailscale authkey, but once Tailscale starts it'll
|
||||
// also store the daemon state.
|
||||
sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, stsC.ChildResourceLabels)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
if sts != nil {
|
||||
// StatefulSet exists, so we have already created the secret.
|
||||
// If the secret is missing, they should delete the StatefulSet.
|
||||
logger.Errorf("Tailscale proxy secret doesn't exist, but the corresponding StatefulSet %s/%s already does. Something is wrong, please delete the StatefulSet.", sts.GetNamespace(), sts.GetName())
|
||||
return "", nil
|
||||
return "", "", nil
|
||||
}
|
||||
// Create API Key secret which is going to be used by the statefulset
|
||||
// to authenticate with Tailscale.
|
||||
@@ -270,30 +290,42 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
|
||||
if len(tags) == 0 {
|
||||
tags = a.defaultTags
|
||||
}
|
||||
authKey, err := a.newAuthKey(ctx, tags)
|
||||
authKey, err = a.newAuthKey(ctx, tags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
}
|
||||
if !shouldDoTailscaledDeclarativeConfig(stsC) && authKey != "" {
|
||||
mak.Set(&secret.StringData, "authkey", authKey)
|
||||
}
|
||||
if shouldDoTailscaledDeclarativeConfig(stsC) {
|
||||
confFileBytes, h, err := tailscaledConfig(stsC, authKey, orig)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("error creating tailscaled config: %w", err)
|
||||
}
|
||||
hash = h
|
||||
mak.Set(&secret.StringData, tailscaledConfigKey, string(confFileBytes))
|
||||
}
|
||||
if stsC.ServeConfig != nil {
|
||||
j, err := json.Marshal(stsC.ServeConfig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
mak.Set(&secret.StringData, "serve-config", string(j))
|
||||
}
|
||||
|
||||
if orig != nil {
|
||||
logger.Debugf("patching existing state Secret with values %s", secret.Data[tailscaledConfigKey])
|
||||
if err := a.Patch(ctx, secret, client.MergeFrom(orig)); err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
} else {
|
||||
logger.Debugf("creating new state Secret with authkey %s", secret.Data[tailscaledConfigKey])
|
||||
if err := a.Create(ctx, secret); err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return secret.Name, nil
|
||||
return secret.Name, hash, nil
|
||||
}
|
||||
|
||||
// DeviceInfo returns the device ID and hostname for the Tailscale device
|
||||
@@ -321,7 +353,6 @@ func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map
|
||||
return "", "", nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return id, hostname, ips, nil
|
||||
}
|
||||
|
||||
@@ -349,7 +380,7 @@ var proxyYaml []byte
|
||||
//go:embed deploy/manifests/userspace-proxy.yaml
|
||||
var userspaceProxyYaml []byte
|
||||
|
||||
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, authKeySecret string) (*appsv1.StatefulSet, error) {
|
||||
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecret, tsConfigHash string) (*appsv1.StatefulSet, error) {
|
||||
var ss appsv1.StatefulSet
|
||||
if sts.ServeConfig != nil {
|
||||
if err := yaml.Unmarshal(userspaceProxyYaml, &ss); err != nil {
|
||||
@@ -369,30 +400,90 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
}
|
||||
container := &ss.Spec.Template.Spec.Containers[0]
|
||||
container.Image = a.proxyImage
|
||||
ss.ObjectMeta = metav1.ObjectMeta{
|
||||
Name: headlessSvc.Name,
|
||||
Namespace: a.operatorNamespace,
|
||||
Labels: sts.ChildResourceLabels,
|
||||
}
|
||||
ss.Spec.ServiceName = headlessSvc.Name
|
||||
ss.Spec.Selector = &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": sts.ParentResourceUID,
|
||||
},
|
||||
}
|
||||
mak.Set(&ss.Spec.Template.Labels, "app", sts.ParentResourceUID)
|
||||
|
||||
// Generic containerboot configuration options.
|
||||
container.Env = append(container.Env,
|
||||
corev1.EnvVar{
|
||||
Name: "TS_KUBE_SECRET",
|
||||
Value: authKeySecret,
|
||||
Value: proxySecret,
|
||||
},
|
||||
corev1.EnvVar{
|
||||
)
|
||||
if !shouldDoTailscaledDeclarativeConfig(sts) {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
Name: "TS_HOSTNAME",
|
||||
Value: sts.Hostname,
|
||||
})
|
||||
// containerboot currently doesn't have a way to re-read the hostname/ip as
|
||||
// it is passed via an environment variable. So we need to restart the
|
||||
// container when the value changes. We do this by adding an annotation to
|
||||
// the pod template that contains the last value we set.
|
||||
mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetHostname, sts.Hostname)
|
||||
}
|
||||
// Configure containeboot to run tailscaled with a configfile read from the state Secret.
|
||||
if shouldDoTailscaledDeclarativeConfig(sts) {
|
||||
mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetConfigFileHash, tsConfigHash)
|
||||
ss.Spec.Template.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, corev1.Volume{
|
||||
Name: "tailscaledconfig",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: proxySecret,
|
||||
Items: []corev1.KeyToPath{{
|
||||
Key: tailscaledConfigKey,
|
||||
Path: tailscaledConfigKey,
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{
|
||||
Name: "tailscaledconfig",
|
||||
ReadOnly: true,
|
||||
MountPath: "/etc/tsconfig",
|
||||
})
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH",
|
||||
Value: "/etc/tsconfig/tailscaled",
|
||||
})
|
||||
}
|
||||
|
||||
if a.tsFirewallMode != "" {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
Name: "TS_DEBUG_FIREWALL_MODE",
|
||||
Value: a.tsFirewallMode,
|
||||
})
|
||||
}
|
||||
ss.Spec.Template.Spec.PriorityClassName = a.proxyPriorityClassName
|
||||
|
||||
// Ingress/egress proxy configuration options.
|
||||
if sts.ClusterTargetIP != "" {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
Name: "TS_DEST_IP",
|
||||
Value: sts.ClusterTargetIP,
|
||||
})
|
||||
mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetClusterIP, sts.ClusterTargetIP)
|
||||
} else if sts.TailnetTargetIP != "" {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
Name: "TS_TAILNET_TARGET_IP",
|
||||
Value: sts.TailnetTargetIP,
|
||||
})
|
||||
mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetTailnetTargetIP, sts.TailnetTargetIP)
|
||||
} else if sts.TailnetTargetFQDN != "" {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
Name: "TS_TAILNET_TARGET_FQDN",
|
||||
Value: sts.TailnetTargetFQDN,
|
||||
})
|
||||
mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetTailnetTargetFQDN, sts.TailnetTargetFQDN)
|
||||
} else if sts.ServeConfig != nil {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
Name: "TS_SERVE_CONFIG",
|
||||
@@ -407,7 +498,7 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
Name: "serve-config",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: authKeySecret,
|
||||
SecretName: proxySecret,
|
||||
Items: []corev1.KeyToPath{{
|
||||
Key: "serve-config",
|
||||
Path: "serve-config",
|
||||
@@ -416,49 +507,47 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
},
|
||||
})
|
||||
}
|
||||
if a.tsFirewallMode != "" {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
Name: "TS_DEBUG_FIREWALL_MODE",
|
||||
Value: a.tsFirewallMode,
|
||||
},
|
||||
)
|
||||
}
|
||||
ss.ObjectMeta = metav1.ObjectMeta{
|
||||
Name: headlessSvc.Name,
|
||||
Namespace: a.operatorNamespace,
|
||||
Labels: sts.ChildResourceLabels,
|
||||
}
|
||||
ss.Spec.ServiceName = headlessSvc.Name
|
||||
ss.Spec.Selector = &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": sts.ParentResourceUID,
|
||||
},
|
||||
}
|
||||
|
||||
// containerboot currently doesn't have a way to re-read the hostname/ip as
|
||||
// it is passed via an environment variable. So we need to restart the
|
||||
// container when the value changes. We do this by adding an annotation to
|
||||
// the pod template that contains the last value we set.
|
||||
ss.Spec.Template.Annotations = map[string]string{
|
||||
podAnnotationLastSetHostname: sts.Hostname,
|
||||
}
|
||||
if sts.ClusterTargetIP != "" {
|
||||
ss.Spec.Template.Annotations[podAnnotationLastSetClusterIP] = sts.ClusterTargetIP
|
||||
}
|
||||
if sts.TailnetTargetIP != "" {
|
||||
ss.Spec.Template.Annotations[podAnnotationLastSetTailnetTargetIP] = sts.TailnetTargetIP
|
||||
}
|
||||
if sts.TailnetTargetFQDN != "" {
|
||||
ss.Spec.Template.Annotations[podAnnotationLastSetTailnetTargetFQDN] = sts.TailnetTargetFQDN
|
||||
}
|
||||
ss.Spec.Template.Labels = map[string]string{
|
||||
"app": sts.ParentResourceUID,
|
||||
}
|
||||
ss.Spec.Template.Spec.PriorityClassName = a.proxyPriorityClassName
|
||||
logger.Debugf("reconciling statefulset %s/%s", ss.GetNamespace(), ss.GetName())
|
||||
return createOrUpdate(ctx, a.Client, a.operatorNamespace, &ss, func(s *appsv1.StatefulSet) { s.Spec = ss.Spec })
|
||||
}
|
||||
|
||||
// tailscaledConfig takes a proxy config, a newly generated auth key if
|
||||
// generated and a Secret with the previous proxy state and auth key and
|
||||
// produces returns tailscaled configuration and a hash of that configuration.
|
||||
func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *corev1.Secret) ([]byte, string, error) {
|
||||
conf := ipn.ConfigVAlpha{
|
||||
Version: "alpha0",
|
||||
AcceptDNS: "false",
|
||||
Locked: "false",
|
||||
Hostname: &stsC.Hostname,
|
||||
}
|
||||
if stsC.Connector != nil {
|
||||
routes, err := netutil.CalcAdvertiseRoutes(stsC.Connector.routes, stsC.Connector.isExitNode)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("error calculating routes: %w", err)
|
||||
}
|
||||
conf.AdvertiseRoutes = routes
|
||||
}
|
||||
if newAuthkey != "" {
|
||||
conf.AuthKey = &newAuthkey
|
||||
} else if oldSecret != nil && len(oldSecret.Data[tailscaledConfigKey]) > 0 { // write to StringData, read from Data as StringData is write-only
|
||||
origConf := &ipn.ConfigVAlpha{}
|
||||
if err := json.Unmarshal([]byte(oldSecret.Data[tailscaledConfigKey]), origConf); err != nil {
|
||||
return nil, "", fmt.Errorf("error unmarshaling previous tailscaled config: %w", err)
|
||||
}
|
||||
conf.AuthKey = origConf.AuthKey
|
||||
}
|
||||
confFileBytes, err := json.Marshal(conf)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("error marshaling tailscaled config : %w", err)
|
||||
}
|
||||
hash, err := hashBytes(confFileBytes)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("error calculating config hash: %w", err)
|
||||
}
|
||||
return confFileBytes, hash, nil
|
||||
}
|
||||
|
||||
// ptrObject is a type constraint for pointer types that implement
|
||||
// client.Object.
|
||||
type ptrObject[T any] interface {
|
||||
@@ -466,6 +555,24 @@ type ptrObject[T any] interface {
|
||||
*T
|
||||
}
|
||||
|
||||
// hashBytes produces a hash for the provided bytes that is the same across
|
||||
// different invocations of this code. We do not use the
|
||||
// tailscale.com/deephash.Hash here because that produces a different hash for
|
||||
// the same value in different tailscale builds. The hash we are producing here
|
||||
// is used to determine if the container running the Connector Tailscale node
|
||||
// needs to be restarted. The container does not need restarting when the only
|
||||
// thing that changed is operator version (the hash is also exposed to users via
|
||||
// an annotation and might be confusing if it changes without the config having
|
||||
// changed).
|
||||
func hashBytes(b []byte) (string, error) {
|
||||
h := sha256.New()
|
||||
_, err := h.Write(b)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error calculating hash: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// createOrUpdate adds obj to the k8s cluster, unless the object already exists,
|
||||
// in which case update is called to make changes to it. If update is nil, the
|
||||
// existing object is returned unmodified.
|
||||
@@ -569,3 +676,10 @@ func nameForService(svc *corev1.Service) (string, error) {
|
||||
func isValidFirewallMode(m string) bool {
|
||||
return m == "auto" || m == "nftables" || m == "iptables"
|
||||
}
|
||||
|
||||
// shouldDoTailscaledDeclarativeConfig determines whether the proxy instance
|
||||
// should be configured to run tailscaled only with a all config opts passed to
|
||||
// tailscaled.
|
||||
func shouldDoTailscaledDeclarativeConfig(stsC *tailscaleSTSConfig) bool {
|
||||
return stsC.Connector != nil
|
||||
}
|
||||
|
||||
411
cmd/k8s-operator/testutils_test.go
Normal file
411
cmd/k8s-operator/testutils_test.go
Normal file
@@ -0,0 +1,411 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
// confgOpts contains configuration options for creating cluster resources for
|
||||
// Tailscale proxies.
|
||||
type configOpts struct {
|
||||
stsName string
|
||||
secretName string
|
||||
hostname string
|
||||
namespace string
|
||||
parentType string
|
||||
priorityClassName string
|
||||
firewallMode string
|
||||
tailnetTargetIP string
|
||||
tailnetTargetFQDN string
|
||||
clusterTargetIP string
|
||||
subnetRoutes string
|
||||
isExitNode bool
|
||||
shouldUseDeclarativeConfig bool // tailscaled in proxy should be configured using config file
|
||||
confFileHash string
|
||||
}
|
||||
|
||||
func expectedSTS(opts configOpts) *appsv1.StatefulSet {
|
||||
tsContainer := corev1.Container{
|
||||
Name: "tailscale",
|
||||
Image: "tailscale/tailscale",
|
||||
Env: []corev1.EnvVar{
|
||||
{Name: "TS_USERSPACE", Value: "false"},
|
||||
{Name: "TS_AUTH_ONCE", Value: "true"},
|
||||
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
|
||||
},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Capabilities: &corev1.Capabilities{
|
||||
Add: []corev1.Capability{"NET_ADMIN"},
|
||||
},
|
||||
},
|
||||
ImagePullPolicy: "Always",
|
||||
}
|
||||
annots := make(map[string]string)
|
||||
var volumes []corev1.Volume
|
||||
if opts.shouldUseDeclarativeConfig {
|
||||
volumes = []corev1.Volume{
|
||||
{
|
||||
Name: "tailscaledconfig",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: opts.secretName,
|
||||
Items: []corev1.KeyToPath{
|
||||
{
|
||||
Key: "tailscaled",
|
||||
Path: "tailscaled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tsContainer.VolumeMounts = []corev1.VolumeMount{{
|
||||
Name: "tailscaledconfig",
|
||||
ReadOnly: true,
|
||||
MountPath: "/etc/tsconfig",
|
||||
}}
|
||||
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{
|
||||
Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH",
|
||||
Value: "/etc/tsconfig/tailscaled",
|
||||
})
|
||||
annots["tailscale.com/operator-last-set-config-file-hash"] = opts.confFileHash
|
||||
} else {
|
||||
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{Name: "TS_HOSTNAME", Value: opts.hostname})
|
||||
annots["tailscale.com/operator-last-set-hostname"] = opts.hostname
|
||||
}
|
||||
if opts.firewallMode != "" {
|
||||
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{
|
||||
Name: "TS_DEBUG_FIREWALL_MODE",
|
||||
Value: opts.firewallMode,
|
||||
})
|
||||
}
|
||||
if opts.tailnetTargetIP != "" {
|
||||
annots["tailscale.com/operator-last-set-ts-tailnet-target-ip"] = opts.tailnetTargetIP
|
||||
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{
|
||||
Name: "TS_TAILNET_TARGET_IP",
|
||||
Value: opts.tailnetTargetIP,
|
||||
})
|
||||
} else if opts.tailnetTargetFQDN != "" {
|
||||
annots["tailscale.com/operator-last-set-ts-tailnet-target-fqdn"] = opts.tailnetTargetFQDN
|
||||
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{
|
||||
Name: "TS_TAILNET_TARGET_FQDN",
|
||||
Value: opts.tailnetTargetFQDN,
|
||||
})
|
||||
|
||||
} else if opts.clusterTargetIP != "" {
|
||||
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{
|
||||
Name: "TS_DEST_IP",
|
||||
Value: opts.clusterTargetIP,
|
||||
})
|
||||
annots["tailscale.com/operator-last-set-cluster-ip"] = opts.clusterTargetIP
|
||||
}
|
||||
return &appsv1.StatefulSet{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "StatefulSet",
|
||||
APIVersion: "apps/v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: opts.stsName,
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-ns": opts.namespace,
|
||||
"tailscale.com/parent-resource-type": opts.parentType,
|
||||
},
|
||||
},
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
Replicas: ptr.To[int32](1),
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{"app": "1234-UID"},
|
||||
},
|
||||
ServiceName: opts.stsName,
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: annots,
|
||||
DeletionGracePeriodSeconds: ptr.To[int64](10),
|
||||
Labels: map[string]string{"app": "1234-UID"},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
ServiceAccountName: "proxies",
|
||||
PriorityClassName: opts.priorityClassName,
|
||||
InitContainers: []corev1.Container{
|
||||
{
|
||||
Name: "sysctler",
|
||||
Image: "tailscale/tailscale",
|
||||
Command: []string{"/bin/sh"},
|
||||
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Privileged: ptr.To(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
Containers: []corev1.Container{tsContainer},
|
||||
Volumes: volumes,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func expectedHeadlessService(name string) *corev1.Service {
|
||||
return &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
GenerateName: "ts-test-",
|
||||
Namespace: "operator-ns",
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-ns": "default",
|
||||
"tailscale.com/parent-resource-type": "svc",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{
|
||||
"app": "1234-UID",
|
||||
},
|
||||
ClusterIP: "None",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func expectedSecret(t *testing.T, opts configOpts) *corev1.Secret {
|
||||
t.Helper()
|
||||
labels := map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-type": opts.parentType,
|
||||
}
|
||||
s := &corev1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Secret",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: opts.secretName,
|
||||
Namespace: "operator-ns",
|
||||
},
|
||||
}
|
||||
if !opts.shouldUseDeclarativeConfig {
|
||||
mak.Set(&s.StringData, "authkey", "secret-authkey")
|
||||
labels["tailscale.com/parent-resource-ns"] = opts.namespace
|
||||
} else {
|
||||
conf := &ipn.ConfigVAlpha{
|
||||
Version: "alpha0",
|
||||
AcceptDNS: "false",
|
||||
Hostname: &opts.hostname,
|
||||
Locked: "false",
|
||||
AuthKey: ptr.To("secret-authkey"),
|
||||
}
|
||||
var routes []netip.Prefix
|
||||
if opts.subnetRoutes != "" || opts.isExitNode {
|
||||
r := opts.subnetRoutes
|
||||
if opts.isExitNode {
|
||||
r = "0.0.0.0/0,::/0," + r
|
||||
}
|
||||
for _, rr := range strings.Split(r, ",") {
|
||||
prefix, err := netip.ParsePrefix(rr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
routes = append(routes, prefix)
|
||||
}
|
||||
}
|
||||
conf.AdvertiseRoutes = routes
|
||||
b, err := json.Marshal(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("error marshalling tailscaled config")
|
||||
}
|
||||
mak.Set(&s.StringData, "tailscaled", string(b))
|
||||
labels["tailscale.com/parent-resource-ns"] = "" // Connector is cluster scoped
|
||||
}
|
||||
s.Labels = labels
|
||||
return s
|
||||
}
|
||||
|
||||
func findGenName(t *testing.T, client client.Client, ns, name, typ string) (full, noSuffix string) {
|
||||
t.Helper()
|
||||
labels := map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelParentName: name,
|
||||
LabelParentNamespace: ns,
|
||||
LabelParentType: typ,
|
||||
}
|
||||
s, err := getSingleObject[corev1.Secret](context.Background(), client, "operator-ns", labels)
|
||||
if err != nil {
|
||||
t.Fatalf("finding secret for %q: %v", name, err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatalf("no secret found for %q %s %+#v", name, ns, labels)
|
||||
}
|
||||
return s.GetName(), strings.TrimSuffix(s.GetName(), "-0")
|
||||
}
|
||||
|
||||
func mustCreate(t *testing.T, client client.Client, obj client.Object) {
|
||||
t.Helper()
|
||||
if err := client.Create(context.Background(), obj); err != nil {
|
||||
t.Fatalf("creating %q: %v", obj.GetName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustUpdate[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string, update func(O)) {
|
||||
t.Helper()
|
||||
obj := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
}, obj); err != nil {
|
||||
t.Fatalf("getting %q: %v", name, err)
|
||||
}
|
||||
update(obj)
|
||||
if err := client.Update(context.Background(), obj); err != nil {
|
||||
t.Fatalf("updating %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustUpdateStatus[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string, update func(O)) {
|
||||
t.Helper()
|
||||
obj := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
}, obj); err != nil {
|
||||
t.Fatalf("getting %q: %v", name, err)
|
||||
}
|
||||
update(obj)
|
||||
if err := client.Status().Update(context.Background(), obj); err != nil {
|
||||
t.Fatalf("updating %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func expectEqual[T any, O ptrObject[T]](t *testing.T, client client.Client, want O) {
|
||||
t.Helper()
|
||||
got := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
Name: want.GetName(),
|
||||
Namespace: want.GetNamespace(),
|
||||
}, got); err != nil {
|
||||
t.Fatalf("getting %q: %v", want.GetName(), err)
|
||||
}
|
||||
// The resource version changes eagerly whenever the operator does even a
|
||||
// no-op update. Asserting a specific value leads to overly brittle tests,
|
||||
// so just remove it from both got and want.
|
||||
got.SetResourceVersion("")
|
||||
want.SetResourceVersion("")
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Fatalf("unexpected object (-got +want):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func expectMissing[T any, O ptrObject[T]](t *testing.T, client client.Client, ns, name string) {
|
||||
t.Helper()
|
||||
obj := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
}, obj); !apierrors.IsNotFound(err) {
|
||||
t.Fatalf("object %s/%s unexpectedly present, wanted missing", ns, name)
|
||||
}
|
||||
}
|
||||
|
||||
func expectReconciled(t *testing.T, sr reconcile.Reconciler, ns, name string) {
|
||||
t.Helper()
|
||||
req := reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Namespace: ns,
|
||||
Name: name,
|
||||
},
|
||||
}
|
||||
res, err := sr.Reconcile(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile: unexpected error: %v", err)
|
||||
}
|
||||
if res.Requeue {
|
||||
t.Fatalf("unexpected immediate requeue")
|
||||
}
|
||||
if res.RequeueAfter != 0 {
|
||||
t.Fatalf("unexpected timed requeue (%v)", res.RequeueAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func expectRequeue(t *testing.T, sr reconcile.Reconciler, ns, name string) {
|
||||
t.Helper()
|
||||
req := reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
},
|
||||
}
|
||||
res, err := sr.Reconcile(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile: unexpected error: %v", err)
|
||||
}
|
||||
if res.RequeueAfter == 0 {
|
||||
t.Fatalf("expected timed requeue, got success")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTSClient struct {
|
||||
sync.Mutex
|
||||
keyRequests []tailscale.KeyCapabilities
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.keyRequests = append(c.keyRequests, caps)
|
||||
k := &tailscale.Key{
|
||||
ID: "key",
|
||||
Created: time.Now(),
|
||||
Capabilities: caps,
|
||||
}
|
||||
return "secret-authkey", k, nil
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) DeleteDevice(ctx context.Context, deviceID string) error {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.deleted = append(c.deleted, deviceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) KeyRequests() []tailscale.KeyCapabilities {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
return c.keyRequests
|
||||
}
|
||||
|
||||
func (c *fakeTSClient) Deleted() []string {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
return c.deleted
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
@@ -45,7 +46,6 @@ import (
|
||||
"github.com/go-json-experiment/json/jsontext"
|
||||
"tailscale.com/types/logid"
|
||||
"tailscale.com/types/netlogtype"
|
||||
"tailscale.com/util/cmpx"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
@@ -155,7 +155,7 @@ func printMessage(msg message) {
|
||||
slices.SortFunc(traffic, func(x, y netlogtype.ConnectionCounts) int {
|
||||
nx := x.TxPackets + x.TxBytes + x.RxPackets + x.RxBytes
|
||||
ny := y.TxPackets + y.TxBytes + y.RxPackets + y.RxBytes
|
||||
return cmpx.Compare(ny, nx)
|
||||
return cmp.Compare(ny, nx)
|
||||
})
|
||||
var sum netlogtype.Counts
|
||||
for _, cc := range traffic {
|
||||
|
||||
190
cmd/stund/depaware.txt
Normal file
190
cmd/stund/depaware.txt
Normal file
@@ -0,0 +1,190 @@
|
||||
tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depaware)
|
||||
|
||||
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
|
||||
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
|
||||
github.com/golang/protobuf/proto from github.com/matttproud/golang_protobuf_extensions/pbutil
|
||||
github.com/google/uuid from tailscale.com/tsweb
|
||||
github.com/matttproud/golang_protobuf_extensions/pbutil from github.com/prometheus/common/expfmt
|
||||
💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz
|
||||
github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus
|
||||
github.com/prometheus/client_model/go from github.com/prometheus/client_golang/prometheus+
|
||||
github.com/prometheus/common/expfmt from github.com/prometheus/client_golang/prometheus+
|
||||
github.com/prometheus/common/internal/bitbucket.org/ww/goautoneg from github.com/prometheus/common/expfmt
|
||||
github.com/prometheus/common/model from github.com/prometheus/client_golang/prometheus+
|
||||
LD github.com/prometheus/procfs from github.com/prometheus/client_golang/prometheus
|
||||
LD github.com/prometheus/procfs/internal/fs from github.com/prometheus/procfs
|
||||
LD github.com/prometheus/procfs/internal/util from github.com/prometheus/procfs
|
||||
💣 go4.org/mem from tailscale.com/metrics+
|
||||
go4.org/netipx from tailscale.com/net/tsaddr
|
||||
google.golang.org/protobuf/encoding/prototext from github.com/golang/protobuf/proto+
|
||||
google.golang.org/protobuf/encoding/protowire from github.com/golang/protobuf/proto+
|
||||
google.golang.org/protobuf/internal/descfmt from google.golang.org/protobuf/internal/filedesc
|
||||
google.golang.org/protobuf/internal/descopts from google.golang.org/protobuf/internal/filedesc+
|
||||
google.golang.org/protobuf/internal/detrand from google.golang.org/protobuf/internal/descfmt+
|
||||
google.golang.org/protobuf/internal/encoding/defval from google.golang.org/protobuf/internal/encoding/tag+
|
||||
google.golang.org/protobuf/internal/encoding/messageset from google.golang.org/protobuf/encoding/prototext+
|
||||
google.golang.org/protobuf/internal/encoding/tag from google.golang.org/protobuf/internal/impl
|
||||
google.golang.org/protobuf/internal/encoding/text from google.golang.org/protobuf/encoding/prototext+
|
||||
google.golang.org/protobuf/internal/errors from google.golang.org/protobuf/encoding/prototext+
|
||||
google.golang.org/protobuf/internal/filedesc from google.golang.org/protobuf/internal/encoding/tag+
|
||||
google.golang.org/protobuf/internal/filetype from google.golang.org/protobuf/runtime/protoimpl
|
||||
google.golang.org/protobuf/internal/flags from google.golang.org/protobuf/encoding/prototext+
|
||||
google.golang.org/protobuf/internal/genid from google.golang.org/protobuf/encoding/prototext+
|
||||
💣 google.golang.org/protobuf/internal/impl from google.golang.org/protobuf/internal/filetype+
|
||||
google.golang.org/protobuf/internal/order from google.golang.org/protobuf/encoding/prototext+
|
||||
google.golang.org/protobuf/internal/pragma from google.golang.org/protobuf/encoding/prototext+
|
||||
google.golang.org/protobuf/internal/set from google.golang.org/protobuf/encoding/prototext
|
||||
💣 google.golang.org/protobuf/internal/strs from google.golang.org/protobuf/encoding/prototext+
|
||||
google.golang.org/protobuf/internal/version from google.golang.org/protobuf/runtime/protoimpl
|
||||
google.golang.org/protobuf/proto from github.com/golang/protobuf/proto+
|
||||
google.golang.org/protobuf/reflect/protodesc from github.com/golang/protobuf/proto
|
||||
💣 google.golang.org/protobuf/reflect/protoreflect from github.com/golang/protobuf/proto+
|
||||
google.golang.org/protobuf/reflect/protoregistry from github.com/golang/protobuf/proto+
|
||||
google.golang.org/protobuf/runtime/protoiface from github.com/golang/protobuf/proto+
|
||||
google.golang.org/protobuf/runtime/protoimpl from github.com/golang/protobuf/proto+
|
||||
google.golang.org/protobuf/types/descriptorpb from google.golang.org/protobuf/reflect/protodesc
|
||||
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
|
||||
tailscale.com from tailscale.com/version
|
||||
tailscale.com/envknob from tailscale.com/tsweb+
|
||||
tailscale.com/metrics from tailscale.com/net/stunserver+
|
||||
tailscale.com/net/netaddr from tailscale.com/net/tsaddr
|
||||
tailscale.com/net/stun from tailscale.com/net/stunserver
|
||||
tailscale.com/net/stunserver from tailscale.com/cmd/stund
|
||||
tailscale.com/net/tsaddr from tailscale.com/tsweb
|
||||
tailscale.com/tailcfg from tailscale.com/version
|
||||
tailscale.com/tsweb from tailscale.com/cmd/stund
|
||||
tailscale.com/tsweb/promvarz from tailscale.com/tsweb
|
||||
tailscale.com/tsweb/varz from tailscale.com/tsweb+
|
||||
tailscale.com/types/dnstype from tailscale.com/tailcfg
|
||||
tailscale.com/types/ipproto from tailscale.com/tailcfg
|
||||
tailscale.com/types/key from tailscale.com/tailcfg
|
||||
tailscale.com/types/lazy from tailscale.com/version+
|
||||
tailscale.com/types/logger from tailscale.com/tsweb
|
||||
tailscale.com/types/opt from tailscale.com/envknob+
|
||||
tailscale.com/types/ptr from tailscale.com/tailcfg
|
||||
tailscale.com/types/structs from tailscale.com/tailcfg+
|
||||
tailscale.com/types/tkatype from tailscale.com/tailcfg+
|
||||
tailscale.com/types/views from tailscale.com/net/tsaddr+
|
||||
tailscale.com/util/cmpx from tailscale.com/tailcfg+
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/tailcfg
|
||||
tailscale.com/util/lineread from tailscale.com/version/distro
|
||||
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
|
||||
tailscale.com/util/slicesx from tailscale.com/tailcfg
|
||||
tailscale.com/util/vizerror from tailscale.com/tailcfg+
|
||||
tailscale.com/version from tailscale.com/envknob+
|
||||
tailscale.com/version/distro from tailscale.com/envknob
|
||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
|
||||
golang.org/x/crypto/chacha20poly1305 from crypto/tls
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/hkdf from crypto/tls
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/net/dns/dnsmessage from net
|
||||
golang.org/x/net/http/httpguts from net/http
|
||||
golang.org/x/net/http/httpproxy from net/http
|
||||
golang.org/x/net/http2/hpack from net/http
|
||||
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
|
||||
D golang.org/x/net/route from net
|
||||
golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+
|
||||
LD golang.org/x/sys/unix from github.com/prometheus/procfs+
|
||||
W golang.org/x/sys/windows from github.com/prometheus/client_golang/prometheus
|
||||
golang.org/x/text/secure/bidirule from golang.org/x/net/idna
|
||||
golang.org/x/text/transform from golang.org/x/text/secure/bidirule+
|
||||
golang.org/x/text/unicode/bidi from golang.org/x/net/idna+
|
||||
golang.org/x/text/unicode/norm from golang.org/x/net/idna
|
||||
bufio from compress/flate+
|
||||
bytes from bufio+
|
||||
cmp from slices
|
||||
compress/flate from compress/gzip
|
||||
compress/gzip from github.com/golang/protobuf/proto+
|
||||
container/list from crypto/tls+
|
||||
context from crypto/tls+
|
||||
crypto from crypto/ecdh+
|
||||
crypto/aes from crypto/ecdsa+
|
||||
crypto/cipher from crypto/aes+
|
||||
crypto/des from crypto/tls+
|
||||
crypto/dsa from crypto/x509
|
||||
crypto/ecdh from crypto/ecdsa+
|
||||
crypto/ecdsa from crypto/tls+
|
||||
crypto/ed25519 from crypto/tls+
|
||||
crypto/elliptic from crypto/ecdsa+
|
||||
crypto/hmac from crypto/tls+
|
||||
crypto/md5 from crypto/tls+
|
||||
crypto/rand from crypto/ed25519+
|
||||
crypto/rc4 from crypto/tls
|
||||
crypto/rsa from crypto/tls+
|
||||
crypto/sha1 from crypto/tls+
|
||||
crypto/sha256 from crypto/tls+
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/aes+
|
||||
crypto/tls from net/http+
|
||||
crypto/x509 from crypto/tls
|
||||
crypto/x509/pkix from crypto/x509
|
||||
database/sql/driver from github.com/google/uuid
|
||||
embed from crypto/internal/nistec+
|
||||
encoding from encoding/json+
|
||||
encoding/asn1 from crypto/x509+
|
||||
encoding/base64 from encoding/json+
|
||||
encoding/binary from compress/gzip+
|
||||
encoding/hex from crypto/x509+
|
||||
encoding/json from expvar+
|
||||
encoding/pem from crypto/tls+
|
||||
errors from bufio+
|
||||
expvar from github.com/prometheus/client_golang/prometheus+
|
||||
flag from tailscale.com/cmd/stund
|
||||
fmt from compress/flate+
|
||||
go/token from google.golang.org/protobuf/internal/strs
|
||||
hash from crypto+
|
||||
hash/crc32 from compress/gzip+
|
||||
hash/fnv from google.golang.org/protobuf/internal/detrand
|
||||
hash/maphash from go4.org/mem
|
||||
html from net/http/pprof+
|
||||
io from bufio+
|
||||
io/fs from crypto/x509+
|
||||
io/ioutil from github.com/golang/protobuf/proto+
|
||||
log from expvar+
|
||||
log/internal from log
|
||||
maps from tailscale.com/tailcfg+
|
||||
math from compress/flate+
|
||||
math/big from crypto/dsa+
|
||||
math/bits from compress/flate+
|
||||
math/rand from math/big+
|
||||
mime from github.com/prometheus/common/expfmt+
|
||||
mime/multipart from net/http
|
||||
mime/quotedprintable from mime/multipart
|
||||
net from crypto/tls+
|
||||
net/http from expvar+
|
||||
net/http/httptrace from net/http
|
||||
net/http/internal from net/http
|
||||
net/http/pprof from tailscale.com/tsweb+
|
||||
net/netip from go4.org/netipx+
|
||||
net/textproto from golang.org/x/net/http/httpguts+
|
||||
net/url from crypto/x509+
|
||||
os from crypto/rand+
|
||||
os/signal from tailscale.com/cmd/stund
|
||||
path from github.com/prometheus/client_golang/prometheus/internal+
|
||||
path/filepath from crypto/x509+
|
||||
reflect from crypto/x509+
|
||||
regexp from github.com/prometheus/client_golang/prometheus/internal+
|
||||
regexp/syntax from regexp
|
||||
runtime/debug from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/metrics from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/pprof from net/http/pprof
|
||||
runtime/trace from net/http/pprof
|
||||
slices from tailscale.com/metrics+
|
||||
sort from compress/flate+
|
||||
strconv from compress/flate+
|
||||
strings from bufio+
|
||||
sync from compress/flate+
|
||||
sync/atomic from context+
|
||||
syscall from crypto/rand+
|
||||
text/tabwriter from runtime/pprof
|
||||
time from compress/gzip+
|
||||
unicode from bytes+
|
||||
unicode/utf16 from crypto/x509+
|
||||
unicode/utf8 from bufio+
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user