Compare commits
155 Commits
andrew/net
...
enable-exi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18fe05fd47 | ||
|
|
f2be3f70cf | ||
|
|
03da762b69 | ||
|
|
1d460731bd | ||
|
|
7ae5f3a5ce | ||
|
|
1535d0feca | ||
|
|
f384742375 | ||
|
|
92ca770b8d | ||
|
|
27038ee3c2 | ||
|
|
ec87e219ae | ||
|
|
e2586bc674 | ||
|
|
7558a1d594 | ||
|
|
e20ce7bf0c | ||
|
|
1d2af801fa | ||
|
|
e80b99cdd1 | ||
|
|
5aa4cfad06 | ||
|
|
e7599c1f7e | ||
|
|
5fb721d4ad | ||
|
|
af61179c2f | ||
|
|
b0941b79d6 | ||
|
|
354cac74a9 | ||
|
|
9401b09028 | ||
|
|
9b5176c4d9 | ||
|
|
9e2f58f846 | ||
|
|
b60c4664c7 | ||
|
|
3e6306a782 | ||
|
|
8f27520633 | ||
|
|
008676f76e | ||
|
|
66e4d843c1 | ||
|
|
bed818a978 | ||
|
|
0d8cd1645a | ||
|
|
eb42a16da9 | ||
|
|
5d41259a63 | ||
|
|
acb611f034 | ||
|
|
4cbef20569 | ||
|
|
55baf9474f | ||
|
|
90a4d6ce69 | ||
|
|
6d90966c1f | ||
|
|
06e22a96b1 | ||
|
|
b6dfd7443a | ||
|
|
8b8b315258 | ||
|
|
1e7050e73a | ||
|
|
a36cfb4d3d | ||
|
|
7b34154df2 | ||
|
|
4992aca6ec | ||
|
|
b104688e04 | ||
|
|
8c88853db6 | ||
|
|
f45594d2c9 | ||
|
|
e0f97738ee | ||
|
|
3f7313dbdb | ||
|
|
8444937c89 | ||
|
|
85febda86d | ||
|
|
d4bfe34ba7 | ||
|
|
6a860cfb35 | ||
|
|
5d1c72f76b | ||
|
|
512fc0b502 | ||
|
|
2f7e7be2ea | ||
|
|
067ed0bf6f | ||
|
|
20e9f3369d | ||
|
|
15c58cb77c | ||
|
|
e37eded256 | ||
|
|
221de01745 | ||
|
|
6da1dc84de | ||
|
|
e382e4cee6 | ||
|
|
6288c9b41e | ||
|
|
68d9e49a5b | ||
|
|
349799a1ba | ||
|
|
b0c3e6f6c5 | ||
|
|
7fe4cbbaf3 | ||
|
|
d2ccfa4edd | ||
|
|
4d747c1833 | ||
|
|
e0886ad167 | ||
|
|
da7c3d1753 | ||
|
|
08ebac9acb | ||
|
|
ea55f96310 | ||
|
|
cf8948da5f | ||
|
|
decd9893e4 | ||
|
|
48eef9e6eb | ||
|
|
da3cf12194 | ||
|
|
f12d2557f9 | ||
|
|
5018683d58 | ||
|
|
205a10b51a | ||
|
|
7429e8912a | ||
|
|
ad33e47270 | ||
|
|
04fceae898 | ||
|
|
055117ad45 | ||
|
|
43fba6e04d | ||
|
|
50a570a83f | ||
|
|
e496451928 | ||
|
|
6c160e6321 | ||
|
|
16ae0f65c0 | ||
|
|
f072d017bd | ||
|
|
54e52532eb | ||
|
|
74e33b9c50 | ||
|
|
c662bd9fe7 | ||
|
|
34176432d6 | ||
|
|
3047b6274c | ||
|
|
9884d06b80 | ||
|
|
62cf83eb92 | ||
|
|
8f27d519bb | ||
|
|
90c4067010 | ||
|
|
fd942b5384 | ||
|
|
6f66f5a75a | ||
|
|
0cb86468ca | ||
|
|
00373f07ac | ||
|
|
c58c59ee54 | ||
|
|
65255b060b | ||
|
|
d59878e457 | ||
|
|
797d75c50a | ||
|
|
6a4e5329c3 | ||
|
|
4338db28f7 | ||
|
|
65c3c690cf | ||
|
|
8780e33500 | ||
|
|
2fa20e3787 | ||
|
|
d610f8eec0 | ||
|
|
13853e7f29 | ||
|
|
dff6f3377f | ||
|
|
232a2d627c | ||
|
|
00554ad277 | ||
|
|
23fbf0003f | ||
|
|
097c5ed927 | ||
|
|
e324a5660f | ||
|
|
80f1cb6227 | ||
|
|
f18f591bc6 | ||
|
|
c7474431f1 | ||
|
|
b68a09cb34 | ||
|
|
2d5d6f5403 | ||
|
|
e83e2e881b | ||
|
|
69f4b4595a | ||
|
|
7e17aeb36b | ||
|
|
b4ff9a578f | ||
|
|
a8a525282c | ||
|
|
74b8985e19 | ||
|
|
3dd8ae2f26 | ||
|
|
a20e46a80f | ||
|
|
23e9447871 | ||
|
|
7912d76da0 | ||
|
|
c5abbcd4b4 | ||
|
|
352c1ac96c | ||
|
|
95dcc1745b | ||
|
|
303125d96d | ||
|
|
45d27fafd6 | ||
|
|
05acf76392 | ||
|
|
086ef19439 | ||
|
|
1cf85822d0 | ||
|
|
eb28818403 | ||
|
|
219efebad4 | ||
|
|
9a8c2f47f2 | ||
|
|
8cc5c51888 | ||
|
|
7ef1fb113d | ||
|
|
b42b9817b0 | ||
|
|
82c569a83a | ||
|
|
95f26565db | ||
|
|
9aa704a05d | ||
|
|
cd9cf93de6 |
64
.github/workflows/go-licenses.yml
vendored
64
.github/workflows/go-licenses.yml
vendored
@@ -1,64 +0,0 @@
|
||||
name: go-licenses
|
||||
|
||||
on:
|
||||
# run action when a change lands in the main branch which updates go.mod or
|
||||
# our license template file. Also allow manual triggering.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- go.mod
|
||||
- .github/licenses.tmpl
|
||||
- .github/workflows/go-licenses.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-licenses:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install go-licenses
|
||||
run: |
|
||||
go install github.com/google/go-licenses@v1.2.2-0.20220825154955-5eedde1c6584
|
||||
|
||||
- name: Run go-licenses
|
||||
env:
|
||||
# include all build tags to include platform-specific dependencies
|
||||
GOFLAGS: "-tags=android,cgo,darwin,freebsd,ios,js,linux,openbsd,wasm,windows"
|
||||
run: |
|
||||
[ -d licenses ] || mkdir licenses
|
||||
go-licenses report tailscale.com/cmd/tailscale tailscale.com/cmd/tailscaled > licenses/tailscale.md --template .github/licenses.tmpl
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 #v5.0.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: License Updater <noreply+license-updater@tailscale.com>
|
||||
committer: License Updater <noreply+license-updater@tailscale.com>
|
||||
branch: licenses/cli
|
||||
commit-message: "licenses: update tailscale{,d} licenses"
|
||||
title: "licenses: update tailscale{,d} licenses"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
team-reviewers: opensource-license-reviewers
|
||||
4
.github/workflows/kubemanifests.yaml
vendored
4
.github/workflows/kubemanifests.yaml
vendored
@@ -2,8 +2,8 @@ name: "Kubernetes manifests"
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- './cmd/k8s-operator/**'
|
||||
- './k8s-operator/**'
|
||||
- 'cmd/k8s-operator/**'
|
||||
- 'k8s-operator/**'
|
||||
- '.github/workflows/kubemanifests.yaml'
|
||||
|
||||
# Cancel workflow run if there is a newer push to the same PR for which it is
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -206,7 +206,7 @@ jobs:
|
||||
- name: Run VM tests
|
||||
run: ./tool/go test ./tstest/integration/vms -v -no-s3 -run-vm-tests -run=TestRunUbuntu2004
|
||||
env:
|
||||
HOME: "/tmp"
|
||||
HOME: "/var/lib/ghrunner/home"
|
||||
TMPDIR: "/tmp"
|
||||
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
|
||||
|
||||
|
||||
8
Makefile
8
Makefile
@@ -100,6 +100,14 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie
|
||||
@test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1)
|
||||
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh
|
||||
|
||||
publishdevnameserver: ## Build and publish k8s-nameserver image to location specified by ${REPO}
|
||||
@test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1)
|
||||
@test "${REPO}" != "tailscale/k8s-nameserver" || (echo "REPO=... must not be tailscale/k8s-nameserver" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/k8s-nameserver" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-nameserver" && exit 1)
|
||||
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-nameserver ./build_docker.sh
|
||||
|
||||
help: ## Show this help
|
||||
@echo "\nSpecify a command. The choices are:\n"
|
||||
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.61.0
|
||||
1.63.0
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
set -eu
|
||||
|
||||
# Use the "go" binary from the "tool" directory (which is github.com/tailscale/go)
|
||||
export PATH=$PWD/tool:$PATH
|
||||
export PATH="$PWD"/tool:"$PATH"
|
||||
|
||||
eval $(./build_dist.sh shellvars)
|
||||
eval "$(./build_dist.sh shellvars)"
|
||||
|
||||
DEFAULT_TARGET="client"
|
||||
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
|
||||
@@ -70,8 +70,24 @@ case "$TARGET" in
|
||||
--target="${PLATFORM}" \
|
||||
/usr/local/bin/operator
|
||||
;;
|
||||
k8s-nameserver)
|
||||
DEFAULT_REPOS="tailscale/k8s-nameserver"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="tailscale.com/cmd/k8s-nameserver:/usr/local/bin/k8s-nameserver" \
|
||||
--ldflags=" \
|
||||
-X tailscale.com/version.longStamp=${VERSION_LONG} \
|
||||
-X tailscale.com/version.shortStamp=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
/usr/local/bin/k8s-nameserver
|
||||
;;
|
||||
*)
|
||||
echo "unknown target: $TARGET"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
esac
|
||||
|
||||
@@ -270,6 +270,14 @@ type UserRuleMatch struct {
|
||||
Users []string `json:"users"`
|
||||
Ports []string `json:"ports"`
|
||||
LineNumber int `json:"lineNumber"`
|
||||
|
||||
// Postures is a list of posture policies that are
|
||||
// associated with this match. The rules can be looked
|
||||
// up in the ACLPreviewResponse parent struct.
|
||||
// The source of the list is from srcPosture on
|
||||
// an ACL or Grant rule:
|
||||
// https://tailscale.com/kb/1288/device-posture#posture-conditions
|
||||
Postures []string `json:"postures"`
|
||||
}
|
||||
|
||||
// ACLPreviewResponse is the response type of previewACLPostRequest
|
||||
@@ -277,6 +285,12 @@ type ACLPreviewResponse struct {
|
||||
Matches []UserRuleMatch `json:"matches"` // ACL rules that match the specified user or ipport.
|
||||
Type string `json:"type"` // The request type: currently only "user" or "ipport".
|
||||
PreviewFor string `json:"previewFor"` // A specific user or ipport.
|
||||
|
||||
// Postures is a map of postures and associated rules that apply
|
||||
// to this preview.
|
||||
// For more details about the posture mapping, see:
|
||||
// https://tailscale.com/kb/1288/device-posture#postures
|
||||
Postures map[string][]string `json:"postures,omitempty"`
|
||||
}
|
||||
|
||||
// ACLPreview is the response type of PreviewACLForUser, PreviewACLForIPPort, PreviewACLHuJSONForUser, and PreviewACLHuJSONForIPPort
|
||||
@@ -284,6 +298,12 @@ type ACLPreview struct {
|
||||
Matches []UserRuleMatch `json:"matches"`
|
||||
User string `json:"user,omitempty"` // Filled if response of PreviewACLForUser or PreviewACLHuJSONForUser
|
||||
IPPort string `json:"ipport,omitempty"` // Filled if response of PreviewACLForIPPort or PreviewACLHuJSONForIPPort
|
||||
|
||||
// Postures is a map of postures and associated rules that apply
|
||||
// to this preview.
|
||||
// For more details about the posture mapping, see:
|
||||
// https://tailscale.com/kb/1288/device-posture#postures
|
||||
Postures map[string][]string `json:"postures,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) {
|
||||
@@ -341,8 +361,9 @@ func (c *Client) PreviewACLForUser(ctx context.Context, acl ACL, user string) (r
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -369,8 +390,9 @@ func (c *Client) PreviewACLForIPPort(ctx context.Context, acl ACL, ipport netip.
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -394,8 +416,9 @@ func (c *Client) PreviewACLHuJSONForUser(ctx context.Context, acl ACLHuJSON, use
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -419,8 +442,9 @@ func (c *Client) PreviewACLHuJSONForIPPort(ctx context.Context, acl ACLHuJSON, i
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
Postures: b.Postures,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1426,10 +1426,10 @@ func (lc *LocalClient) TailFSSetFileServerAddr(ctx context.Context, addr string)
|
||||
return err
|
||||
}
|
||||
|
||||
// TailFSShareAdd adds the given share to the list of shares that TailFS will
|
||||
// serve to remote nodes. If a share with the same name already exists, the
|
||||
// existing share is replaced/updated.
|
||||
func (lc *LocalClient) TailFSShareAdd(ctx context.Context, share *tailfs.Share) error {
|
||||
// TailFSShareSet adds or updates the given share in the list of shares that
|
||||
// TailFS will serve to remote nodes. If a share with the same name already
|
||||
// exists, the existing share is replaced/updated.
|
||||
func (lc *LocalClient) TailFSShareSet(ctx context.Context, share *tailfs.Share) error {
|
||||
_, err := lc.send(ctx, "PUT", "/localapi/v0/tailfs/shares", http.StatusCreated, jsonBody(share))
|
||||
return err
|
||||
}
|
||||
@@ -1442,20 +1442,29 @@ func (lc *LocalClient) TailFSShareRemove(ctx context.Context, name string) error
|
||||
"DELETE",
|
||||
"/localapi/v0/tailfs/shares",
|
||||
http.StatusNoContent,
|
||||
jsonBody(&tailfs.Share{
|
||||
Name: name,
|
||||
}))
|
||||
strings.NewReader(name))
|
||||
return err
|
||||
}
|
||||
|
||||
// TailFSShareRename renames the share from old to new name.
|
||||
func (lc *LocalClient) TailFSShareRename(ctx context.Context, oldName, newName string) error {
|
||||
_, err := lc.send(
|
||||
ctx,
|
||||
"POST",
|
||||
"/localapi/v0/tailfs/shares",
|
||||
http.StatusNoContent,
|
||||
jsonBody([2]string{oldName, newName}))
|
||||
return err
|
||||
}
|
||||
|
||||
// TailFSShareList returns the list of shares that TailFS is currently serving
|
||||
// to remote nodes.
|
||||
func (lc *LocalClient) TailFSShareList(ctx context.Context) (map[string]*tailfs.Share, error) {
|
||||
func (lc *LocalClient) TailFSShareList(ctx context.Context) ([]*tailfs.Share, error) {
|
||||
result, err := lc.get200(ctx, "/localapi/v0/tailfs/shares")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var shares map[string]*tailfs.Share
|
||||
var shares []*tailfs.Share
|
||||
err = json.Unmarshal(result, &shares)
|
||||
return shares, err
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -234,7 +235,12 @@ func (s *Server) newSessionID() (string, error) {
|
||||
return "", errors.New("too many collisions generating new session; please refresh page")
|
||||
}
|
||||
|
||||
type peerCapabilities map[capFeature]bool // value is true if the peer can edit the given feature
|
||||
// peerCapabilities holds information about what a source
|
||||
// peer is allowed to edit via the web UI.
|
||||
//
|
||||
// map value is true if the peer can edit the given feature.
|
||||
// Only capFeatures included in validCaps will be included.
|
||||
type peerCapabilities map[capFeature]bool
|
||||
|
||||
// canEdit is true if the peerCapabilities grant edit access
|
||||
// to the given feature.
|
||||
@@ -248,21 +254,47 @@ func (p peerCapabilities) canEdit(feature capFeature) bool {
|
||||
return p[feature]
|
||||
}
|
||||
|
||||
// isEmpty is true if p is either nil or has no capabilities
|
||||
// with value true.
|
||||
func (p peerCapabilities) isEmpty() bool {
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
for _, v := range p {
|
||||
if v == true {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type capFeature string
|
||||
|
||||
const (
|
||||
// The following values should not be edited.
|
||||
// New caps can be added, but existing ones should not be changed,
|
||||
// as these exact values are used by users in tailnet policy files.
|
||||
//
|
||||
// IMPORTANT: When adding a new cap, also update validCaps slice below.
|
||||
|
||||
capFeatureAll capFeature = "*" // grants peer management of all features
|
||||
capFeatureFunnel capFeature = "funnel" // grants peer serve/funnel management
|
||||
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
|
||||
capFeatureSubnet capFeature = "subnet" // grants peer subnet routes management
|
||||
capFeatureExitNode capFeature = "exitnode" // grants peer ability to advertise-as and use exit nodes
|
||||
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
|
||||
capFeatureAll capFeature = "*" // grants peer management of all features
|
||||
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
|
||||
capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management
|
||||
capFeatureExitNodes capFeature = "exitnodes" // grants peer ability to advertise-as and use exit nodes
|
||||
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
|
||||
)
|
||||
|
||||
// validCaps contains the list of valid capabilities used in the web client.
|
||||
// Any capabilities included in a peer's grants that do not fall into this
|
||||
// list will be ignored.
|
||||
var validCaps []capFeature = []capFeature{
|
||||
capFeatureAll,
|
||||
capFeatureSSH,
|
||||
capFeatureSubnets,
|
||||
capFeatureExitNodes,
|
||||
capFeatureAccount,
|
||||
}
|
||||
|
||||
type capRule struct {
|
||||
CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
|
||||
}
|
||||
@@ -270,7 +302,13 @@ type capRule struct {
|
||||
// toPeerCapabilities parses out the web ui capabilities from the
|
||||
// given whois response.
|
||||
func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) {
|
||||
if whois == nil {
|
||||
if whois == nil || status == nil {
|
||||
return peerCapabilities{}, nil
|
||||
}
|
||||
if whois.Node.IsTagged() {
|
||||
// We don't allow management *from* tagged nodes, so ignore caps.
|
||||
// The web client auth flow relies on having a true user identity
|
||||
// that can be verified through login.
|
||||
return peerCapabilities{}, nil
|
||||
}
|
||||
|
||||
@@ -291,7 +329,10 @@ func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (
|
||||
}
|
||||
for _, c := range rules {
|
||||
for _, f := range c.CanEdit {
|
||||
caps[capFeature(strings.ToLower(f))] = true
|
||||
cap := capFeature(strings.ToLower(f))
|
||||
if slices.Contains(validCaps, cap) {
|
||||
caps[cap] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return caps, nil
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"node": "18.16.1",
|
||||
"yarn": "1.22.19"
|
||||
},
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
@@ -32,12 +33,16 @@
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^4.7.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
"vitest": "^1.3.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
"@typescript-eslint/parser": "^6.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"start": "vite",
|
||||
|
||||
@@ -11,8 +11,8 @@ import LoginView from "src/components/views/login-view"
|
||||
import SSHView from "src/components/views/ssh-view"
|
||||
import SubnetRouterView from "src/components/views/subnet-router-view"
|
||||
import { UpdatingView } from "src/components/views/updating-view"
|
||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||
import { Feature, featureDescription, NodeData } from "src/types"
|
||||
import useAuth, { AuthResponse, canEdit } from "src/hooks/auth"
|
||||
import { Feature, NodeData, featureDescription } from "src/types"
|
||||
import Card from "src/ui/card"
|
||||
import EmptyState from "src/ui/empty-state"
|
||||
import LoadingDots from "src/ui/loading-dots"
|
||||
@@ -56,16 +56,19 @@ function WebClient({
|
||||
<Header node={node} auth={auth} newSession={newSession} />
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<HomeView readonly={!auth.canManageNode} node={node} />
|
||||
<HomeView node={node} auth={auth} />
|
||||
</Route>
|
||||
<Route path="/details">
|
||||
<DeviceDetailsView readonly={!auth.canManageNode} node={node} />
|
||||
<DeviceDetailsView node={node} auth={auth} />
|
||||
</Route>
|
||||
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
|
||||
<SubnetRouterView readonly={!auth.canManageNode} node={node} />
|
||||
<SubnetRouterView
|
||||
readonly={!canEdit("subnets", auth)}
|
||||
node={node}
|
||||
/>
|
||||
</FeatureRoute>
|
||||
<FeatureRoute path="/ssh" feature="ssh" node={node}>
|
||||
<SSHView readonly={!auth.canManageNode} node={node} />
|
||||
<SSHView readonly={!canEdit("ssh", auth)} node={node} />
|
||||
</FeatureRoute>
|
||||
{/* <Route path="/serve">Share local content</Route> */}
|
||||
<FeatureRoute path="/update" feature="auto-update" node={node}>
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import cx from "classnames"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
|
||||
import Eye from "src/assets/icons/eye.svg?react"
|
||||
import User from "src/assets/icons/user.svg?react"
|
||||
import { AuthResponse, AuthType } from "src/hooks/auth"
|
||||
import { AuthResponse, hasAnyEditCapabilities } from "src/hooks/auth"
|
||||
import { useTSWebConnected } from "src/hooks/ts-web-connected"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Popover from "src/ui/popover"
|
||||
import ProfilePic from "src/ui/profile-pic"
|
||||
import { assertNever, isHTTPS } from "src/utils/util"
|
||||
|
||||
export default function LoginToggle({
|
||||
node,
|
||||
@@ -22,12 +24,29 @@ export default function LoginToggle({
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
const { tsWebConnected, checkTSWebConnection } = useTSWebConnected(
|
||||
auth.serverMode,
|
||||
node.IPv4
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
className="p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]"
|
||||
className="p-3 bg-white rounded-lg shadow flex flex-col max-w-[317px]"
|
||||
content={
|
||||
<LoginPopoverContent node={node} auth={auth} newSession={newSession} />
|
||||
auth.serverMode === "readonly" ? (
|
||||
<ReadonlyModeContent auth={auth} />
|
||||
) : auth.serverMode === "login" ? (
|
||||
<LoginModeContent
|
||||
auth={auth}
|
||||
node={node}
|
||||
tsWebConnected={tsWebConnected}
|
||||
checkTSWebConnection={checkTSWebConnection}
|
||||
/>
|
||||
) : auth.serverMode === "manage" ? (
|
||||
<ManageModeContent auth={auth} node={node} newSession={newSession} />
|
||||
) : (
|
||||
assertNever(auth.serverMode)
|
||||
)
|
||||
}
|
||||
side="bottom"
|
||||
align="end"
|
||||
@@ -35,228 +54,303 @@ export default function LoginToggle({
|
||||
onOpenChange={setOpen}
|
||||
asChild
|
||||
>
|
||||
{!auth.canManageNode ? (
|
||||
<button
|
||||
className={cx(
|
||||
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
|
||||
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Eye />
|
||||
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
|
||||
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
|
||||
{auth.viewerIdentity && (
|
||||
<ProfilePic
|
||||
className="ml-2"
|
||||
size="medium"
|
||||
url={auth.viewerIdentity.profilePicUrl}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={cx(
|
||||
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
|
||||
{
|
||||
"bg-transparent": !open,
|
||||
"bg-gray-300": open,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button onClick={() => setOpen(!open)}>
|
||||
<ProfilePic
|
||||
size="medium"
|
||||
url={auth.viewerIdentity?.profilePicUrl}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{auth.authorized ? (
|
||||
<TriggerWhenManaging auth={auth} open={open} setOpen={setOpen} />
|
||||
) : (
|
||||
<TriggerWhenReading auth={auth} open={open} setOpen={setOpen} />
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginPopoverContent({
|
||||
/**
|
||||
* TriggerWhenManaging is displayed as the trigger for the login popover
|
||||
* when the user has an active authorized managment session.
|
||||
*/
|
||||
function TriggerWhenManaging({
|
||||
auth,
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
auth: AuthResponse
|
||||
open: boolean
|
||||
setOpen: (next: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
|
||||
{
|
||||
"bg-transparent": !open,
|
||||
"bg-gray-300": open,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button onClick={() => setOpen(!open)}>
|
||||
<ProfilePic size="medium" url={auth.viewerIdentity?.profilePicUrl} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TriggerWhenReading is displayed as the trigger for the login popover
|
||||
* when the user is currently in read mode (doesn't have an authorized
|
||||
* management session).
|
||||
*/
|
||||
function TriggerWhenReading({
|
||||
auth,
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
auth: AuthResponse
|
||||
open: boolean
|
||||
setOpen: (next: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cx(
|
||||
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
|
||||
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
|
||||
)}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Eye />
|
||||
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
|
||||
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
|
||||
{auth.viewerIdentity && (
|
||||
<ProfilePic
|
||||
className="ml-2"
|
||||
size="medium"
|
||||
url={auth.viewerIdentity.profilePicUrl}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PopoverContentHeader is the header for the login popover.
|
||||
*/
|
||||
function PopoverContentHeader({ auth }: { auth: AuthResponse }) {
|
||||
return (
|
||||
<div className="text-black text-sm font-medium leading-tight mb-1">
|
||||
{auth.authorized ? "Managing" : "Viewing"}
|
||||
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PopoverContentFooter is the footer for the login popover.
|
||||
*/
|
||||
function PopoverContentFooter({ auth }: { auth: AuthResponse }) {
|
||||
return auth.viewerIdentity ? (
|
||||
<>
|
||||
<hr className="my-2" />
|
||||
<div className="flex items-center">
|
||||
<User className="flex-shrink-0" />
|
||||
<p className="text-gray-500 text-xs ml-2">
|
||||
We recognize you because you are accessing this page from{" "}
|
||||
<span className="font-medium">
|
||||
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadonlyModeContent is the body of the login popover when the web
|
||||
* client is being run in "readonly" server mode.
|
||||
*/
|
||||
function ReadonlyModeContent({ auth }: { auth: AuthResponse }) {
|
||||
return (
|
||||
<>
|
||||
<PopoverContentHeader auth={auth} />
|
||||
<p className="text-gray-500 text-xs">
|
||||
This web interface is running in read-only mode.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/s/web-client-read-only"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
<PopoverContentFooter auth={auth} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LoginModeContent is the body of the login popover when the web
|
||||
* client is being run in "login" server mode.
|
||||
*/
|
||||
function LoginModeContent({
|
||||
node,
|
||||
auth,
|
||||
tsWebConnected,
|
||||
checkTSWebConnection,
|
||||
}: {
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
tsWebConnected: boolean
|
||||
checkTSWebConnection: () => void
|
||||
}) {
|
||||
const https = isHTTPS()
|
||||
// We can't run the ts web connection test when the webpage is loaded
|
||||
// over HTTPS. So in this case, we default to presenting a login button
|
||||
// with some helper text reminding the user to check their connection
|
||||
// themselves.
|
||||
const hasACLAccess = https || tsWebConnected
|
||||
|
||||
const hasEditCaps = useMemo(() => {
|
||||
if (!auth.viewerIdentity) {
|
||||
// If not connected to login client over tailscale, we won't know the viewer's
|
||||
// identity. So we must assume they may be able to edit something and have the
|
||||
// management client handle permissions once the user gets there.
|
||||
return true
|
||||
}
|
||||
return hasAnyEditCapabilities(auth)
|
||||
}, [auth])
|
||||
|
||||
const handleLogin = useCallback(() => {
|
||||
// Must be connected over Tailscale to log in.
|
||||
// Send user to Tailscale IP and start check mode
|
||||
const manageURL = `http://${node.IPv4}:5252/?check=now`
|
||||
if (window.self !== window.top) {
|
||||
// If we're inside an iframe, open management client in new window.
|
||||
window.open(manageURL, "_blank")
|
||||
} else {
|
||||
window.location.href = manageURL
|
||||
}
|
||||
}, [node.IPv4])
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={
|
||||
hasEditCaps && !hasACLAccess ? checkTSWebConnection : undefined
|
||||
}
|
||||
>
|
||||
<PopoverContentHeader auth={auth} />
|
||||
{!hasACLAccess || !hasEditCaps ? (
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
{!hasEditCaps ? (
|
||||
// ACLs allow access, but user isn't allowed to edit any features,
|
||||
// restricted to readonly. No point in sending them over to the
|
||||
// tailscaleIP:5252 address.
|
||||
<>
|
||||
You don’t have permission to make changes to this device, but
|
||||
you can view most of its details.
|
||||
</>
|
||||
) : !node.ACLAllowsAnyIncomingTraffic ? (
|
||||
// Tailnet ACLs don't allow access to anyone.
|
||||
<>
|
||||
The current tailnet policy file does not allow connecting to
|
||||
this device.
|
||||
</>
|
||||
) : (
|
||||
// ACLs don't allow access to this user specifically.
|
||||
<>
|
||||
Cannot access this device’s Tailscale IP. Make sure you are
|
||||
connected to your tailnet, and that your policy file allows
|
||||
access.
|
||||
</>
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://tailscale.com/s/web-client-access"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
// User can connect to Tailcale IP; sign in when ready.
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
You can see most of this device’s details. To make changes, you need
|
||||
to sign in.
|
||||
</p>
|
||||
{https && (
|
||||
// we don't know if the user can connect over TS, so
|
||||
// provide extra tips in case they have trouble.
|
||||
<p className="text-gray-500 text-xs font-semibold pt-2">
|
||||
Make sure you are connected to your tailnet, and that your policy
|
||||
file allows access.
|
||||
</p>
|
||||
)}
|
||||
<SignInButton auth={auth} onClick={handleLogin} />
|
||||
</>
|
||||
)}
|
||||
<PopoverContentFooter auth={auth} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ManageModeContent is the body of the login popover when the web
|
||||
* client is being run in "manage" server mode.
|
||||
*/
|
||||
function ManageModeContent({
|
||||
auth,
|
||||
newSession,
|
||||
}: {
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
newSession: () => void
|
||||
}) {
|
||||
/**
|
||||
* canConnectOverTS indicates whether the current viewer
|
||||
* is able to hit the node's web client that's being served
|
||||
* at http://${node.IP}:5252. If false, this means that the
|
||||
* viewer must connect to the correct tailnet before being
|
||||
* able to sign in.
|
||||
*/
|
||||
const [canConnectOverTS, setCanConnectOverTS] = useState<boolean>(false)
|
||||
const [isRunningCheck, setIsRunningCheck] = useState<boolean>(false)
|
||||
|
||||
// Whether the current page is loaded over HTTPS.
|
||||
// If it is, then the connectivity check to the management client
|
||||
// will fail with a mixed-content error.
|
||||
const isHTTPS = window.location.protocol === "https:"
|
||||
|
||||
const checkTSConnection = useCallback(() => {
|
||||
if (auth.viewerIdentity || isHTTPS) {
|
||||
// Skip the connectivity check if we either already know we're connected over Tailscale,
|
||||
// or know the connectivity check will fail because the current page is loaded over HTTPS.
|
||||
setCanConnectOverTS(true)
|
||||
return
|
||||
}
|
||||
// Otherwise, test connection to the ts IP.
|
||||
if (isRunningCheck) {
|
||||
return // already checking
|
||||
}
|
||||
setIsRunningCheck(true)
|
||||
fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" })
|
||||
.then(() => {
|
||||
setCanConnectOverTS(true)
|
||||
setIsRunningCheck(false)
|
||||
})
|
||||
.catch(() => setIsRunningCheck(false))
|
||||
}, [auth.viewerIdentity, isRunningCheck, node.IPv4, isHTTPS])
|
||||
|
||||
/**
|
||||
* Checking connection for first time on page load.
|
||||
*
|
||||
* While not connected, we check again whenever the mouse
|
||||
* enters the popover component, to pick up on the user
|
||||
* leaving to turn on Tailscale then returning to the view.
|
||||
* See `onMouseEnter` on the div below.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => checkTSConnection(), [])
|
||||
|
||||
const handleSignInClick = useCallback(() => {
|
||||
if (auth.viewerIdentity && auth.serverMode === "manage") {
|
||||
if (window.self !== window.top) {
|
||||
// if we're inside an iframe, start session in new window
|
||||
let url = new URL(window.location.href)
|
||||
url.searchParams.set("check", "now")
|
||||
window.open(url, "_blank")
|
||||
} else {
|
||||
newSession()
|
||||
}
|
||||
const handleLogin = useCallback(() => {
|
||||
if (window.self !== window.top) {
|
||||
// If we're inside an iframe, start session in new window.
|
||||
let url = new URL(window.location.href)
|
||||
url.searchParams.set("check", "now")
|
||||
window.open(url, "_blank")
|
||||
} else {
|
||||
// Must be connected over Tailscale to log in.
|
||||
// Send user to Tailscale IP and start check mode
|
||||
const manageURL = `http://${node.IPv4}:5252/?check=now`
|
||||
if (window.self !== window.top) {
|
||||
// if we're inside an iframe, open management client in new window
|
||||
window.open(manageURL, "_blank")
|
||||
} else {
|
||||
window.location.href = manageURL
|
||||
}
|
||||
newSession()
|
||||
}
|
||||
}, [auth.viewerIdentity, auth.serverMode, newSession, node.IPv4])
|
||||
}, [newSession])
|
||||
|
||||
const hasAnyPermissions = useMemo(() => hasAnyEditCapabilities(auth), [auth])
|
||||
|
||||
return (
|
||||
<div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>
|
||||
<div className="text-black text-sm font-medium leading-tight mb-1">
|
||||
{!auth.canManageNode ? "Viewing" : "Managing"}
|
||||
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
|
||||
</div>
|
||||
{!auth.canManageNode && (
|
||||
<>
|
||||
{auth.serverMode === "readonly" ? (
|
||||
<>
|
||||
<PopoverContentHeader auth={auth} />
|
||||
{!auth.authorized &&
|
||||
(hasAnyPermissions ? (
|
||||
// User is connected over Tailscale, but needs to complete check mode.
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
This web interface is running in read-only mode.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/s/web-client-read-only"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
To make changes, sign in to confirm your identity. This extra step
|
||||
helps us keep your device secure.
|
||||
</p>
|
||||
) : !auth.viewerIdentity ? (
|
||||
// User is not connected over Tailscale.
|
||||
// These states are only possible on the login client.
|
||||
<>
|
||||
{!canConnectOverTS ? (
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
{!node.ACLAllowsAnyIncomingTraffic ? (
|
||||
// Tailnet ACLs don't allow access.
|
||||
<>
|
||||
The current tailnet policy file does not allow
|
||||
connecting to this device.
|
||||
</>
|
||||
) : (
|
||||
// ACLs allow access, but user can't connect.
|
||||
<>
|
||||
Cannot access this device’s Tailscale IP. Make sure you
|
||||
are connected to your tailnet, and that your policy file
|
||||
allows access.
|
||||
</>
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://tailscale.com/s/web-client-connection"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
// User can connect to Tailcale IP; sign in when ready.
|
||||
<>
|
||||
<p className="text-gray-500 text-xs">
|
||||
You can see most of this device’s details. To make changes,
|
||||
you need to sign in.
|
||||
</p>
|
||||
{isHTTPS && (
|
||||
// we don't know if the user can connect over TS, so
|
||||
// provide extra tips in case they have trouble.
|
||||
<p className="text-gray-500 text-xs font-semibold pt-2">
|
||||
Make sure you are connected to your tailnet, and that your
|
||||
policy file allows access.
|
||||
</p>
|
||||
)}
|
||||
<SignInButton auth={auth} onClick={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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{auth.viewerIdentity && (
|
||||
<>
|
||||
<hr className="my-2" />
|
||||
<div className="flex items-center">
|
||||
<User className="flex-shrink-0" />
|
||||
<p className="text-gray-500 text-xs ml-2">
|
||||
We recognize you because you are accessing this page from{" "}
|
||||
<span className="font-medium">
|
||||
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SignInButton auth={auth} onClick={handleLogin} />
|
||||
</>
|
||||
) : (
|
||||
// User is connected over tailscale, but doesn't have permission to manage.
|
||||
<p className="text-gray-500 text-xs">
|
||||
You don’t have permission to make changes to this device, but you
|
||||
can view most of its details.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/s/web-client-access"
|
||||
className="text-blue-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
))}
|
||||
<PopoverContentFooter auth={auth} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import ACLTag from "src/components/acl-tag"
|
||||
import * as Control from "src/components/control-components"
|
||||
import NiceIP from "src/components/nice-ip"
|
||||
import { UpdateAvailableNotification } from "src/components/update-available"
|
||||
import { AuthResponse, canEdit } from "src/hooks/auth"
|
||||
import { NodeData } from "src/types"
|
||||
import Button from "src/ui/button"
|
||||
import Card from "src/ui/card"
|
||||
@@ -16,11 +17,11 @@ import QuickCopy from "src/ui/quick-copy"
|
||||
import { useLocation } from "wouter"
|
||||
|
||||
export default function DeviceDetailsView({
|
||||
readonly,
|
||||
node,
|
||||
auth,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -37,11 +38,11 @@ export default function DeviceDetailsView({
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{!readonly && <DisconnectDialog />}
|
||||
{canEdit("account", auth) && <DisconnectDialog />}
|
||||
</div>
|
||||
</Card>
|
||||
{node.Features["auto-update"] &&
|
||||
!readonly &&
|
||||
canEdit("account", auth) &&
|
||||
node.ClientVersion &&
|
||||
!node.ClientVersion.RunningLatest && (
|
||||
<UpdateAvailableNotification details={node.ClientVersion} />
|
||||
|
||||
@@ -8,17 +8,18 @@ import ArrowRight from "src/assets/icons/arrow-right.svg?react"
|
||||
import Machine from "src/assets/icons/machine.svg?react"
|
||||
import AddressCard from "src/components/address-copy-card"
|
||||
import ExitNodeSelector from "src/components/exit-node-selector"
|
||||
import { AuthResponse, canEdit } from "src/hooks/auth"
|
||||
import { NodeData } from "src/types"
|
||||
import Card from "src/ui/card"
|
||||
import { pluralize } from "src/utils/util"
|
||||
import { Link, useLocation } from "wouter"
|
||||
|
||||
export default function HomeView({
|
||||
readonly,
|
||||
node,
|
||||
auth,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
auth: AuthResponse
|
||||
}) {
|
||||
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
|
||||
() => [
|
||||
@@ -63,7 +64,11 @@ export default function HomeView({
|
||||
</div>
|
||||
{(node.Features["advertise-exit-node"] ||
|
||||
node.Features["use-exit-node"]) && (
|
||||
<ExitNodeSelector className="mb-5" node={node} disabled={readonly} />
|
||||
<ExitNodeSelector
|
||||
className="mb-5"
|
||||
node={node}
|
||||
disabled={!canEdit("exitnodes", auth)}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
className="link font-medium"
|
||||
|
||||
@@ -4,25 +4,50 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch, setSynoToken } from "src/api"
|
||||
|
||||
export enum AuthType {
|
||||
synology = "synology",
|
||||
tailscale = "tailscale",
|
||||
}
|
||||
|
||||
export type AuthResponse = {
|
||||
authNeeded?: AuthType
|
||||
canManageNode: boolean
|
||||
serverMode: "login" | "readonly" | "manage"
|
||||
serverMode: AuthServerMode
|
||||
authorized: boolean
|
||||
viewerIdentity?: {
|
||||
loginName: string
|
||||
nodeName: string
|
||||
nodeIP: string
|
||||
profilePicUrl?: string
|
||||
capabilities: { [key in PeerCapability]: boolean }
|
||||
}
|
||||
needsSynoAuth?: boolean
|
||||
}
|
||||
|
||||
// useAuth reports and refreshes Tailscale auth status
|
||||
// for the web client.
|
||||
export type AuthServerMode = "login" | "readonly" | "manage"
|
||||
|
||||
export type PeerCapability = "*" | "ssh" | "subnets" | "exitnodes" | "account"
|
||||
|
||||
/**
|
||||
* canEdit reports whether the given auth response specifies that the viewer
|
||||
* has the ability to edit the given capability.
|
||||
*/
|
||||
export function canEdit(cap: PeerCapability, auth: AuthResponse): boolean {
|
||||
if (!auth.authorized || !auth.viewerIdentity) {
|
||||
return false
|
||||
}
|
||||
if (auth.viewerIdentity.capabilities["*"] === true) {
|
||||
return true // can edit all features
|
||||
}
|
||||
return auth.viewerIdentity.capabilities[cap] === true
|
||||
}
|
||||
|
||||
/**
|
||||
* hasAnyEditCapabilities reports whether the given auth response specifies
|
||||
* that the viewer has at least one edit capability. If this is true, the
|
||||
* user is able to go through the auth flow to authenticate a management
|
||||
* session.
|
||||
*/
|
||||
export function hasAnyEditCapabilities(auth: AuthResponse): boolean {
|
||||
return Object.values(auth.viewerIdentity?.capabilities || {}).includes(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* useAuth reports and refreshes Tailscale auth status for the web client.
|
||||
*/
|
||||
export default function useAuth() {
|
||||
const [data, setData] = useState<AuthResponse>()
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
@@ -33,18 +58,16 @@ export default function useAuth() {
|
||||
return apiFetch<AuthResponse>("/auth", "GET")
|
||||
.then((d) => {
|
||||
setData(d)
|
||||
switch (d.authNeeded) {
|
||||
case AuthType.synology:
|
||||
fetch("/webman/login.cgi")
|
||||
.then((r) => r.json())
|
||||
.then((a) => {
|
||||
setSynoToken(a.SynoToken)
|
||||
setRanSynoAuth(true)
|
||||
setLoading(false)
|
||||
})
|
||||
break
|
||||
default:
|
||||
setLoading(false)
|
||||
if (d.needsSynoAuth) {
|
||||
fetch("/webman/login.cgi")
|
||||
.then((r) => r.json())
|
||||
.then((a) => {
|
||||
setSynoToken(a.SynoToken)
|
||||
setRanSynoAuth(true)
|
||||
setLoading(false)
|
||||
})
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
return d
|
||||
})
|
||||
@@ -72,8 +95,13 @@ export default function useAuth() {
|
||||
|
||||
useEffect(() => {
|
||||
loadAuth().then((d) => {
|
||||
if (!d) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!d?.canManageNode &&
|
||||
!d.authorized &&
|
||||
hasAnyEditCapabilities(d) &&
|
||||
// Start auth flow immediately if browser has requested it.
|
||||
new URLSearchParams(window.location.search).get("check") === "now"
|
||||
) {
|
||||
newSession()
|
||||
|
||||
46
client/web/src/hooks/ts-web-connected.ts
Normal file
46
client/web/src/hooks/ts-web-connected.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { isHTTPS } from "src/utils/util"
|
||||
import { AuthServerMode } from "./auth"
|
||||
|
||||
/**
|
||||
* useTSWebConnected hook is used to check whether the browser is able to
|
||||
* connect to the web client served at http://${nodeIPv4}:5252
|
||||
*/
|
||||
export function useTSWebConnected(mode: AuthServerMode, nodeIPv4: string) {
|
||||
const [tsWebConnected, setTSWebConnected] = useState<boolean>(
|
||||
mode === "manage" // browser already on the web client
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
|
||||
const checkTSWebConnection = useCallback(() => {
|
||||
if (mode === "manage") {
|
||||
// Already connected to the web client.
|
||||
setTSWebConnected(true)
|
||||
return
|
||||
}
|
||||
if (isHTTPS()) {
|
||||
// When page is loaded over HTTPS, the connectivity check will always
|
||||
// fail with a mixed-content error. In this case don't bother doing
|
||||
// the check.
|
||||
return
|
||||
}
|
||||
if (isLoading) {
|
||||
return // already checking
|
||||
}
|
||||
setIsLoading(true)
|
||||
fetch(`http://${nodeIPv4}:5252/ok`, { mode: "no-cors" })
|
||||
.then(() => {
|
||||
setTSWebConnected(true)
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch(() => setIsLoading(false))
|
||||
}, [isLoading, mode, nodeIPv4])
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => checkTSWebConnection(), []) // checking connection for first time on page load
|
||||
|
||||
return { tsWebConnected, checkTSWebConnection, isLoading }
|
||||
}
|
||||
@@ -49,3 +49,10 @@ export function isPromise<T = unknown>(val: unknown): val is Promise<T> {
|
||||
}
|
||||
return typeof val === "object" && "then" in val
|
||||
}
|
||||
|
||||
/**
|
||||
* isHTTPS reports whether the current page is loaded over HTTPS.
|
||||
*/
|
||||
export function isHTTPS() {
|
||||
return window.location.protocol === "https:"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const plugin = require("tailwindcss/plugin")
|
||||
const styles = require("./styles.json")
|
||||
import plugin from "tailwindcss/plugin"
|
||||
import styles from "./styles.json"
|
||||
|
||||
module.exports = {
|
||||
const config = {
|
||||
theme: {
|
||||
screens: {
|
||||
sm: "420px",
|
||||
@@ -96,20 +96,22 @@ module.exports = {
|
||||
plugins: [
|
||||
plugin(function ({ addVariant }) {
|
||||
addVariant("state-open", [
|
||||
'&[data-state="open"]',
|
||||
'[data-state="open"] &',
|
||||
"&[data-state=“open”]",
|
||||
"[data-state=“open”] &",
|
||||
])
|
||||
addVariant("state-closed", [
|
||||
'&[data-state="closed"]',
|
||||
'[data-state="closed"] &',
|
||||
"&[data-state=“closed”]",
|
||||
"[data-state=“closed”] &",
|
||||
])
|
||||
addVariant("state-delayed-open", [
|
||||
'&[data-state="delayed-open"]',
|
||||
'[data-state="delayed-open"] &',
|
||||
"&[data-state=“delayed-open”]",
|
||||
"[data-state=“delayed-open”] &",
|
||||
])
|
||||
addVariant("state-active", ['&[data-state="active"]'])
|
||||
addVariant("state-inactive", ['&[data-state="inactive"]'])
|
||||
addVariant("state-active", ["&[data-state=“active”]"])
|
||||
addVariant("state-inactive", ["&[data-state=“inactive”]"])
|
||||
}),
|
||||
],
|
||||
content: ["./src/**/*.html", "./src/**/*.{ts,tsx}", "./index.html"],
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
@@ -445,18 +445,188 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
type authType string
|
||||
type apiHandler[data any] struct {
|
||||
s *Server
|
||||
w http.ResponseWriter
|
||||
r *http.Request
|
||||
|
||||
var (
|
||||
synoAuth authType = "synology" // user needs a SynoToken for subsequent API calls
|
||||
tailscaleAuth authType = "tailscale" // user needs to complete Tailscale check mode
|
||||
)
|
||||
// permissionCheck allows for defining whether a requesting peer's
|
||||
// capabilities grant them access to make the given data update.
|
||||
// If permissionCheck reports false, the request fails as unauthorized.
|
||||
permissionCheck func(data data, peer peerCapabilities) bool
|
||||
}
|
||||
|
||||
// newHandler constructs a new api handler which restricts the given request
|
||||
// to the specified permission check. If the permission check fails for
|
||||
// the peer associated with the request, an unauthorized error is returned
|
||||
// to the client.
|
||||
func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
|
||||
return &apiHandler[data]{
|
||||
s: s,
|
||||
w: w,
|
||||
r: r,
|
||||
permissionCheck: permissionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
// alwaysAllowed can be passed as the permissionCheck argument to newHandler
|
||||
// for requests that are always allowed to complete regardless of a peer's
|
||||
// capabilities.
|
||||
func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
|
||||
|
||||
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
|
||||
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
|
||||
// WhoIs when originally checking for a session from authorizeRequest.
|
||||
// Would be nice if we could pipe those through to here so we don't end
|
||||
// up having to re-call them to grab the peer capabilities.
|
||||
status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
peer, err := toPeerCapabilities(status, whois)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return peer, nil
|
||||
}
|
||||
|
||||
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
|
||||
|
||||
// handle runs the given handler if the source peer satisfies the
|
||||
// constraints for running this request.
|
||||
//
|
||||
// handle is expected for use when `data` type is empty, or set to
|
||||
// `noBodyData` in practice. For requests that expect JSON body data
|
||||
// to be attached, use handleJSON instead.
|
||||
func (a *apiHandler[data]) handle(h http.HandlerFunc) {
|
||||
peer, err := a.getPeer()
|
||||
if err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var body data // not used
|
||||
if !a.permissionCheck(body, peer) {
|
||||
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h(a.w, a.r)
|
||||
}
|
||||
|
||||
// handleJSON manages decoding the request's body JSON and passing
|
||||
// it on to the provided function if the source peer satisfies the
|
||||
// constraints for running this request.
|
||||
func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
|
||||
defer a.r.Body.Close()
|
||||
var body data
|
||||
if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
peer, err := a.getPeer()
|
||||
if err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !a.permissionCheck(body, peer) {
|
||||
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h(a.r.Context(), body); err != nil {
|
||||
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == httpm.PATCH {
|
||||
// Enforce that PATCH requests are always application/json.
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case path == "/data" && r.Method == httpm.GET:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveGetNodeData)
|
||||
return
|
||||
case path == "/exit-nodes" && r.Method == httpm.GET:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveGetExitNodes)
|
||||
return
|
||||
case path == "/routes" && r.Method == httpm.POST:
|
||||
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
|
||||
if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
|
||||
return false
|
||||
} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
newHandler[postRoutesRequest](s, w, r, peerAllowed).
|
||||
handleJSON(s.servePostRoutes)
|
||||
return
|
||||
case path == "/device-details-click" && r.Method == httpm.POST:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.serveDeviceDetailsClick)
|
||||
return
|
||||
case path == "/local/v0/logout" && r.Method == httpm.POST:
|
||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
||||
return peer.canEdit(capFeatureAccount)
|
||||
}
|
||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
|
||||
peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
|
||||
if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
newHandler[maskedPrefs](s, w, r, peerAllowed).
|
||||
handleJSON(s.serveUpdatePrefs)
|
||||
return
|
||||
case path == "/local/v0/update/check" && r.Method == httpm.GET:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/update/check" && r.Method == httpm.POST:
|
||||
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
||||
return peer.canEdit(capFeatureAccount)
|
||||
}
|
||||
newHandler[noBodyData](s, w, r, peerAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
|
||||
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
||||
handle(s.proxyRequestToLocalAPI)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
}
|
||||
|
||||
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"`
|
||||
Authorized bool `json:"authorized"` // has an authorized management session
|
||||
ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
|
||||
NeedsSynoAuth bool `json:"needsSynoAuth,omitempty"`
|
||||
}
|
||||
|
||||
// viewerIdentity is the Tailscale identity of the source node
|
||||
@@ -475,9 +645,11 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var resp authResponse
|
||||
resp.ServerMode = s.mode
|
||||
session, whois, status, sErr := s.getSession(r)
|
||||
var caps peerCapabilities
|
||||
|
||||
if whois != nil {
|
||||
caps, err := toPeerCapabilities(status, whois)
|
||||
var err error
|
||||
caps, err = toPeerCapabilities(status, whois)
|
||||
if err != nil {
|
||||
http.Error(w, sErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -504,7 +676,7 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !authorized {
|
||||
resp.AuthNeeded = synoAuth
|
||||
resp.NeedsSynoAuth = true
|
||||
writeJSON(w, resp)
|
||||
return
|
||||
}
|
||||
@@ -520,21 +692,17 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch {
|
||||
case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
|
||||
// Restricted to the readonly view, no auth action to take.
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
|
||||
resp.AuthNeeded = ""
|
||||
resp.Authorized = false // restricted to the readonly view
|
||||
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 = ""
|
||||
resp.Authorized = false // restricted to the readonly view
|
||||
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 = ""
|
||||
resp.Authorized = false // restricted to the readonly view
|
||||
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 = ""
|
||||
resp.Authorized = false // restricted to the readonly view
|
||||
case sErr != nil && !errors.Is(sErr, errNoSession):
|
||||
// Any other error.
|
||||
http.Error(w, sErr.Error(), http.StatusInternalServerError)
|
||||
@@ -545,16 +713,26 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_managing_remote", 1)
|
||||
}
|
||||
resp.CanManageNode = true
|
||||
resp.AuthNeeded = ""
|
||||
// User has a valid session. They're now authorized to edit if they
|
||||
// have any edit capabilities. In practice, they won't be sent through
|
||||
// the auth flow if they don't have edit caps, but their ACL granted
|
||||
// permissions may change at any time. The frontend views and backend
|
||||
// endpoints are always restricted to their current capabilities in
|
||||
// addition to a valid session.
|
||||
//
|
||||
// But, we also check the caps here for a better user experience on
|
||||
// the frontend login toggle, which uses resp.Authorized to display
|
||||
// "viewing" vs "managing" copy. If they don't have caps, we want to
|
||||
// display "viewing" even if they have a valid session.
|
||||
resp.Authorized = !caps.isEmpty()
|
||||
default:
|
||||
// whois being nil implies local as the request did not come over Tailscale
|
||||
if whois == nil || (whois.Node.StableID == status.Self.ID) {
|
||||
// whois being nil implies local as the request did not come over Tailscale.
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
|
||||
} else {
|
||||
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote", 1)
|
||||
}
|
||||
resp.AuthNeeded = tailscaleAuth
|
||||
resp.Authorized = false // not yet authorized
|
||||
}
|
||||
|
||||
writeJSON(w, resp)
|
||||
@@ -618,32 +796,6 @@ func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// serveAPI serves requests for the web client api.
|
||||
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
||||
// which protects the handler using gorilla csrf.
|
||||
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case path == "/data" && r.Method == httpm.GET:
|
||||
s.serveGetNodeData(w, r)
|
||||
return
|
||||
case path == "/exit-nodes" && r.Method == httpm.GET:
|
||||
s.serveGetExitNodes(w, r)
|
||||
return
|
||||
case path == "/routes" && r.Method == httpm.POST:
|
||||
s.servePostRoutes(w, r)
|
||||
return
|
||||
case path == "/device-details-click" && r.Method == httpm.POST:
|
||||
s.serveDeviceDetailsClick(w, r)
|
||||
return
|
||||
case strings.HasPrefix(path, "/local/"):
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
}
|
||||
|
||||
type nodeData struct {
|
||||
ID tailcfg.StableNodeID
|
||||
Status string
|
||||
@@ -880,6 +1032,23 @@ func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, exitNodes)
|
||||
}
|
||||
|
||||
// maskedPrefs is the subset of ipn.MaskedPrefs that are
|
||||
// allowed to be editable via the web UI.
|
||||
type maskedPrefs struct {
|
||||
RunSSHSet bool
|
||||
RunSSH bool
|
||||
}
|
||||
|
||||
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
|
||||
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
RunSSHSet: prefs.RunSSHSet,
|
||||
Prefs: ipn.Prefs{
|
||||
RunSSH: prefs.RunSSH,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type postRoutesRequest struct {
|
||||
SetExitNode bool // when set, UseExitNode and AdvertiseExitNode values are applied
|
||||
SetRoutes bool // when set, AdvertiseRoutes value is applied
|
||||
@@ -888,18 +1057,10 @@ type postRoutesRequest struct {
|
||||
AdvertiseRoutes []string
|
||||
}
|
||||
|
||||
func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
var data postRoutesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
prefs, err := s.lc.GetPrefs(r.Context())
|
||||
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
|
||||
prefs, err := s.lc.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
return err
|
||||
}
|
||||
var currNonExitRoutes []string
|
||||
var currAdvertisingExitNode bool
|
||||
@@ -922,8 +1083,7 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
||||
routes, err := netutil.CalcAdvertiseRoutes(routesStr, data.AdvertiseExitNode)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
hasExitNodeRoute := func(all []netip.Prefix) bool {
|
||||
@@ -932,8 +1092,7 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if !data.UseExitNode.IsZero() && hasExitNodeRoute(routes) {
|
||||
http.Error(w, "cannot use and advertise exit node at same time", http.StatusBadRequest)
|
||||
return
|
||||
return errors.New("cannot use and advertise exit node at same time")
|
||||
}
|
||||
|
||||
// Make prefs update.
|
||||
@@ -945,12 +1104,8 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
AdvertiseRoutes: routes,
|
||||
},
|
||||
}
|
||||
if _, err := s.lc.EditPrefs(r.Context(), p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err = s.lc.EditPrefs(ctx, p)
|
||||
return err
|
||||
}
|
||||
|
||||
// tailscaleUp starts the daemon with the provided options.
|
||||
@@ -1089,26 +1244,12 @@ func (s *Server) serveDeviceDetailsClick(w http.ResponseWriter, r *http.Request)
|
||||
//
|
||||
// The web API request path is expected to exactly match a localapi path,
|
||||
// with prefix /api/local/ rather than /localapi/.
|
||||
//
|
||||
// If the localapi path is not included in localapiAllowlist,
|
||||
// the request is rejected.
|
||||
func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/local")
|
||||
if r.URL.Path == path { // missing prefix
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Method == httpm.PATCH {
|
||||
// enforce that PATCH requests are always application/json
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !slices.Contains(localapiAllowlist, path) {
|
||||
http.Error(w, fmt.Sprintf("%s not allowed from localapi proxy", path), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
|
||||
@@ -1133,21 +1274,6 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// localapiAllowlist is an allowlist of localapi endpoints the
|
||||
// web client is allowed to proxy to the client's localapi.
|
||||
//
|
||||
// Rather than exposing all localapi endpoints over the proxy,
|
||||
// this limits to just the ones actually used from the web
|
||||
// client frontend.
|
||||
var localapiAllowlist = []string{
|
||||
"/v0/logout",
|
||||
"/v0/prefs",
|
||||
"/v0/update/check",
|
||||
"/v0/update/install",
|
||||
"/v0/update/progress",
|
||||
"/v0/upload-client-metrics",
|
||||
}
|
||||
|
||||
// csrfKey returns a key that can be used for CSRF protection.
|
||||
// If an error occurs during key creation, the error is logged and the active process terminated.
|
||||
// If the server is running in CGI mode, the key is cached to disk and reused between requests.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -86,75 +87,172 @@ func TestQnapAuthnURL(t *testing.T) {
|
||||
|
||||
// TestServeAPI tests the web client api's handling of
|
||||
// 1. invalid endpoint errors
|
||||
// 2. localapi proxy allowlist
|
||||
// 2. permissioning of api endpoints based on node capabilities
|
||||
func TestServeAPI(t *testing.T) {
|
||||
selfTags := views.SliceOf([]string{"tag:server"})
|
||||
self := &ipnstate.PeerStatus{ID: "self", Tags: &selfTags}
|
||||
prefs := &ipn.Prefs{}
|
||||
|
||||
remoteUser := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
|
||||
remoteIPWithAllCapabilities := "100.100.100.101"
|
||||
remoteIPWithNoCapabilities := "100.100.100.102"
|
||||
|
||||
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||
defer lal.Close()
|
||||
// Serve dummy localapi. Just returns "success".
|
||||
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "success")
|
||||
})}
|
||||
localapi := mockLocalAPI(t,
|
||||
map[string]*apitype.WhoIsResponse{
|
||||
remoteIPWithAllCapabilities: {
|
||||
Node: &tailcfg.Node{StableID: "node1"},
|
||||
UserProfile: remoteUser,
|
||||
CapMap: tailcfg.PeerCapMap{tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{"{\"canEdit\":[\"*\"]}"}},
|
||||
},
|
||||
remoteIPWithNoCapabilities: {
|
||||
Node: &tailcfg.Node{StableID: "node2"},
|
||||
UserProfile: remoteUser,
|
||||
},
|
||||
},
|
||||
func() *ipnstate.PeerStatus { return self },
|
||||
func() *ipn.Prefs { return prefs },
|
||||
nil,
|
||||
)
|
||||
defer localapi.Close()
|
||||
|
||||
go localapi.Serve(lal)
|
||||
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
|
||||
|
||||
s := &Server{
|
||||
mode: ManageServerMode,
|
||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||
timeNow: time.Now,
|
||||
}
|
||||
|
||||
type requestTest struct {
|
||||
remoteIP string
|
||||
wantResponse string
|
||||
wantStatus int
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reqMethod string
|
||||
reqPath string
|
||||
reqMethod string
|
||||
reqContentType string
|
||||
wantResp string
|
||||
wantStatus int
|
||||
reqBody string
|
||||
tests []requestTest
|
||||
}{{
|
||||
name: "invalid_endpoint",
|
||||
reqMethod: httpm.POST,
|
||||
reqPath: "/not-an-endpoint",
|
||||
wantResp: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
reqPath: "/not-an-endpoint",
|
||||
reqMethod: httpm.POST,
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantResponse: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}},
|
||||
}, {
|
||||
name: "not_in_localapi_allowlist",
|
||||
reqMethod: httpm.POST,
|
||||
reqPath: "/local/v0/not-allowlisted",
|
||||
wantResp: "/v0/not-allowlisted not allowed from localapi proxy",
|
||||
wantStatus: http.StatusForbidden,
|
||||
reqPath: "/local/v0/not-an-endpoint",
|
||||
reqMethod: httpm.POST,
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantResponse: "invalid endpoint",
|
||||
wantStatus: http.StatusNotFound,
|
||||
}},
|
||||
}, {
|
||||
name: "in_localapi_allowlist",
|
||||
reqMethod: httpm.POST,
|
||||
reqPath: "/local/v0/logout",
|
||||
wantResp: "success", // Successfully allowed to hit localapi.
|
||||
wantStatus: http.StatusOK,
|
||||
reqPath: "/local/v0/logout",
|
||||
reqMethod: httpm.POST,
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "not allowed", // requesting node has insufficient permissions
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantResponse: "success", // requesting node has sufficient permissions
|
||||
wantStatus: http.StatusOK,
|
||||
}},
|
||||
}, {
|
||||
reqPath: "/exit-nodes",
|
||||
reqMethod: httpm.GET,
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "null",
|
||||
wantStatus: http.StatusOK, // allowed, no additional capabilities required
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantResponse: "null",
|
||||
wantStatus: http.StatusOK,
|
||||
}},
|
||||
}, {
|
||||
reqPath: "/routes",
|
||||
reqMethod: httpm.POST,
|
||||
reqBody: "{\"setExitNode\":true}",
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "not allowed",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantStatus: http.StatusOK,
|
||||
}},
|
||||
}, {
|
||||
name: "patch_bad_contenttype",
|
||||
reqMethod: httpm.PATCH,
|
||||
reqPath: "/local/v0/prefs",
|
||||
reqMethod: httpm.PATCH,
|
||||
reqBody: "{\"runSSHSet\":true}",
|
||||
reqContentType: "application/json",
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "not allowed",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantStatus: http.StatusOK,
|
||||
}},
|
||||
}, {
|
||||
reqPath: "/local/v0/prefs",
|
||||
reqMethod: httpm.PATCH,
|
||||
reqContentType: "multipart/form-data",
|
||||
wantResp: "invalid request",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
tests: []requestTest{{
|
||||
remoteIP: remoteIPWithNoCapabilities,
|
||||
wantResponse: "invalid request",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
}, {
|
||||
remoteIP: remoteIPWithAllCapabilities,
|
||||
wantResponse: "invalid request",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
}},
|
||||
}}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, nil)
|
||||
if tt.reqContentType != "" {
|
||||
r.Header.Add("Content-Type", tt.reqContentType)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
for _, req := range tt.tests {
|
||||
t.Run(req.remoteIP+"_requesting_"+tt.reqPath, func(t *testing.T) {
|
||||
var reqBody io.Reader
|
||||
if tt.reqBody != "" {
|
||||
reqBody = bytes.NewBuffer([]byte(tt.reqBody))
|
||||
}
|
||||
r := httptest.NewRequest(tt.reqMethod, "/api"+tt.reqPath, reqBody)
|
||||
r.RemoteAddr = req.remoteIP
|
||||
if tt.reqContentType != "" {
|
||||
r.Header.Add("Content-Type", tt.reqContentType)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.serveAPI(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
|
||||
if tt.wantResp != gotResp {
|
||||
t.Errorf("wrong response; want=%q, got=%q", tt.wantResp, gotResp)
|
||||
}
|
||||
})
|
||||
s.serveAPI(w, r)
|
||||
res := w.Result()
|
||||
defer res.Body.Close()
|
||||
if gotStatus := res.StatusCode; req.wantStatus != gotStatus {
|
||||
t.Errorf("wrong status; want=%v, got=%v", req.wantStatus, gotStatus)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
|
||||
if req.wantResponse != gotResp {
|
||||
t.Errorf("wrong response; want=%q, got=%q", req.wantResponse, gotResp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,7 +622,7 @@ func TestServeAuth(t *testing.T) {
|
||||
name: "no-session",
|
||||
path: "/api/auth",
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantNewCookie: false,
|
||||
wantSession: nil,
|
||||
},
|
||||
@@ -549,7 +647,7 @@ func TestServeAuth(t *testing.T) {
|
||||
path: "/api/auth",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
@@ -597,7 +695,7 @@ func TestServeAuth(t *testing.T) {
|
||||
path: "/api/auth",
|
||||
cookie: successCookie,
|
||||
wantStatus: http.StatusOK,
|
||||
wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantResp: &authResponse{Authorized: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
|
||||
wantSession: &browserSession{
|
||||
ID: successCookie,
|
||||
SrcNode: remoteNode.Node.ID,
|
||||
@@ -1121,9 +1219,10 @@ func TestPeerCapabilities(t *testing.T) {
|
||||
status: userOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(2)},
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
|
||||
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1134,9 +1233,10 @@ func TestPeerCapabilities(t *testing.T) {
|
||||
status: userOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)},
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
|
||||
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1146,6 +1246,7 @@ func TestPeerCapabilities(t *testing.T) {
|
||||
name: "tag-owned-no-webui-caps",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{},
|
||||
},
|
||||
@@ -1156,68 +1257,71 @@ func TestPeerCapabilities(t *testing.T) {
|
||||
name: "tag-owned-one-webui-cap",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
|
||||
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnet: true,
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnets: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tag-owned-multiple-webui-cap",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"ssh\",\"subnet\"]}",
|
||||
"{\"canEdit\":[\"subnet\",\"exitnode\",\"*\"]}",
|
||||
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
|
||||
"{\"canEdit\":[\"subnets\",\"exitnodes\",\"*\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnet: true,
|
||||
capFeatureExitNode: true,
|
||||
capFeatureAll: true,
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnets: true,
|
||||
capFeatureExitNodes: true,
|
||||
capFeatureAll: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tag-owned-case-insensitive-caps",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"SSH\",\"sUBnet\"]}",
|
||||
"{\"canEdit\":[\"SSH\",\"sUBnets\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnet: true,
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnets: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tag-owned-random-canEdit-contents-dont-error",
|
||||
name: "tag-owned-random-canEdit-contents-get-dropped",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"unknown-feature\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{
|
||||
"unknown-feature": true,
|
||||
},
|
||||
wantCaps: peerCapabilities{},
|
||||
},
|
||||
{
|
||||
name: "tag-owned-no-canEdit-section",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canDoSomething\":[\"*\"]}",
|
||||
@@ -1226,6 +1330,19 @@ func TestPeerCapabilities(t *testing.T) {
|
||||
},
|
||||
wantCaps: peerCapabilities{},
|
||||
},
|
||||
{
|
||||
name: "tagged-source-caps-ignored",
|
||||
status: tagOwnedStatus,
|
||||
whois: &apitype.WhoIsResponse{
|
||||
Node: &tailcfg.Node{ID: tailcfg.NodeID(1), Tags: tags.AsSlice()},
|
||||
CapMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
|
||||
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaps: peerCapabilities{},
|
||||
},
|
||||
}
|
||||
for _, tt := range toPeerCapsTests {
|
||||
t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) {
|
||||
@@ -1249,36 +1366,33 @@ func TestPeerCapabilities(t *testing.T) {
|
||||
name: "empty-caps",
|
||||
caps: nil,
|
||||
wantCanEdit: map[capFeature]bool{
|
||||
capFeatureAll: false,
|
||||
capFeatureFunnel: false,
|
||||
capFeatureSSH: false,
|
||||
capFeatureSubnet: false,
|
||||
capFeatureExitNode: false,
|
||||
capFeatureAccount: false,
|
||||
capFeatureAll: false,
|
||||
capFeatureSSH: false,
|
||||
capFeatureSubnets: false,
|
||||
capFeatureExitNodes: false,
|
||||
capFeatureAccount: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "some-caps",
|
||||
caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true},
|
||||
wantCanEdit: map[capFeature]bool{
|
||||
capFeatureAll: false,
|
||||
capFeatureFunnel: false,
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnet: false,
|
||||
capFeatureExitNode: false,
|
||||
capFeatureAccount: true,
|
||||
capFeatureAll: false,
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnets: false,
|
||||
capFeatureExitNodes: false,
|
||||
capFeatureAccount: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard-in-caps",
|
||||
caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true},
|
||||
wantCanEdit: map[capFeature]bool{
|
||||
capFeatureAll: true,
|
||||
capFeatureFunnel: true,
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnet: true,
|
||||
capFeatureExitNode: true,
|
||||
capFeatureAccount: true,
|
||||
capFeatureAll: true,
|
||||
capFeatureSSH: true,
|
||||
capFeatureSubnets: true,
|
||||
capFeatureExitNodes: true,
|
||||
capFeatureAccount: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1339,6 +1453,9 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
|
||||
metricCapture(metricNames[0].Name)
|
||||
writeJSON(w, struct{}{})
|
||||
return
|
||||
case "/localapi/v0/logout":
|
||||
fmt.Fprintf(w, "success")
|
||||
return
|
||||
default:
|
||||
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
|
||||
}
|
||||
|
||||
@@ -20,23 +20,7 @@
|
||||
"@jridgewell/gen-mapping" "^0.3.0"
|
||||
"@jridgewell/trace-mapping" "^0.3.9"
|
||||
|
||||
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.10", "@babel/code-frame@^7.22.5":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.10.tgz#1c20e612b768fefa75f6e90d6ecb86329247f0a3"
|
||||
integrity sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==
|
||||
dependencies:
|
||||
"@babel/highlight" "^7.22.10"
|
||||
chalk "^2.4.2"
|
||||
|
||||
"@babel/code-frame@^7.22.13":
|
||||
version "7.22.13"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
|
||||
integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
|
||||
dependencies:
|
||||
"@babel/highlight" "^7.22.13"
|
||||
chalk "^2.4.2"
|
||||
|
||||
"@babel/code-frame@^7.23.4":
|
||||
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.22.10", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.22.5", "@babel/code-frame@^7.23.4":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.4.tgz#03ae5af150be94392cb5c7ccd97db5a19a5da6aa"
|
||||
integrity sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==
|
||||
@@ -44,17 +28,12 @@
|
||||
"@babel/highlight" "^7.23.4"
|
||||
chalk "^2.4.2"
|
||||
|
||||
"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.23.3":
|
||||
"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.22.9", "@babel/compat-data@^7.23.3":
|
||||
version "7.23.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.23.3.tgz#3febd552541e62b5e883a25eb3effd7c7379db11"
|
||||
integrity sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==
|
||||
|
||||
"@babel/compat-data@^7.22.9":
|
||||
version "7.22.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.9.tgz#71cdb00a1ce3a329ce4cbec3a44f9fef35669730"
|
||||
integrity sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==
|
||||
|
||||
"@babel/core@^7.16.0":
|
||||
"@babel/core@^7.16.0", "@babel/core@^7.21.3":
|
||||
version "7.23.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.23.3.tgz#5ec09c8803b91f51cc887dedc2654a35852849c9"
|
||||
integrity sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==
|
||||
@@ -75,27 +54,6 @@
|
||||
json5 "^2.2.3"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/core@^7.21.3":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.22.10.tgz#aad442c7bcd1582252cb4576747ace35bc122f35"
|
||||
integrity sha512-fTmqbbUBAwCcre6zPzNngvsI0aNrPZe77AeqvDxWM9Nm+04RrJ3CAmGHA9f7lJQY6ZMhRztNemy4uslDxTX4Qw==
|
||||
dependencies:
|
||||
"@ampproject/remapping" "^2.2.0"
|
||||
"@babel/code-frame" "^7.22.10"
|
||||
"@babel/generator" "^7.22.10"
|
||||
"@babel/helper-compilation-targets" "^7.22.10"
|
||||
"@babel/helper-module-transforms" "^7.22.9"
|
||||
"@babel/helpers" "^7.22.10"
|
||||
"@babel/parser" "^7.22.10"
|
||||
"@babel/template" "^7.22.5"
|
||||
"@babel/traverse" "^7.22.10"
|
||||
"@babel/types" "^7.22.10"
|
||||
convert-source-map "^1.7.0"
|
||||
debug "^4.1.0"
|
||||
gensync "^1.0.0-beta.2"
|
||||
json5 "^2.2.2"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/eslint-parser@^7.16.3":
|
||||
version "7.23.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.23.3.tgz#7bf0db1c53b54da0c8a12627373554a0828479ca"
|
||||
@@ -105,27 +63,7 @@
|
||||
eslint-visitor-keys "^2.1.0"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/generator@^7.22.10":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.22.10.tgz#c92254361f398e160645ac58831069707382b722"
|
||||
integrity sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==
|
||||
dependencies:
|
||||
"@babel/types" "^7.22.10"
|
||||
"@jridgewell/gen-mapping" "^0.3.2"
|
||||
"@jridgewell/trace-mapping" "^0.3.17"
|
||||
jsesc "^2.5.1"
|
||||
|
||||
"@babel/generator@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.0.tgz#df5c386e2218be505b34837acbcb874d7a983420"
|
||||
integrity sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==
|
||||
dependencies:
|
||||
"@babel/types" "^7.23.0"
|
||||
"@jridgewell/gen-mapping" "^0.3.2"
|
||||
"@jridgewell/trace-mapping" "^0.3.17"
|
||||
jsesc "^2.5.1"
|
||||
|
||||
"@babel/generator@^7.23.3", "@babel/generator@^7.23.4":
|
||||
"@babel/generator@^7.22.10", "@babel/generator@^7.23.0", "@babel/generator@^7.23.3", "@babel/generator@^7.23.4":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.4.tgz#4a41377d8566ec18f807f42962a7f3551de83d1c"
|
||||
integrity sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==
|
||||
@@ -149,18 +87,7 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.22.15"
|
||||
|
||||
"@babel/helper-compilation-targets@^7.22.10":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz#01d648bbc25dd88f513d862ee0df27b7d4e67024"
|
||||
integrity sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==
|
||||
dependencies:
|
||||
"@babel/compat-data" "^7.22.9"
|
||||
"@babel/helper-validator-option" "^7.22.5"
|
||||
browserslist "^4.21.9"
|
||||
lru-cache "^5.1.1"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/helper-compilation-targets@^7.22.15", "@babel/helper-compilation-targets@^7.22.6":
|
||||
"@babel/helper-compilation-targets@^7.22.10", "@babel/helper-compilation-targets@^7.22.15", "@babel/helper-compilation-targets@^7.22.6":
|
||||
version "7.22.15"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz#0698fc44551a26cf29f18d4662d5bf545a6cfc52"
|
||||
integrity sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==
|
||||
@@ -206,16 +133,11 @@
|
||||
lodash.debounce "^4.0.8"
|
||||
resolve "^1.14.2"
|
||||
|
||||
"@babel/helper-environment-visitor@^7.22.20":
|
||||
"@babel/helper-environment-visitor@^7.22.20", "@babel/helper-environment-visitor@^7.22.5":
|
||||
version "7.22.20"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167"
|
||||
integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==
|
||||
|
||||
"@babel/helper-environment-visitor@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz#f06dd41b7c1f44e1f8da6c4055b41ab3a09a7e98"
|
||||
integrity sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==
|
||||
|
||||
"@babel/helper-function-name@^7.22.5", "@babel/helper-function-name@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759"
|
||||
@@ -238,32 +160,14 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.23.0"
|
||||
|
||||
"@babel/helper-module-imports@^7.22.15":
|
||||
"@babel/helper-module-imports@^7.22.15", "@babel/helper-module-imports@^7.22.5":
|
||||
version "7.22.15"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0"
|
||||
integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==
|
||||
dependencies:
|
||||
"@babel/types" "^7.22.15"
|
||||
|
||||
"@babel/helper-module-imports@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz#1a8f4c9f4027d23f520bd76b364d44434a72660c"
|
||||
integrity sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==
|
||||
dependencies:
|
||||
"@babel/types" "^7.22.5"
|
||||
|
||||
"@babel/helper-module-transforms@^7.22.9":
|
||||
version "7.22.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz#92dfcb1fbbb2bc62529024f72d942a8c97142129"
|
||||
integrity sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==
|
||||
dependencies:
|
||||
"@babel/helper-environment-visitor" "^7.22.5"
|
||||
"@babel/helper-module-imports" "^7.22.5"
|
||||
"@babel/helper-simple-access" "^7.22.5"
|
||||
"@babel/helper-split-export-declaration" "^7.22.6"
|
||||
"@babel/helper-validator-identifier" "^7.22.5"
|
||||
|
||||
"@babel/helper-module-transforms@^7.23.3":
|
||||
"@babel/helper-module-transforms@^7.22.9", "@babel/helper-module-transforms@^7.23.3":
|
||||
version "7.23.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz#d7d12c3c5d30af5b3c0fcab2a6d5217773e2d0f1"
|
||||
integrity sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==
|
||||
@@ -325,36 +229,21 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.22.5"
|
||||
|
||||
"@babel/helper-string-parser@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f"
|
||||
integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==
|
||||
|
||||
"@babel/helper-string-parser@^7.23.4":
|
||||
"@babel/helper-string-parser@^7.22.5", "@babel/helper-string-parser@^7.23.4":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83"
|
||||
integrity sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.22.20":
|
||||
"@babel/helper-validator-identifier@^7.22.20", "@babel/helper-validator-identifier@^7.22.5":
|
||||
version "7.22.20"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
|
||||
integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz#9544ef6a33999343c8740fa51350f30eeaaaf193"
|
||||
integrity sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==
|
||||
|
||||
"@babel/helper-validator-option@^7.22.15":
|
||||
"@babel/helper-validator-option@^7.22.15", "@babel/helper-validator-option@^7.22.5":
|
||||
version "7.22.15"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz#694c30dfa1d09a6534cdfcafbe56789d36aba040"
|
||||
integrity sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==
|
||||
|
||||
"@babel/helper-validator-option@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz#de52000a15a177413c8234fa3a8af4ee8102d0ac"
|
||||
integrity sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==
|
||||
|
||||
"@babel/helper-wrap-function@^7.22.20":
|
||||
version "7.22.20"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz#15352b0b9bfb10fc9c76f79f6342c00e3411a569"
|
||||
@@ -364,16 +253,7 @@
|
||||
"@babel/template" "^7.22.15"
|
||||
"@babel/types" "^7.22.19"
|
||||
|
||||
"@babel/helpers@^7.22.10":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.22.10.tgz#ae6005c539dfbcb5cd71fb51bfc8a52ba63bc37a"
|
||||
integrity sha512-a41J4NW8HyZa1I1vAndrraTlPZ/eZoga2ZgS7fEr0tZJGVU4xqdE80CEm0CcNjha5EZ8fTBYLKHF0kqDUuAwQw==
|
||||
dependencies:
|
||||
"@babel/template" "^7.22.5"
|
||||
"@babel/traverse" "^7.22.10"
|
||||
"@babel/types" "^7.22.10"
|
||||
|
||||
"@babel/helpers@^7.23.2":
|
||||
"@babel/helpers@^7.22.10", "@babel/helpers@^7.23.2":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.23.4.tgz#7d2cfb969aa43222032193accd7329851facf3c1"
|
||||
integrity sha512-HfcMizYz10cr3h29VqyfGL6ZWIjTwWfvYBMsBVGwpcbhNGe3wQ1ZXZRPzZoAHhd9OqHadHqjQ89iVKINXnbzuw==
|
||||
@@ -382,25 +262,7 @@
|
||||
"@babel/traverse" "^7.23.4"
|
||||
"@babel/types" "^7.23.4"
|
||||
|
||||
"@babel/highlight@^7.22.10":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.10.tgz#02a3f6d8c1cb4521b2fd0ab0da8f4739936137d7"
|
||||
integrity sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==
|
||||
dependencies:
|
||||
"@babel/helper-validator-identifier" "^7.22.5"
|
||||
chalk "^2.4.2"
|
||||
js-tokens "^4.0.0"
|
||||
|
||||
"@babel/highlight@^7.22.13":
|
||||
version "7.22.20"
|
||||
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
|
||||
integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
|
||||
dependencies:
|
||||
"@babel/helper-validator-identifier" "^7.22.20"
|
||||
chalk "^2.4.2"
|
||||
js-tokens "^4.0.0"
|
||||
|
||||
"@babel/highlight@^7.23.4":
|
||||
"@babel/highlight@^7.22.10", "@babel/highlight@^7.22.13", "@babel/highlight@^7.23.4":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b"
|
||||
integrity sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==
|
||||
@@ -409,17 +271,7 @@
|
||||
chalk "^2.4.2"
|
||||
js-tokens "^4.0.0"
|
||||
|
||||
"@babel/parser@^7.22.10", "@babel/parser@^7.22.5":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.10.tgz#e37634f9a12a1716136c44624ef54283cabd3f55"
|
||||
integrity sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==
|
||||
|
||||
"@babel/parser@^7.22.15", "@babel/parser@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719"
|
||||
integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==
|
||||
|
||||
"@babel/parser@^7.23.3", "@babel/parser@^7.23.4":
|
||||
"@babel/parser@^7.22.10", "@babel/parser@^7.22.15", "@babel/parser@^7.22.5", "@babel/parser@^7.23.0", "@babel/parser@^7.23.3", "@babel/parser@^7.23.4":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.4.tgz#409fbe690c333bb70187e2de4021e1e47a026661"
|
||||
integrity sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==
|
||||
@@ -1234,21 +1086,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
|
||||
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
|
||||
|
||||
"@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.8.4":
|
||||
"@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.16.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.8.4":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.4.tgz#36fa1d2b36db873d25ec631dcc4923fdc1cf2e2e"
|
||||
integrity sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.13.10":
|
||||
version "7.23.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885"
|
||||
integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/template@^7.22.15":
|
||||
"@babel/template@^7.22.15", "@babel/template@^7.22.5":
|
||||
version "7.22.15"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
|
||||
integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==
|
||||
@@ -1257,32 +1102,7 @@
|
||||
"@babel/parser" "^7.22.15"
|
||||
"@babel/types" "^7.22.15"
|
||||
|
||||
"@babel/template@^7.22.5":
|
||||
version "7.22.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec"
|
||||
integrity sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.22.5"
|
||||
"@babel/parser" "^7.22.5"
|
||||
"@babel/types" "^7.22.5"
|
||||
|
||||
"@babel/traverse@^7.22.10":
|
||||
version "7.23.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8"
|
||||
integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.22.13"
|
||||
"@babel/generator" "^7.23.0"
|
||||
"@babel/helper-environment-visitor" "^7.22.20"
|
||||
"@babel/helper-function-name" "^7.23.0"
|
||||
"@babel/helper-hoist-variables" "^7.22.5"
|
||||
"@babel/helper-split-export-declaration" "^7.22.6"
|
||||
"@babel/parser" "^7.23.0"
|
||||
"@babel/types" "^7.23.0"
|
||||
debug "^4.1.0"
|
||||
globals "^11.1.0"
|
||||
|
||||
"@babel/traverse@^7.23.3", "@babel/traverse@^7.23.4":
|
||||
"@babel/traverse@^7.22.10", "@babel/traverse@^7.23.3", "@babel/traverse@^7.23.4":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.4.tgz#c2790f7edf106d059a0098770fe70801417f3f85"
|
||||
integrity sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==
|
||||
@@ -1298,25 +1118,7 @@
|
||||
debug "^4.1.0"
|
||||
globals "^11.1.0"
|
||||
|
||||
"@babel/types@^7.21.3", "@babel/types@^7.22.10", "@babel/types@^7.22.5":
|
||||
version "7.22.10"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.10.tgz#4a9e76446048f2c66982d1a989dd12b8a2d2dc03"
|
||||
integrity sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==
|
||||
dependencies:
|
||||
"@babel/helper-string-parser" "^7.22.5"
|
||||
"@babel/helper-validator-identifier" "^7.22.5"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@babel/types@^7.22.15", "@babel/types@^7.23.0":
|
||||
version "7.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb"
|
||||
integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==
|
||||
dependencies:
|
||||
"@babel/helper-string-parser" "^7.22.5"
|
||||
"@babel/helper-validator-identifier" "^7.22.20"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@babel/types@^7.22.19", "@babel/types@^7.23.3", "@babel/types@^7.23.4", "@babel/types@^7.4.4":
|
||||
"@babel/types@^7.21.3", "@babel/types@^7.22.10", "@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.23.3", "@babel/types@^7.23.4", "@babel/types@^7.4.4":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.4.tgz#7206a1810fc512a7f7f7d4dace4cb4c1c9dbfb8e"
|
||||
integrity sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==
|
||||
@@ -1445,14 +1247,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae"
|
||||
integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==
|
||||
|
||||
"@eslint-community/eslint-utils@^4.2.0":
|
||||
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59"
|
||||
integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==
|
||||
dependencies:
|
||||
eslint-visitor-keys "^3.3.0"
|
||||
|
||||
"@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1":
|
||||
"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1":
|
||||
version "4.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63"
|
||||
integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==
|
||||
@@ -2063,17 +1865,12 @@
|
||||
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.5.tgz#043b731d4f56a79b4897a3de1af35e75d56bc63a"
|
||||
integrity sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==
|
||||
|
||||
"@types/estree@1.0.5":
|
||||
"@types/estree@1.0.5", "@types/estree@^1.0.0":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
|
||||
integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
|
||||
|
||||
"@types/estree@^1.0.0":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.1.tgz#aa22750962f3bf0e79d753d3cc067f010c95f194"
|
||||
integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==
|
||||
|
||||
"@types/json-schema@^7.0.9":
|
||||
"@types/json-schema@^7.0.12", "@types/json-schema@^7.0.9":
|
||||
version "7.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||
@@ -2121,26 +1918,27 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"
|
||||
integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==
|
||||
|
||||
"@types/semver@^7.3.12":
|
||||
version "7.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339"
|
||||
integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==
|
||||
"@types/semver@^7.3.12", "@types/semver@^7.5.0":
|
||||
version "7.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e"
|
||||
integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==
|
||||
|
||||
"@typescript-eslint/eslint-plugin@^5.5.0":
|
||||
version "5.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz#aeef0328d172b9e37d9bab6dbc13b87ed88977db"
|
||||
integrity sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==
|
||||
"@typescript-eslint/eslint-plugin@^5.5.0", "@typescript-eslint/eslint-plugin@^6.2.1":
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz#30830c1ca81fd5f3c2714e524c4303e0194f9cd3"
|
||||
integrity sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.4.0"
|
||||
"@typescript-eslint/scope-manager" "5.62.0"
|
||||
"@typescript-eslint/type-utils" "5.62.0"
|
||||
"@typescript-eslint/utils" "5.62.0"
|
||||
"@eslint-community/regexpp" "^4.5.1"
|
||||
"@typescript-eslint/scope-manager" "6.21.0"
|
||||
"@typescript-eslint/type-utils" "6.21.0"
|
||||
"@typescript-eslint/utils" "6.21.0"
|
||||
"@typescript-eslint/visitor-keys" "6.21.0"
|
||||
debug "^4.3.4"
|
||||
graphemer "^1.4.0"
|
||||
ignore "^5.2.0"
|
||||
natural-compare-lite "^1.4.0"
|
||||
semver "^7.3.7"
|
||||
tsutils "^3.21.0"
|
||||
ignore "^5.2.4"
|
||||
natural-compare "^1.4.0"
|
||||
semver "^7.5.4"
|
||||
ts-api-utils "^1.0.1"
|
||||
|
||||
"@typescript-eslint/experimental-utils@^5.0.0":
|
||||
version "5.62.0"
|
||||
@@ -2149,14 +1947,15 @@
|
||||
dependencies:
|
||||
"@typescript-eslint/utils" "5.62.0"
|
||||
|
||||
"@typescript-eslint/parser@^5.5.0":
|
||||
version "5.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.62.0.tgz#1b63d082d849a2fcae8a569248fbe2ee1b8a56c7"
|
||||
integrity sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==
|
||||
"@typescript-eslint/parser@^5.5.0", "@typescript-eslint/parser@^6.2.1":
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.21.0.tgz#af8fcf66feee2edc86bc5d1cf45e33b0630bf35b"
|
||||
integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "5.62.0"
|
||||
"@typescript-eslint/types" "5.62.0"
|
||||
"@typescript-eslint/typescript-estree" "5.62.0"
|
||||
"@typescript-eslint/scope-manager" "6.21.0"
|
||||
"@typescript-eslint/types" "6.21.0"
|
||||
"@typescript-eslint/typescript-estree" "6.21.0"
|
||||
"@typescript-eslint/visitor-keys" "6.21.0"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/scope-manager@5.62.0":
|
||||
@@ -2167,21 +1966,34 @@
|
||||
"@typescript-eslint/types" "5.62.0"
|
||||
"@typescript-eslint/visitor-keys" "5.62.0"
|
||||
|
||||
"@typescript-eslint/type-utils@5.62.0":
|
||||
version "5.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a"
|
||||
integrity sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==
|
||||
"@typescript-eslint/scope-manager@6.21.0":
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz#ea8a9bfc8f1504a6ac5d59a6df308d3a0630a2b1"
|
||||
integrity sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==
|
||||
dependencies:
|
||||
"@typescript-eslint/typescript-estree" "5.62.0"
|
||||
"@typescript-eslint/utils" "5.62.0"
|
||||
"@typescript-eslint/types" "6.21.0"
|
||||
"@typescript-eslint/visitor-keys" "6.21.0"
|
||||
|
||||
"@typescript-eslint/type-utils@6.21.0":
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz#6473281cfed4dacabe8004e8521cee0bd9d4c01e"
|
||||
integrity sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==
|
||||
dependencies:
|
||||
"@typescript-eslint/typescript-estree" "6.21.0"
|
||||
"@typescript-eslint/utils" "6.21.0"
|
||||
debug "^4.3.4"
|
||||
tsutils "^3.21.0"
|
||||
ts-api-utils "^1.0.1"
|
||||
|
||||
"@typescript-eslint/types@5.62.0":
|
||||
version "5.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f"
|
||||
integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==
|
||||
|
||||
"@typescript-eslint/types@6.21.0":
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d"
|
||||
integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==
|
||||
|
||||
"@typescript-eslint/typescript-estree@5.62.0":
|
||||
version "5.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b"
|
||||
@@ -2195,6 +2007,20 @@
|
||||
semver "^7.3.7"
|
||||
tsutils "^3.21.0"
|
||||
|
||||
"@typescript-eslint/typescript-estree@6.21.0":
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz#c47ae7901db3b8bddc3ecd73daff2d0895688c46"
|
||||
integrity sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "6.21.0"
|
||||
"@typescript-eslint/visitor-keys" "6.21.0"
|
||||
debug "^4.3.4"
|
||||
globby "^11.1.0"
|
||||
is-glob "^4.0.3"
|
||||
minimatch "9.0.3"
|
||||
semver "^7.5.4"
|
||||
ts-api-utils "^1.0.1"
|
||||
|
||||
"@typescript-eslint/utils@5.62.0", "@typescript-eslint/utils@^5.58.0":
|
||||
version "5.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86"
|
||||
@@ -2209,6 +2035,19 @@
|
||||
eslint-scope "^5.1.1"
|
||||
semver "^7.3.7"
|
||||
|
||||
"@typescript-eslint/utils@6.21.0":
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.21.0.tgz#4714e7a6b39e773c1c8e97ec587f520840cd8134"
|
||||
integrity sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.4.0"
|
||||
"@types/json-schema" "^7.0.12"
|
||||
"@types/semver" "^7.5.0"
|
||||
"@typescript-eslint/scope-manager" "6.21.0"
|
||||
"@typescript-eslint/types" "6.21.0"
|
||||
"@typescript-eslint/typescript-estree" "6.21.0"
|
||||
semver "^7.5.4"
|
||||
|
||||
"@typescript-eslint/visitor-keys@5.62.0":
|
||||
version "5.62.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e"
|
||||
@@ -2217,6 +2056,14 @@
|
||||
"@typescript-eslint/types" "5.62.0"
|
||||
eslint-visitor-keys "^3.3.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@6.21.0":
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz#87a99d077aa507e20e238b11d56cc26ade45fe47"
|
||||
integrity sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "6.21.0"
|
||||
eslint-visitor-keys "^3.4.1"
|
||||
|
||||
"@ungap/structured-clone@^1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
|
||||
@@ -2283,16 +2130,11 @@ acorn-walk@^8.3.2:
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa"
|
||||
integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==
|
||||
|
||||
acorn@^8.11.3:
|
||||
acorn@^8.11.3, acorn@^8.9.0:
|
||||
version "8.11.3"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a"
|
||||
integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==
|
||||
|
||||
acorn@^8.9.0:
|
||||
version "8.10.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"
|
||||
@@ -2579,6 +2421,13 @@ brace-expansion@^1.1.7:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
brace-expansion@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
|
||||
integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
|
||||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
|
||||
braces@^3.0.2, braces@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
||||
@@ -2586,17 +2435,7 @@ braces@^3.0.2, braces@~3.0.2:
|
||||
dependencies:
|
||||
fill-range "^7.0.1"
|
||||
|
||||
browserslist@^4.21.10, browserslist@^4.21.9:
|
||||
version "4.21.10"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.10.tgz#dbbac576628c13d3b2231332cb2ec5a46e015bb0"
|
||||
integrity sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==
|
||||
dependencies:
|
||||
caniuse-lite "^1.0.30001517"
|
||||
electron-to-chromium "^1.4.477"
|
||||
node-releases "^2.0.13"
|
||||
update-browserslist-db "^1.0.11"
|
||||
|
||||
browserslist@^4.22.1:
|
||||
browserslist@^4.21.10, browserslist@^4.21.9, browserslist@^4.22.1:
|
||||
version "4.22.1"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.1.tgz#ba91958d1a59b87dab6fed8dfbcb3da5e2e9c619"
|
||||
integrity sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==
|
||||
@@ -2635,17 +2474,7 @@ camelcase@^6.2.0:
|
||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
|
||||
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
|
||||
|
||||
caniuse-lite@^1.0.30001517:
|
||||
version "1.0.30001519"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001519.tgz#3e7b8b8a7077e78b0eb054d69e6edf5c7df35601"
|
||||
integrity sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==
|
||||
|
||||
caniuse-lite@^1.0.30001520:
|
||||
version "1.0.30001520"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001520.tgz#62e2b7a1c7b35269594cf296a80bdf8cb9565006"
|
||||
integrity sha512-tahF5O9EiiTzwTUqAeFjIZbn4Dnqxzz7ktrgGlMYNLH43Ul26IgTMH/zvL3DG0lZxBYnlT04axvInszUsZULdA==
|
||||
|
||||
caniuse-lite@^1.0.30001541:
|
||||
caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520, caniuse-lite@^1.0.30001541:
|
||||
version "1.0.30001565"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz#a528b253c8a2d95d2b415e11d8b9942acc100c4f"
|
||||
integrity sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w==
|
||||
@@ -2943,12 +2772,7 @@ dot-case@^3.0.4:
|
||||
no-case "^3.0.4"
|
||||
tslib "^2.0.3"
|
||||
|
||||
electron-to-chromium@^1.4.477:
|
||||
version "1.4.490"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.490.tgz#d99286f6e915667fa18ea4554def1aa60eb4d5f1"
|
||||
integrity sha512-6s7NVJz+sATdYnIwhdshx/N/9O6rvMxmhVoDSDFdj6iA45gHR8EQje70+RYsF4GeB+k0IeNSBnP7yG9ZXJFr7A==
|
||||
|
||||
electron-to-chromium@^1.4.535:
|
||||
electron-to-chromium@^1.4.477, electron-to-chromium@^1.4.535:
|
||||
version "1.4.596"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.596.tgz#6752d1aa795d942d49dfc5d3764d6ea283fab1d7"
|
||||
integrity sha512-zW3zbZ40Icb2BCWjm47nxwcFGYlIgdXkAx85XDO7cyky9J4QQfq8t0W19/TLZqq3JPQXtlv8BPIGmfa9Jb4scg==
|
||||
@@ -3379,18 +3203,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||
|
||||
fast-glob@^3.2.12:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4"
|
||||
integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
|
||||
dependencies:
|
||||
"@nodelib/fs.stat" "^2.0.2"
|
||||
"@nodelib/fs.walk" "^1.2.3"
|
||||
glob-parent "^5.1.2"
|
||||
merge2 "^1.3.0"
|
||||
micromatch "^4.0.4"
|
||||
|
||||
fast-glob@^3.2.9:
|
||||
fast-glob@^3.2.12, fast-glob@^3.2.9:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
|
||||
integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==
|
||||
@@ -3480,22 +3293,12 @@ fs.realpath@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
||||
|
||||
fsevents@~2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
|
||||
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
||||
|
||||
fsevents@~2.3.3:
|
||||
fsevents@~2.3.2, fsevents@~2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
||||
|
||||
function-bind@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
|
||||
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
|
||||
|
||||
function-bind@^1.1.2:
|
||||
function-bind@^1.1.1, function-bind@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
|
||||
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
|
||||
@@ -3732,10 +3535,10 @@ iconv-lite@0.6.3:
|
||||
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"
|
||||
integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==
|
||||
ignore@^5.2.0, ignore@^5.2.4:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
|
||||
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
|
||||
|
||||
import-fresh@^3.2.1:
|
||||
version "3.3.0"
|
||||
@@ -3827,14 +3630,7 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7:
|
||||
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
|
||||
integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
|
||||
|
||||
is-core-module@^2.13.0:
|
||||
version "2.13.0"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db"
|
||||
integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==
|
||||
dependencies:
|
||||
has "^1.0.3"
|
||||
|
||||
is-core-module@^2.13.1:
|
||||
is-core-module@^2.13.0, is-core-module@^2.13.1:
|
||||
version "2.13.1"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384"
|
||||
integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==
|
||||
@@ -4173,14 +3969,7 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||
dependencies:
|
||||
js-tokens "^3.0.0 || ^4.0.0"
|
||||
|
||||
loupe@^2.3.6:
|
||||
version "2.3.6"
|
||||
resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.6.tgz#76e4af498103c532d1ecc9be102036a21f787b53"
|
||||
integrity sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==
|
||||
dependencies:
|
||||
get-func-name "^2.0.0"
|
||||
|
||||
loupe@^2.3.7:
|
||||
loupe@^2.3.6, loupe@^2.3.7:
|
||||
version "2.3.7"
|
||||
resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697"
|
||||
integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==
|
||||
@@ -4250,6 +4039,13 @@ mimic-fn@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc"
|
||||
integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==
|
||||
|
||||
minimatch@9.0.3:
|
||||
version "9.0.3"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
|
||||
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
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"
|
||||
@@ -4262,17 +4058,7 @@ minimist@^1.2.0, minimist@^1.2.6:
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
|
||||
mlly@^1.2.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.4.0.tgz#830c10d63f1f97bd8785377b24dc2a15d972832b"
|
||||
integrity sha512-ua8PAThnTwpprIaU47EPeZ/bPUVp2QYBbWMphUQpVdBI3Lgqzm5KZQ45Agm3YJedHXaIHl6pBGabaLSUPPSptg==
|
||||
dependencies:
|
||||
acorn "^8.9.0"
|
||||
pathe "^1.1.1"
|
||||
pkg-types "^1.0.3"
|
||||
ufo "^1.1.2"
|
||||
|
||||
mlly@^1.4.2:
|
||||
mlly@^1.2.0, mlly@^1.4.2:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.6.0.tgz#0ecfbddc706857f5e170ccd28c6b0b9c81d3f548"
|
||||
integrity sha512-YOvg9hfYQmnaB56Yb+KrJE2u0Yzz5zR+sLejEvF4fzwzV1Al6hkf2vyHTwqCRyv0hCi9rVCqVoXpyYevQIRwLQ==
|
||||
@@ -4301,21 +4087,11 @@ mz@^2.7.0:
|
||||
object-assign "^4.0.1"
|
||||
thenify-all "^1.0.0"
|
||||
|
||||
nanoid@^3.3.6:
|
||||
version "3.3.6"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
|
||||
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
|
||||
|
||||
nanoid@^3.3.7:
|
||||
nanoid@^3.3.6, nanoid@^3.3.7:
|
||||
version "3.3.7"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
|
||||
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
|
||||
|
||||
natural-compare-lite@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4"
|
||||
integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==
|
||||
|
||||
natural-compare@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
@@ -4532,12 +4308,7 @@ path-type@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
|
||||
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
|
||||
|
||||
pathe@^1.1.0, pathe@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.1.tgz#1dd31d382b974ba69809adc9a7a347e65d84829a"
|
||||
integrity sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==
|
||||
|
||||
pathe@^1.1.2:
|
||||
pathe@^1.1.0, pathe@^1.1.1, pathe@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
|
||||
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
|
||||
@@ -4620,16 +4391,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
|
||||
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
|
||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||
|
||||
postcss@^8.4.23, postcss@^8.4.31:
|
||||
version "8.4.31"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d"
|
||||
integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==
|
||||
dependencies:
|
||||
nanoid "^3.3.6"
|
||||
picocolors "^1.0.0"
|
||||
source-map-js "^1.0.2"
|
||||
|
||||
postcss@^8.4.35:
|
||||
postcss@^8.4.23, postcss@^8.4.31, postcss@^8.4.35:
|
||||
version "8.4.35"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.35.tgz#60997775689ce09011edf083a549cea44aabe2f7"
|
||||
integrity sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==
|
||||
@@ -4843,16 +4605,7 @@ resolve-from@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
||||
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
|
||||
|
||||
resolve@^1.1.7, resolve@^1.22.2:
|
||||
version "1.22.4"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.4.tgz#1dc40df46554cdaf8948a486a10f6ba1e2026c34"
|
||||
integrity sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==
|
||||
dependencies:
|
||||
is-core-module "^2.13.0"
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
resolve@^1.14.2, resolve@^1.19.0, resolve@^1.22.4:
|
||||
resolve@^1.1.7, resolve@^1.14.2, resolve@^1.19.0, resolve@^1.22.2, resolve@^1.22.4:
|
||||
version "1.22.8"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
|
||||
integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
|
||||
@@ -4959,10 +4712,10 @@ semver@^6.3.1:
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
semver@^7.3.7:
|
||||
version "7.5.4"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
|
||||
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
|
||||
semver@^7.3.7, semver@^7.5.4:
|
||||
version "7.6.0"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d"
|
||||
integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
@@ -5261,6 +5014,11 @@ tr46@^5.0.0:
|
||||
dependencies:
|
||||
punycode "^2.3.1"
|
||||
|
||||
ts-api-utils@^1.0.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.2.1.tgz#f716c7e027494629485b21c0df6180f4d08f5e8b"
|
||||
integrity sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==
|
||||
|
||||
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"
|
||||
@@ -5358,17 +5116,12 @@ typed-array-length@^1.0.4:
|
||||
for-each "^0.3.3"
|
||||
is-typed-array "^1.1.9"
|
||||
|
||||
typescript@^4.7.4:
|
||||
version "4.9.5"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
|
||||
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
||||
typescript@^5.3.3:
|
||||
version "5.3.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
|
||||
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
|
||||
|
||||
ufo@^1.1.2:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.2.0.tgz#28d127a087a46729133fdc89cb1358508b3f80ba"
|
||||
integrity sha512-RsPyTbqORDNDxqAdQPQBpgqhWle1VcTSou/FraClYlHf6TZnQcGslpLcAphNR+sQW4q5lLWLbOsRlh9j24baQg==
|
||||
|
||||
ufo@^1.3.2:
|
||||
ufo@^1.1.2, ufo@^1.3.2:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.4.0.tgz#39845b31be81b4f319ab1d99fd20c56cac528d32"
|
||||
integrity sha512-Hhy+BhRBleFjpJ2vchUNN40qgkh0366FWJGqVLYBHev0vpHTrXSA0ryT+74UiW6KWsldNurQMKGqCm1M2zBciQ==
|
||||
@@ -5416,15 +5169,7 @@ universalify@^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"
|
||||
integrity sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==
|
||||
dependencies:
|
||||
escalade "^3.1.1"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
update-browserslist-db@^1.0.13:
|
||||
update-browserslist-db@^1.0.11, update-browserslist-db@^1.0.13:
|
||||
version "1.0.13"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4"
|
||||
integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==
|
||||
|
||||
@@ -665,6 +665,7 @@ func (up *Updater) updateAlpineLike() (err error) {
|
||||
|
||||
func parseAlpinePackageVersion(out []byte) (string, error) {
|
||||
s := bufio.NewScanner(bytes.NewReader(out))
|
||||
var maxVer string
|
||||
for s.Scan() {
|
||||
// The line should look like this:
|
||||
// tailscale-1.44.2-r0 description:
|
||||
@@ -676,7 +677,13 @@ func parseAlpinePackageVersion(out []byte) (string, error) {
|
||||
if len(parts) < 3 {
|
||||
return "", fmt.Errorf("malformed info line: %q", line)
|
||||
}
|
||||
return parts[1], nil
|
||||
ver := parts[1]
|
||||
if cmpver.Compare(ver, maxVer) == 1 {
|
||||
maxVer = ver
|
||||
}
|
||||
}
|
||||
if maxVer != "" {
|
||||
return maxVer, nil
|
||||
}
|
||||
return "", errors.New("tailscale version not found in output")
|
||||
}
|
||||
@@ -829,7 +836,7 @@ func (up *Updater) switchOutputToFile() (io.Closer, error) {
|
||||
func (up *Updater) installMSI(msi string) error {
|
||||
var err error
|
||||
for tries := 0; tries < 2; tries++ {
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/promptrestart", "/qn")
|
||||
cmd := exec.Command("msiexec.exe", "/i", filepath.Base(msi), "/quiet", "/norestart", "/qn")
|
||||
cmd.Dir = filepath.Dir(msi)
|
||||
cmd.Stdout = up.Stdout
|
||||
cmd.Stderr = up.Stderr
|
||||
|
||||
@@ -251,6 +251,29 @@ tailscale installed size:
|
||||
out: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "multiple versions",
|
||||
out: `
|
||||
tailscale-1.54.1-r0 description:
|
||||
The easiest, most secure way to use WireGuard and 2FA
|
||||
|
||||
tailscale-1.54.1-r0 webpage:
|
||||
https://tailscale.com/
|
||||
|
||||
tailscale-1.54.1-r0 installed size:
|
||||
34 MiB
|
||||
|
||||
tailscale-1.58.2-r0 description:
|
||||
The easiest, most secure way to use WireGuard and 2FA
|
||||
|
||||
tailscale-1.58.2-r0 webpage:
|
||||
https://tailscale.com/
|
||||
|
||||
tailscale-1.58.2-r0 installed size:
|
||||
35 MiB
|
||||
`,
|
||||
want: "1.58.2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -114,7 +114,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
💣 tailscale.com/safesocket from tailscale.com/client/tailscale
|
||||
tailscale.com/syncs from tailscale.com/cmd/derper+
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale+
|
||||
tailscale.com/tailfs from tailscale.com/client/tailscale
|
||||
tailscale.com/tailfs from tailscale.com/client/tailscale+
|
||||
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||
W tailscale.com/tsconst from tailscale.com/net/interfaces
|
||||
tailscale.com/tstime from tailscale.com/derp+
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
"golang.org/x/time/rate"
|
||||
"tailscale.com/atomicfile"
|
||||
"tailscale.com/derp"
|
||||
@@ -36,6 +35,7 @@ import (
|
||||
"tailscale.com/net/stunserver"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -235,7 +235,7 @@ func main() {
|
||||
KeepAlive: *tcpKeepAlive,
|
||||
}
|
||||
|
||||
quietLogger := log.New(logFilter{}, "", 0)
|
||||
quietLogger := log.New(logger.HTTPServerLogFilter{Inner: log.Printf}, "", 0)
|
||||
httpsrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: mux,
|
||||
@@ -452,22 +452,3 @@ func (l *rateLimitedListener) Accept() (net.Conn, error) {
|
||||
l.numAccepts.Add(1)
|
||||
return cn, nil
|
||||
}
|
||||
|
||||
// logFilter is used to filter out useless error logs that are logged to
|
||||
// the net/http.Server.ErrorLog logger.
|
||||
type logFilter struct{}
|
||||
|
||||
func (logFilter) Write(p []byte) (int, error) {
|
||||
b := mem.B(p)
|
||||
if mem.HasSuffix(b, mem.S(": EOF\n")) ||
|
||||
mem.HasSuffix(b, mem.S(": i/o timeout\n")) ||
|
||||
mem.HasSuffix(b, mem.S(": read: connection reset by peer\n")) ||
|
||||
mem.HasSuffix(b, mem.S(": remote error: tls: bad certificate\n")) ||
|
||||
mem.HasSuffix(b, mem.S(": tls: first record does not look like a TLS handshake\n")) {
|
||||
// Skip this log message, but say that we processed it
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
log.Printf("%s", p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
@@ -19,18 +19,31 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
derpMapURL = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map (https:// or file://)")
|
||||
listen = flag.String("listen", ":8030", "HTTP listen address")
|
||||
probeOnce = flag.Bool("once", false, "probe once and print results, then exit; ignores the listen flag")
|
||||
spread = flag.Bool("spread", true, "whether to spread probing over time")
|
||||
interval = flag.Duration("interval", 15*time.Second, "probe interval")
|
||||
derpMapURL = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map (https:// or file://)")
|
||||
listen = flag.String("listen", ":8030", "HTTP listen address")
|
||||
probeOnce = flag.Bool("once", false, "probe once and print results, then exit; ignores the listen flag")
|
||||
spread = flag.Bool("spread", true, "whether to spread probing over time")
|
||||
interval = flag.Duration("interval", 15*time.Second, "probe interval")
|
||||
meshInterval = flag.Duration("mesh-interval", 15*time.Second, "mesh probe interval")
|
||||
stunInterval = flag.Duration("stun-interval", 15*time.Second, "STUN probe interval")
|
||||
tlsInterval = flag.Duration("tls-interval", 15*time.Second, "TLS probe interval")
|
||||
bwInterval = flag.Duration("bw-interval", 0, "bandwidth probe interval (0 = no bandwidth probing)")
|
||||
bwSize = flag.Int64("bw-probe-size-bytes", 1_000_000, "bandwidth probe size")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
p := prober.New().WithSpread(*spread).WithOnce(*probeOnce).WithMetricNamespace("derpprobe")
|
||||
dp, err := prober.DERP(p, *derpMapURL, *interval, *interval, *interval)
|
||||
opts := []prober.DERPOpt{
|
||||
prober.WithMeshProbing(*meshInterval),
|
||||
prober.WithSTUNProbing(*stunInterval),
|
||||
prober.WithTLSProbing(*tlsInterval),
|
||||
}
|
||||
if *bwInterval > 0 {
|
||||
opts = append(opts, prober.WithBandwidthProbing(*bwInterval, *bwSize))
|
||||
}
|
||||
dp, err := prober.DERP(p, *derpMapURL, opts...)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -53,6 +66,7 @@ func main() {
|
||||
mux := http.NewServeMux()
|
||||
tsweb.Debugger(mux)
|
||||
mux.HandleFunc("/", http.HandlerFunc(serveFunc(p)))
|
||||
log.Printf("Listening on %s", *listen)
|
||||
log.Fatal(http.ListenAndServe(*listen, mux))
|
||||
}
|
||||
|
||||
|
||||
348
cmd/k8s-nameserver/main.go
Normal file
348
cmd/k8s-nameserver/main.go
Normal file
@@ -0,0 +1,348 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// k8s-nameserver is a simple nameserver implementation meant to be used with
|
||||
// k8s-operator to allow to resolve magicDNS names associated with tailnet
|
||||
// proxies in cluster.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/miekg/dns"
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
const (
|
||||
// tsNetDomain is the domain that this DNS nameserver has registered a handler for.
|
||||
tsNetDomain = "ts.net"
|
||||
// addr is the the address that the UDP and TCP listeners will listen on.
|
||||
addr = ":1053"
|
||||
|
||||
// The following constants are specific to the nameserver configuration
|
||||
// provided by a mounted Kubernetes Configmap. The Configmap mounted at
|
||||
// /config is the only supported way for configuring this nameserver.
|
||||
defaultDNSConfigDir = "/config"
|
||||
defaultDNSFile = "dns.json"
|
||||
kubeletMountedConfigLn = "..data"
|
||||
)
|
||||
|
||||
// nameserver is a simple nameserver that responds to DNS queries for A records
|
||||
// for ts.net domain names over UDP or TCP. It serves DNS responses from
|
||||
// in-memory IPv4 host records. It is intended to be deployed on Kubernetes with
|
||||
// a ConfigMap mounted at /config that should contain the host records. It
|
||||
// dynamically reconfigures its in-memory mappings as the contents of the
|
||||
// mounted ConfigMap changes.
|
||||
type nameserver struct {
|
||||
// configReader returns the latest desired configuration (host records)
|
||||
// for the nameserver. By default it gets set to a reader that reads
|
||||
// from a Kubernetes ConfigMap mounted at /config, but this can be
|
||||
// overridden in tests.
|
||||
configReader configReaderFunc
|
||||
// configWatcher is a watcher that returns an event when the desired
|
||||
// configuration has changed and the nameserver should update the
|
||||
// in-memory records.
|
||||
configWatcher <-chan string
|
||||
|
||||
mu sync.Mutex // protects following
|
||||
// ip4 are the in-memory hostname -> IP4 mappings that the nameserver
|
||||
// uses to respond to A record queries.
|
||||
ip4 map[dnsname.FQDN][]net.IP
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Ensure that we watch the kube Configmap mounted at /config for
|
||||
// nameserver configuration updates and send events when updates happen.
|
||||
c := ensureWatcherForKubeConfigMap(ctx)
|
||||
|
||||
ns := &nameserver{
|
||||
configReader: configMapConfigReader,
|
||||
configWatcher: c,
|
||||
}
|
||||
|
||||
// Ensure that in-memory records get set up to date now and will get
|
||||
// reset when the configuration changes.
|
||||
ns.runRecordsReconciler(ctx)
|
||||
|
||||
// Register a DNS server handle for ts.net domain names. Not having a
|
||||
// handle registered for any other domain names is how we enforce that
|
||||
// this nameserver can only be used for ts.net domains - querying any
|
||||
// other domain names returns Rcode Refused.
|
||||
dns.HandleFunc(tsNetDomain, ns.handleFunc())
|
||||
|
||||
// Listen for DNS queries over UDP and TCP.
|
||||
udpSig := make(chan os.Signal)
|
||||
tcpSig := make(chan os.Signal)
|
||||
go listenAndServe("udp", addr, udpSig)
|
||||
go listenAndServe("tcp", addr, tcpSig)
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
s := <-sig
|
||||
log.Printf("OS signal (%s) received, shutting down\n", s)
|
||||
cancel() // exit the records reconciler and configmap watcher goroutines
|
||||
udpSig <- s // stop the UDP listener
|
||||
tcpSig <- s // stop the TCP listener
|
||||
}
|
||||
|
||||
// handleFunc is a DNS query handler that can respond to A record queries from
|
||||
// the nameserver's in-memory records.
|
||||
// - If an A record query is received and the
|
||||
// nameserver's in-memory records contain records for the queried domain name,
|
||||
// return a success response.
|
||||
// - If an A record query is received, but the
|
||||
// nameserver's in-memory records do not contain records for the queried domain name,
|
||||
// return NXDOMAIN.
|
||||
// - If an A record query is received, but the queried domain name is not valid, return Format Error.
|
||||
// - If a query is received for any other record type than A, return Not Implemented.
|
||||
func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
h := func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
defer func() {
|
||||
w.WriteMsg(m)
|
||||
}()
|
||||
if len(r.Question) < 1 {
|
||||
log.Print("[unexpected] nameserver received a request with no questions\n")
|
||||
m = r.SetRcodeFormatError(r)
|
||||
return
|
||||
}
|
||||
// TODO (irbekrm): maybe set message compression
|
||||
switch r.Question[0].Qtype {
|
||||
case dns.TypeA:
|
||||
q := r.Question[0].Name
|
||||
fqdn, err := dnsname.ToFQDN(q)
|
||||
if err != nil {
|
||||
m = r.SetRcodeFormatError(r)
|
||||
return
|
||||
}
|
||||
// The only supported use of this nameserver is as a
|
||||
// single source of truth for MagicDNS names by
|
||||
// non-tailnet Kubernetes workloads.
|
||||
m.Authoritative = true
|
||||
m.RecursionAvailable = false
|
||||
|
||||
ips := n.lookupIP4(fqdn)
|
||||
if ips == nil || len(ips) == 0 {
|
||||
// As we are the authoritative nameserver for MagicDNS
|
||||
// names, if we do not have a record for this MagicDNS
|
||||
// name, it does not exist.
|
||||
m = m.SetRcode(r, dns.RcodeNameError)
|
||||
return
|
||||
}
|
||||
// TODO (irbekrm): what TTL?
|
||||
for _, ip := range ips {
|
||||
rr := &dns.A{Hdr: dns.RR_Header{Name: q, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, A: ip}
|
||||
m.SetRcode(r, dns.RcodeSuccess)
|
||||
m.Answer = append(m.Answer, rr)
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
// TODO (irbekrm): implement IPv6 support
|
||||
fallthrough
|
||||
default:
|
||||
log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s\n", r.Question[0].String())
|
||||
m.SetRcode(r, dns.RcodeNotImplemented)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// runRecordsReconciler ensures that nameserver's in-memory records are
|
||||
// reset when the provided configuration changes.
|
||||
func (n *nameserver) runRecordsReconciler(ctx context.Context) {
|
||||
log.Print("updating nameserver's records from the provided configuration...\n")
|
||||
if err := n.resetRecords(); err != nil { // ensure records are up to date before the nameserver starts
|
||||
log.Fatalf("error setting nameserver's records: %v\n", err)
|
||||
}
|
||||
log.Print("nameserver's records were updated\n")
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("context cancelled, exiting records reconciler\n")
|
||||
return
|
||||
case <-n.configWatcher:
|
||||
log.Print("configuration update detected, resetting records\n")
|
||||
if err := n.resetRecords(); err != nil {
|
||||
// TODO (irbekrm): this runs in a
|
||||
// container that will be thrown away,
|
||||
// so this should be ok. But maybe still
|
||||
// need to ensure that the DNS server
|
||||
// terminates connections more
|
||||
// gracefully.
|
||||
log.Fatalf("error resetting records: %v\n", err)
|
||||
}
|
||||
log.Print("nameserver records were reset\n")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// resetRecords sets the in-memory DNS records of this nameserver from the
|
||||
// provided configuration. It does not check for the diff, so the caller is
|
||||
// expected to ensure that this is only called when reset is needed.
|
||||
func (n *nameserver) resetRecords() error {
|
||||
dnsCfgBytes, err := n.configReader()
|
||||
if err != nil {
|
||||
log.Printf("error reading nameserver's configuration: %v\n", err)
|
||||
return err
|
||||
}
|
||||
if dnsCfgBytes == nil || len(dnsCfgBytes) < 1 {
|
||||
log.Print("nameserver's configuration is empty, any in-memory records will be unset\n")
|
||||
n.mu.Lock()
|
||||
n.ip4 = make(map[dnsname.FQDN][]net.IP)
|
||||
n.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
dnsCfg := &operatorutils.Records{}
|
||||
err = json.Unmarshal(dnsCfgBytes, dnsCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error unmarshalling nameserver configuration: %v\n", err)
|
||||
}
|
||||
|
||||
if dnsCfg.Version != operatorutils.Alpha1Version {
|
||||
return fmt.Errorf("unsupported configuration version %s, supported versions are %s\n", dnsCfg.Version, operatorutils.Alpha1Version)
|
||||
}
|
||||
|
||||
ip4 := make(map[dnsname.FQDN][]net.IP)
|
||||
defer func() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
n.ip4 = ip4
|
||||
}()
|
||||
|
||||
if dnsCfg.IP4 == nil || len(dnsCfg.IP4) == 0 {
|
||||
log.Print("nameserver's configuration contains no records, any in-memory records will be unset\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
for fqdn, ips := range dnsCfg.IP4 {
|
||||
fqdn, err := dnsname.ToFQDN(fqdn)
|
||||
if err != nil {
|
||||
log.Printf("invalid nameserver's configuration: %s is not a valid FQDN: %v; skipping this record\n", fqdn, err)
|
||||
continue // one invalid hostname should not break the whole nameserver
|
||||
}
|
||||
for _, ipS := range ips {
|
||||
ip := net.ParseIP(ipS).To4()
|
||||
if ip == nil { // To4 returns nil if IP is not a IPv4 address
|
||||
log.Printf("invalid nameserver's configuration: %v does not appear to be an IPv4 address; skipping this record\n", ipS)
|
||||
continue // one invalid IP address should not break the whole nameserver
|
||||
}
|
||||
ip4[fqdn] = []net.IP{ip}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listenAndServe starts a DNS server for the provided network and address.
|
||||
func listenAndServe(net, addr string, shutdown chan os.Signal) {
|
||||
s := &dns.Server{Addr: addr, Net: net}
|
||||
go func() {
|
||||
<-shutdown
|
||||
log.Printf("shutting down server for %s\n", net)
|
||||
s.Shutdown()
|
||||
}()
|
||||
log.Printf("listening for %s queries on %s\n", net, addr)
|
||||
if err := s.ListenAndServe(); err != nil {
|
||||
log.Fatalf("error running %s server: %v\n", net, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureWatcherForKubeConfigMap sets up a new file watcher for the ConfigMap
|
||||
// that's expected to be mounted at /config. Returns a channel that receives an
|
||||
// event every time the contents get updated.
|
||||
func ensureWatcherForKubeConfigMap(ctx context.Context) chan string {
|
||||
c := make(chan string)
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Fatalf("error creating a new watcher for the mounted ConfigMap: %v\n", err)
|
||||
}
|
||||
// kubelet mounts configmap to a Pod using a series of symlinks, one of
|
||||
// which is <mount-dir>/..data that Kubernetes recommends consumers to
|
||||
// use if they need to monitor changes
|
||||
// https://github.com/kubernetes/kubernetes/blob/v1.28.1/pkg/volume/util/atomic_writer.go#L39-L61
|
||||
toWatch := filepath.Join(defaultDNSConfigDir, kubeletMountedConfigLn)
|
||||
go func() {
|
||||
defer watcher.Close()
|
||||
log.Printf("starting file watch for %s\n", defaultDNSConfigDir)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Print("context cancelled, exiting ConfigMap watcher\n")
|
||||
return
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
log.Fatal("watcher finished; exiting")
|
||||
}
|
||||
if event.Name == toWatch {
|
||||
msg := fmt.Sprintf("ConfigMap update received: %s\n", event)
|
||||
log.Print(msg)
|
||||
c <- msg
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
// TODO (irbekrm): this runs in a
|
||||
// container that will be thrown away,
|
||||
// so this should be ok. But maybe still
|
||||
// need to ensure that the DNS server
|
||||
// terminates connections more
|
||||
// gracefully.
|
||||
log.Fatalf("[unexpected] configuration watcher error: errors watcher finished: %v\n", err)
|
||||
}
|
||||
if err != nil {
|
||||
// TODO (irbekrm): this runs in a
|
||||
// container that will be thrown away,
|
||||
// so this should be ok. But maybe still
|
||||
// need to ensure that the DNS server
|
||||
// terminates connections more
|
||||
// gracefully.
|
||||
log.Fatalf("[unexpected] error watching configuration: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err = watcher.Add(defaultDNSConfigDir); err != nil {
|
||||
log.Fatalf("failed setting up a watcher for the mounted ConfigMap: %v\n", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// configReaderFunc is a function that returns the desired nameserver configuration.
|
||||
type configReaderFunc func() ([]byte, error)
|
||||
|
||||
// configMapConfigReader reads the desired nameserver configuration from a
|
||||
// dns.json file in a ConfigMap mounted at /config.
|
||||
var configMapConfigReader configReaderFunc = func() ([]byte, error) {
|
||||
if contents, err := os.ReadFile(filepath.Join(defaultDNSConfigDir, defaultDNSFile)); err == nil {
|
||||
return contents, nil
|
||||
} else if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// lookupIP4 returns any IPv4 addresses for the given FQDN from nameserver's
|
||||
// in-memory records.
|
||||
func (n *nameserver) lookupIP4(fqdn dnsname.FQDN) []net.IP {
|
||||
if n.ip4 == nil {
|
||||
return nil
|
||||
}
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
f := n.ip4[fqdn]
|
||||
return f
|
||||
}
|
||||
227
cmd/k8s-nameserver/main_test.go
Normal file
227
cmd/k8s-nameserver/main_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/miekg/dns"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
func TestNameserver(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ip4 map[dnsname.FQDN][]net.IP
|
||||
query *dns.Msg
|
||||
wantResp *dns.Msg
|
||||
}{
|
||||
{
|
||||
name: "A record query, record exists",
|
||||
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||
query: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeA}},
|
||||
MsgHdr: dns.MsgHdr{Id: 1, RecursionDesired: true},
|
||||
},
|
||||
wantResp: &dns.Msg{
|
||||
Answer: []dns.RR{&dns.A{Hdr: dns.RR_Header{
|
||||
Name: "foo.bar.com", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0},
|
||||
A: net.IP{1, 2, 3, 4}}},
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeA}},
|
||||
MsgHdr: dns.MsgHdr{
|
||||
Id: 1,
|
||||
Rcode: dns.RcodeSuccess,
|
||||
RecursionAvailable: false,
|
||||
RecursionDesired: true,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
Authoritative: true,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "A record query, record does not exist",
|
||||
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||
query: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeA}},
|
||||
MsgHdr: dns.MsgHdr{Id: 1},
|
||||
},
|
||||
wantResp: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeA}},
|
||||
MsgHdr: dns.MsgHdr{
|
||||
Id: 1,
|
||||
Rcode: dns.RcodeNameError,
|
||||
RecursionAvailable: false,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
Authoritative: true,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "A record query, but the name is not a valid FQDN",
|
||||
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||
query: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo..bar.com", Qtype: dns.TypeA}},
|
||||
MsgHdr: dns.MsgHdr{Id: 1},
|
||||
},
|
||||
wantResp: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo..bar.com", Qtype: dns.TypeA}},
|
||||
MsgHdr: dns.MsgHdr{
|
||||
Id: 1,
|
||||
Rcode: dns.RcodeFormatError,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "AAAA record query",
|
||||
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||
query: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||
MsgHdr: dns.MsgHdr{Id: 1},
|
||||
},
|
||||
wantResp: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||
MsgHdr: dns.MsgHdr{
|
||||
Id: 1,
|
||||
Rcode: dns.RcodeNotImplemented,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "AAAA record query",
|
||||
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||
query: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||
MsgHdr: dns.MsgHdr{Id: 1},
|
||||
},
|
||||
wantResp: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeAAAA}},
|
||||
MsgHdr: dns.MsgHdr{
|
||||
Id: 1,
|
||||
Rcode: dns.RcodeNotImplemented,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "CNAME record query",
|
||||
ip4: map[dnsname.FQDN][]net.IP{dnsname.FQDN("foo.bar.com."): {{1, 2, 3, 4}}},
|
||||
query: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeCNAME}},
|
||||
MsgHdr: dns.MsgHdr{Id: 1},
|
||||
},
|
||||
wantResp: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "foo.bar.com", Qtype: dns.TypeCNAME}},
|
||||
MsgHdr: dns.MsgHdr{
|
||||
Id: 1,
|
||||
Rcode: dns.RcodeNotImplemented,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ns := &nameserver{
|
||||
ip4: tt.ip4,
|
||||
}
|
||||
handler := ns.handleFunc()
|
||||
fakeRespW := &fakeResponseWriter{}
|
||||
handler(fakeRespW, tt.query)
|
||||
if diff := cmp.Diff(*fakeRespW.msg, *tt.wantResp); diff != "" {
|
||||
t.Fatalf("unexpected response (-got +want): \n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetRecords(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config []byte
|
||||
hasIp4 map[dnsname.FQDN][]net.IP
|
||||
wantsIp4 map[dnsname.FQDN][]net.IP
|
||||
wantsErr bool
|
||||
}{
|
||||
{
|
||||
name: "previously empty nameserver.ip4 gets set",
|
||||
config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
|
||||
wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}},
|
||||
},
|
||||
{
|
||||
name: "nameserver.ip4 gets reset",
|
||||
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
|
||||
config: []byte(`{"version": "v1alpha1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
|
||||
wantsIp4: map[dnsname.FQDN][]net.IP{"foo.bar.com.": {{1, 2, 3, 4}}},
|
||||
},
|
||||
{
|
||||
name: "configuration with incompatible version",
|
||||
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
|
||||
config: []byte(`{"version": "v1beta1", "ip4": {"foo.bar.com": ["1.2.3.4"]}}`),
|
||||
wantsIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "nameserver.ip4 gets reset to empty config when no configuration is provided",
|
||||
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
|
||||
wantsIp4: make(map[dnsname.FQDN][]net.IP),
|
||||
},
|
||||
{
|
||||
name: "nameserver.ip4 gets reset to empty config when the provided configuration is empty",
|
||||
hasIp4: map[dnsname.FQDN][]net.IP{"baz.bar.com.": {{1, 1, 3, 3}}},
|
||||
config: []byte(`{"version": "v1alpha1", "ip4": {}}`),
|
||||
wantsIp4: make(map[dnsname.FQDN][]net.IP),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ns := &nameserver{
|
||||
ip4: tt.hasIp4,
|
||||
configReader: func() ([]byte, error) { return tt.config, nil },
|
||||
}
|
||||
if err := ns.resetRecords(); err == nil == tt.wantsErr {
|
||||
t.Errorf("resetRecords() returned err: %v, wantsErr: %v", err, tt.wantsErr)
|
||||
}
|
||||
if diff := cmp.Diff(ns.ip4, tt.wantsIp4); diff != "" {
|
||||
t.Fatalf("unexpected nameserver.ip4 contents (-got +want): \n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeResponseWriter is a faked out dns.ResponseWriter that can be used in
|
||||
// tests that need to read the response message that was written.
|
||||
type fakeResponseWriter struct {
|
||||
msg *dns.Msg
|
||||
}
|
||||
|
||||
var _ dns.ResponseWriter = &fakeResponseWriter{}
|
||||
|
||||
func (fr *fakeResponseWriter) WriteMsg(msg *dns.Msg) error {
|
||||
fr.msg = msg
|
||||
return nil
|
||||
}
|
||||
func (fr *fakeResponseWriter) LocalAddr() net.Addr {
|
||||
return nil
|
||||
}
|
||||
func (fr *fakeResponseWriter) RemoteAddr() net.Addr {
|
||||
return nil
|
||||
}
|
||||
func (fr *fakeResponseWriter) Write([]byte) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (fr *fakeResponseWriter) Close() error {
|
||||
return nil
|
||||
}
|
||||
func (fr *fakeResponseWriter) TsigStatus() error {
|
||||
return nil
|
||||
}
|
||||
func (fr *fakeResponseWriter) TsigTimersOnly(bool) {}
|
||||
func (fr *fakeResponseWriter) Hijack() {}
|
||||
@@ -67,45 +67,40 @@ func TestConnector(t *testing.T) {
|
||||
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",
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
parentType: "connector",
|
||||
hostname: "test-connector",
|
||||
isExitNode: true,
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 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(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 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(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 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(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// Re-add the subnet router.
|
||||
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
|
||||
@@ -114,9 +109,8 @@ func TestConnector(t *testing.T) {
|
||||
}
|
||||
})
|
||||
opts.subnetRoutes = "10.44.0.0/20"
|
||||
opts.confFileHash = "bacba177bcfe3849065cf6fee53d658a9bb4144197ac5b861727d69ea99742bb"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// Delete the Connector.
|
||||
if err = fc.Delete(context.Background(), cn); err != nil {
|
||||
@@ -152,25 +146,22 @@ func TestConnector(t *testing.T) {
|
||||
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",
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
parentType: "connector",
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
hostname: "test-connector",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 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(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// Delete the Connector.
|
||||
if err = fc.Delete(context.Background(), cn); err != nil {
|
||||
@@ -239,17 +230,15 @@ func TestConnectorWithProxyClass(t *testing.T) {
|
||||
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",
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
parentType: "connector",
|
||||
hostname: "test-connector",
|
||||
isExitNode: true,
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 2. Update Connector to specify a ProxyClass. ProxyClass is not yet
|
||||
// ready, so its configuration is NOT applied to the Connector
|
||||
@@ -258,7 +247,7 @@ func TestConnectorWithProxyClass(t *testing.T) {
|
||||
conn.Spec.ProxyClass = "custom-metadata"
|
||||
})
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 3. ProxyClass is set to Ready by proxy-class reconciler. Connector
|
||||
// get reconciled and configuration from the ProxyClass is applied to
|
||||
@@ -272,12 +261,8 @@ func TestConnectorWithProxyClass(t *testing.T) {
|
||||
}}}
|
||||
})
|
||||
opts.proxyClass = pc.Name
|
||||
// We lose the auth key on second reconcile, because in code it's set to
|
||||
// StringData, but is actually read from Data. This works with a real
|
||||
// API server, but not with our test setup here.
|
||||
opts.confFileHash = "1499b591fd97a50f0330db6ec09979792c49890cf31f5da5bb6a3f50dba1e77a"
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 4. Connector.spec.proxyClass field is unset, Connector gets
|
||||
// reconciled and configuration from the ProxyClass is removed from the
|
||||
@@ -287,5 +272,5 @@ func TestConnectorWithProxyClass(t *testing.T) {
|
||||
})
|
||||
opts.proxyClass = ""
|
||||
expectReconciled(t, cr, "", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ rules:
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["connectors", "connectors/status", "proxyclasses", "proxyclasses/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["dnsconfigs", "dnsconfigs/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
@@ -45,10 +48,10 @@ metadata:
|
||||
namespace: {{ .Release.Namespace }}
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resources: ["secrets", "serviceaccounts", "configmaps"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["statefulsets"]
|
||||
resources: ["statefulsets", "deployments"]
|
||||
verbs: ["*"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
|
||||
@@ -12,7 +12,7 @@ oauth: {}
|
||||
# of chart installation. We do not use Helm's CRD installation mechanism as that
|
||||
# does not allow for upgrading CRDs.
|
||||
# https://helm.sh/docs/chart_best_practices/custom_resource_definitions/
|
||||
installCRDs: "true"
|
||||
installCRDs: true
|
||||
|
||||
operatorConfig:
|
||||
# ACL tag that operator will be tagged with. Operator must be made owner of
|
||||
|
||||
96
cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml
Normal file
96
cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml
Normal file
@@ -0,0 +1,96 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
name: dnsconfigs.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
names:
|
||||
kind: DNSConfig
|
||||
listKind: DNSConfigList
|
||||
plural: dnsconfigs
|
||||
shortNames:
|
||||
- dc
|
||||
singular: dnsconfig
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- description: Service IP address of the nameserver
|
||||
jsonPath: .status.nameserverStatus.ip
|
||||
name: NameserverIP
|
||||
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:
|
||||
type: object
|
||||
required:
|
||||
- nameserver
|
||||
properties:
|
||||
nameserver:
|
||||
type: object
|
||||
properties:
|
||||
image:
|
||||
type: object
|
||||
properties:
|
||||
repo:
|
||||
type: string
|
||||
tag:
|
||||
type: string
|
||||
status:
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
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
|
||||
nameserverStatus:
|
||||
type: object
|
||||
properties:
|
||||
ip:
|
||||
type: string
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
4
cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml
Normal file
4
cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: dnsconfig
|
||||
37
cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml
Normal file
37
cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nameserver
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 5
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nameserver
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nameserver
|
||||
spec:
|
||||
containers:
|
||||
- imagePullPolicy: IfNotPresent
|
||||
name: nameserver
|
||||
ports:
|
||||
- name: tcp
|
||||
protocol: TCP
|
||||
containerPort: 1053
|
||||
- name: udp
|
||||
protocol: UDP
|
||||
containerPort: 1053
|
||||
volumeMounts:
|
||||
- name: dnsconfig
|
||||
mountPath: /config
|
||||
restartPolicy: Always
|
||||
serviceAccount: nameserver
|
||||
serviceAccountName: nameserver
|
||||
volumes:
|
||||
- name: dnsconfig
|
||||
configMap:
|
||||
name: dnsconfig
|
||||
6
cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml
Normal file
6
cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: nameserver
|
||||
imagePullSecrets:
|
||||
- name: foo
|
||||
16
cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml
Normal file
16
cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: nameserver
|
||||
spec:
|
||||
selector:
|
||||
app: nameserver
|
||||
ports:
|
||||
- name: udp
|
||||
targetPort: 1053
|
||||
port: 53
|
||||
protocol: UDP
|
||||
- name: tcp
|
||||
targetPort: 1053
|
||||
port: 53
|
||||
protocol: TCP
|
||||
@@ -158,6 +158,103 @@ spec:
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
name: dnsconfigs.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
names:
|
||||
kind: DNSConfig
|
||||
listKind: DNSConfigList
|
||||
plural: dnsconfigs
|
||||
shortNames:
|
||||
- dc
|
||||
singular: dnsconfig
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- description: Service IP address of the nameserver
|
||||
jsonPath: .status.nameserverStatus.ip
|
||||
name: NameserverIP
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
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:
|
||||
properties:
|
||||
nameserver:
|
||||
properties:
|
||||
image:
|
||||
properties:
|
||||
repo:
|
||||
type: string
|
||||
tag:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
required:
|
||||
- nameserver
|
||||
type: object
|
||||
status:
|
||||
properties:
|
||||
conditions:
|
||||
items:
|
||||
description: ConnectorCondition contains condition information for a Connector.
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
|
||||
format: date-time
|
||||
type: string
|
||||
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.
|
||||
format: int64
|
||||
type: integer
|
||||
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
|
||||
required:
|
||||
- status
|
||||
- type
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
nameserverStatus:
|
||||
properties:
|
||||
ip:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
@@ -691,6 +788,16 @@ rules:
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
- apiGroups:
|
||||
- tailscale.com
|
||||
resources:
|
||||
- dnsconfigs
|
||||
- dnsconfigs/status
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
@@ -715,12 +822,15 @@ rules:
|
||||
- ""
|
||||
resources:
|
||||
- secrets
|
||||
- serviceaccounts
|
||||
- configmaps
|
||||
verbs:
|
||||
- '*'
|
||||
- apiGroups:
|
||||
- apps
|
||||
resources:
|
||||
- statefulsets
|
||||
- deployments
|
||||
verbs:
|
||||
- '*'
|
||||
---
|
||||
|
||||
@@ -28,8 +28,6 @@ spec:
|
||||
env:
|
||||
- name: TS_USERSPACE
|
||||
value: "false"
|
||||
- name: TS_AUTH_ONCE
|
||||
value: "true"
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
|
||||
@@ -20,5 +20,3 @@ spec:
|
||||
env:
|
||||
- name: TS_USERSPACE
|
||||
value: "true"
|
||||
- name: TS_AUTH_ONCE
|
||||
value: "true"
|
||||
|
||||
@@ -22,9 +22,11 @@ const (
|
||||
operatorDeploymentFilesPath = "cmd/k8s-operator/deploy"
|
||||
connectorCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_connectors.yaml"
|
||||
proxyClassCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_proxyclasses.yaml"
|
||||
dnsConfigCRDPath = operatorDeploymentFilesPath + "/crds/tailscale.com_dnsconfigs.yaml"
|
||||
helmTemplatesPath = operatorDeploymentFilesPath + "/chart/templates"
|
||||
connectorCRDHelmTemplatePath = helmTemplatesPath + "/connector.yaml"
|
||||
proxyClassCRDHelmTemplatePath = helmTemplatesPath + "/proxyclass.yaml"
|
||||
dnsConfigCRDHelmTemplatePath = helmTemplatesPath + "/dnsconfig.yaml"
|
||||
|
||||
helmConditionalStart = "{{ if .Values.installCRDs -}}\n"
|
||||
helmConditionalEnd = "{{- end -}}"
|
||||
@@ -108,7 +110,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// generate places tailscale.com CRDs (currently Connector and ProxyClass) into
|
||||
// generate places tailscale.com CRDs (currently Connector, ProxyClass and DNSConfig) into
|
||||
// the Helm chart templates behind .Values.installCRDs=true condition (true by
|
||||
// default).
|
||||
func generate(baseDir string) error {
|
||||
@@ -140,6 +142,9 @@ func generate(baseDir string) error {
|
||||
if err := addCRDToHelm(proxyClassCRDPath, proxyClassCRDHelmTemplatePath); err != nil {
|
||||
return fmt.Errorf("error adding ProxyClass CRD to Helm templates: %w", err)
|
||||
}
|
||||
if err := addCRDToHelm(dnsConfigCRDPath, dnsConfigCRDHelmTemplatePath); err != nil {
|
||||
return fmt.Errorf("error adding DNSConfig CRD to Helm templates: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -151,5 +156,8 @@ func cleanup(baseDir string) error {
|
||||
if err := os.Remove(filepath.Join(baseDir, proxyClassCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("error cleaning up ProxyClass CRD template: %w", err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(baseDir, dnsConfigCRDHelmTemplatePath)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("error cleaning up DNSConfig CRD template: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ func Test_generate(t *testing.T) {
|
||||
if !strings.Contains(installContentsWithCRD.String(), "name: proxyclasses.tailscale.com") {
|
||||
t.Errorf("ProxyClass CRD not found in default chart install")
|
||||
}
|
||||
if !strings.Contains(installContentsWithCRD.String(), "name: dnsconfigs.tailscale.com") {
|
||||
t.Errorf("DNSConfig CRD not found in default chart install")
|
||||
}
|
||||
|
||||
// Test that CRDs can be excluded from Helm chart install
|
||||
installContentsWithoutCRD := bytes.NewBuffer([]byte{})
|
||||
@@ -71,4 +74,7 @@ func Test_generate(t *testing.T) {
|
||||
if strings.Contains(installContentsWithoutCRD.String(), "name: connectors.tailscale.com") {
|
||||
t.Errorf("ProxyClass CRD found in chart install that should not contain a CRD")
|
||||
}
|
||||
if strings.Contains(installContentsWithoutCRD.String(), "name: dnsconfigs.tailscale.com") {
|
||||
t.Errorf("DNSConfig CRD found in chart install that should not contain a CRD")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,9 @@ func TestTailscaleIngress(t *testing.T) {
|
||||
}
|
||||
opts.serveConfig = serveConfig
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"))
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"), nil)
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 2. Ingress status gets updated with ingress proxy's MagicDNS name
|
||||
// once that becomes available.
|
||||
@@ -117,7 +117,7 @@ func TestTailscaleIngress(t *testing.T) {
|
||||
{Hostname: "foo.tailnetxyz.ts.net", Ports: []networkingv1.IngressPortStatus{{Port: 443, Protocol: "TCP"}}},
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, ing)
|
||||
expectEqual(t, fc, ing, nil)
|
||||
|
||||
// 3. Resources get created for Ingress that should allow forwarding
|
||||
// cluster traffic
|
||||
@@ -126,7 +126,7 @@ func TestTailscaleIngress(t *testing.T) {
|
||||
})
|
||||
opts.shouldEnableForwardingClusterTrafficViaIngress = true
|
||||
expectReconciled(t, ingR, "default", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 4. Resources get cleaned up when Ingress class is unset
|
||||
mustUpdate(t, fc, "default", "test", func(ing *networkingv1.Ingress) {
|
||||
@@ -231,9 +231,9 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
||||
}
|
||||
opts.serveConfig = serveConfig
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"))
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "ingress"), nil)
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 2. Ingress is updated to specify a ProxyClass, ProxyClass is not yet
|
||||
// ready, so proxy resource configuration does not change.
|
||||
@@ -241,7 +241,7 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
||||
mak.Set(&ing.ObjectMeta.Labels, LabelProxyClass, "custom-metadata")
|
||||
})
|
||||
expectReconciled(t, ingR, "default", "test")
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 3. ProxyClass is set to Ready by proxy-class reconciler. Ingress get
|
||||
// reconciled and configuration from the ProxyClass is applied to the
|
||||
@@ -256,7 +256,7 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, ingR, "default", "test")
|
||||
opts.proxyClass = pc.Name
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 4. tailscale.com/proxy-class label is removed from the Ingress, the
|
||||
// Ingress gets reconciled and the custom ProxyClass configuration is
|
||||
@@ -266,5 +266,5 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, ingR, "default", "test")
|
||||
opts.proxyClass = ""
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTSUserspace(t, fc, opts), removeHashAnnotation)
|
||||
}
|
||||
|
||||
278
cmd/k8s-operator/nameserver.go
Normal file
278
cmd/k8s-operator/nameserver.go
Normal file
@@ -0,0 +1,278 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// tailscale-operator provides a way to expose services running in a Kubernetes
|
||||
// cluster to your Tailnet and to make Tailscale nodes available to cluster
|
||||
// workloads
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
xslices "golang.org/x/exp/slices"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
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"
|
||||
"sigs.k8s.io/yaml"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
reasonNameserverCreationFailed = "NameserverCreationFailed"
|
||||
reasonMultipleDNSConfigsPresent = "MultipleDNSConfigsPresent"
|
||||
|
||||
reasonNameserverCreated = "NameserverCreated"
|
||||
|
||||
messageNameserverCreationFailed = "Failed creating nameserver resources: %v"
|
||||
messageMultipleDNSConfigsPresent = "Multiple DNSConfig resources found in cluster. Please ensure no more than one is present."
|
||||
)
|
||||
|
||||
// NameserverReconciler knows how to create nameserver resources in cluster in
|
||||
// response to users applying DNSConfig.
|
||||
type NameserverReconciler struct {
|
||||
client.Client
|
||||
logger *zap.SugaredLogger
|
||||
recorder record.EventRecorder
|
||||
clock tstime.Clock
|
||||
tsNamespace string
|
||||
|
||||
mu sync.Mutex // protects following
|
||||
managedNameservers set.Slice[types.UID] // one or none
|
||||
}
|
||||
|
||||
var (
|
||||
gaugeNameserverResources = clientmetric.NewGauge("k8s_nameserver_resources")
|
||||
)
|
||||
|
||||
func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
logger := a.logger.With("dnsConfig", req.Name)
|
||||
logger.Debugf("starting reconcile")
|
||||
defer logger.Debugf("reconcile finished")
|
||||
|
||||
var dnsCfg tsapi.DNSConfig
|
||||
err = a.Get(ctx, req.NamespacedName, &dnsCfg)
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Request object not found, could have been deleted after reconcile request.
|
||||
logger.Debugf("dnsconfig not found, assuming it was deleted")
|
||||
return reconcile.Result{}, nil
|
||||
} else if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get dnsconfig: %w", err)
|
||||
}
|
||||
if !dnsCfg.DeletionTimestamp.IsZero() {
|
||||
ix := xslices.Index(dnsCfg.Finalizers, FinalizerName)
|
||||
if ix < 0 {
|
||||
logger.Debugf("no finalizer, nothing to do")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
logger.Info("Cleaning up DNSConfig resources")
|
||||
if err := a.maybeCleanup(ctx, &dnsCfg, logger); err != nil {
|
||||
logger.Errorf("error cleaning up reconciler resource: %v", err)
|
||||
return res, err
|
||||
}
|
||||
dnsCfg.Finalizers = append(dnsCfg.Finalizers[:ix], dnsCfg.Finalizers[ix+1:]...)
|
||||
if err := a.Update(ctx, &dnsCfg); err != nil {
|
||||
logger.Errorf("error removing finalizer: %v", err)
|
||||
return reconcile.Result{}, err
|
||||
}
|
||||
logger.Infof("Nameserver resources cleaned up")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
oldCnStatus := dnsCfg.Status.DeepCopy()
|
||||
setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
tsoperator.SetDNSConfigCondition(dnsCfg, tsapi.NameserverReady, status, reason, message, dnsCfg.Generation, a.clock, logger)
|
||||
if !apiequality.Semantic.DeepEqual(oldCnStatus, dnsCfg.Status) {
|
||||
// An error encountered here should get returned by the Reconcile function.
|
||||
if updateErr := a.Client.Status().Update(ctx, dnsCfg); updateErr != nil {
|
||||
err = errors.Wrap(err, updateErr.Error())
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
var dnsCfgs tsapi.DNSConfigList
|
||||
if err := a.List(ctx, &dnsCfgs); err != nil {
|
||||
return res, fmt.Errorf("error listing DNSConfigs: %w", err)
|
||||
}
|
||||
if len(dnsCfgs.Items) > 1 { // enforce DNSConfig to be a singleton
|
||||
msg := "invalid cluster configuration: more than one tailscale.com/dnsconfigs found. Please ensure that no more than one is created."
|
||||
logger.Error(msg)
|
||||
a.recorder.Event(&dnsCfg, corev1.EventTypeWarning, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent)
|
||||
setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent)
|
||||
}
|
||||
|
||||
if !slices.Contains(dnsCfg.Finalizers, FinalizerName) {
|
||||
logger.Infof("ensuring nameserver resources")
|
||||
dnsCfg.Finalizers = append(dnsCfg.Finalizers, FinalizerName)
|
||||
if err := a.Update(ctx, &dnsCfg); err != nil {
|
||||
msg := fmt.Sprintf(messageNameserverCreationFailed, err)
|
||||
logger.Error(msg)
|
||||
return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonNameserverCreationFailed, msg)
|
||||
}
|
||||
}
|
||||
if err := a.maybeProvision(ctx, &dnsCfg, logger); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("error provisioning nameserver resources: %w", err)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.managedNameservers.Add(dnsCfg.UID)
|
||||
a.mu.Unlock()
|
||||
gaugeNameserverResources.Set(int64(a.managedNameservers.Len()))
|
||||
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: a.tsNamespace},
|
||||
}
|
||||
if err := a.Client.Get(ctx, client.ObjectKeyFromObject(svc), svc); err != nil {
|
||||
return res, fmt.Errorf("error getting Service: %w", err)
|
||||
}
|
||||
if ip := svc.Spec.ClusterIP; ip != "" && ip != "None" {
|
||||
dnsCfg.Status.NameserverStatus = &tsapi.NameserverStatus{
|
||||
IP: ip,
|
||||
}
|
||||
return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated)
|
||||
}
|
||||
logger.Info("nameserver Service does not have an IP address allocated, waiting...")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
func nameserverResourceLabels(name, namespace string) map[string]string {
|
||||
labels := childResourceLabels(name, namespace, "nameserver")
|
||||
labels["app.kubernetes.io/name"] = "tailscale"
|
||||
labels["app.kubernetes.io/component"] = "nameserver"
|
||||
return labels
|
||||
}
|
||||
|
||||
func (a *NameserverReconciler) maybeProvision(ctx context.Context, tsDNSCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error {
|
||||
labels := nameserverResourceLabels(tsDNSCfg.Name, a.tsNamespace)
|
||||
dCfg := &deployConfig{
|
||||
ownerRefs: []metav1.OwnerReference{*metav1.NewControllerRef(tsDNSCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig"))},
|
||||
namespace: a.tsNamespace,
|
||||
labels: labels,
|
||||
}
|
||||
if tsDNSCfg.Spec.Nameserver.Image.Repo != "" {
|
||||
dCfg.imageRepo = tsDNSCfg.Spec.Nameserver.Image.Repo
|
||||
}
|
||||
if tsDNSCfg.Spec.Nameserver.Image.Tag != "" {
|
||||
dCfg.imageTag = tsDNSCfg.Spec.Nameserver.Image.Tag
|
||||
}
|
||||
for _, deployable := range []deployable{saDeployable, deployDeployable, svcDeployable, cmDeployable} {
|
||||
if err := deployable.updateObj(ctx, dCfg, a.Client); err != nil {
|
||||
return fmt.Errorf("error reconciling %s: %w", deployable.kind, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeCleanup removes DNSConfig from being tracked. The cluster resources
|
||||
// created, will be automatically garbage collected as they are owned by the
|
||||
// DNSConfig.
|
||||
func (a *NameserverReconciler) maybeCleanup(ctx context.Context, dnsCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error {
|
||||
a.mu.Lock()
|
||||
a.managedNameservers.Remove(dnsCfg.UID)
|
||||
a.mu.Unlock()
|
||||
gaugeNameserverResources.Set(int64(a.managedNameservers.Len()))
|
||||
return nil
|
||||
}
|
||||
|
||||
type deployable struct {
|
||||
kind string
|
||||
updateObj func(context.Context, *deployConfig, client.Client) error
|
||||
}
|
||||
|
||||
type deployConfig struct {
|
||||
imageRepo string
|
||||
imageTag string
|
||||
labels map[string]string
|
||||
ownerRefs []metav1.OwnerReference
|
||||
namespace string
|
||||
}
|
||||
|
||||
var (
|
||||
//go:embed deploy/manifests/nameserver/cm.yaml
|
||||
cmYaml []byte
|
||||
//go:embed deploy/manifests/nameserver/deploy.yaml
|
||||
deployYaml []byte
|
||||
//go:embed deploy/manifests/nameserver/sa.yaml
|
||||
saYaml []byte
|
||||
//go:embed deploy/manifests/nameserver/svc.yaml
|
||||
svcYaml []byte
|
||||
|
||||
deployDeployable = deployable{
|
||||
kind: "Deployment",
|
||||
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||
d := new(appsv1.Deployment)
|
||||
if err := yaml.Unmarshal(deployYaml, &d); err != nil {
|
||||
return fmt.Errorf("error unmarshalling Deployment yaml: %w", err)
|
||||
}
|
||||
d.Spec.Template.Spec.Containers[0].Image = fmt.Sprintf("%s:%s", cfg.imageRepo, cfg.imageTag)
|
||||
d.ObjectMeta.Namespace = cfg.namespace
|
||||
d.ObjectMeta.Labels = cfg.labels
|
||||
d.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||
updateF := func(oldD *appsv1.Deployment) {
|
||||
oldD.Spec = d.Spec
|
||||
}
|
||||
_, err := createOrUpdate[appsv1.Deployment](ctx, kubeClient, cfg.namespace, d, updateF)
|
||||
return err
|
||||
},
|
||||
}
|
||||
saDeployable = deployable{
|
||||
kind: "ServiceAccount",
|
||||
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||
sa := new(corev1.ServiceAccount)
|
||||
if err := yaml.Unmarshal(saYaml, &sa); err != nil {
|
||||
return fmt.Errorf("error unmarshalling ServiceAccount yaml: %w", err)
|
||||
}
|
||||
sa.ObjectMeta.Labels = cfg.labels
|
||||
sa.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||
sa.ObjectMeta.Namespace = cfg.namespace
|
||||
_, err := createOrUpdate(ctx, kubeClient, cfg.namespace, sa, func(*corev1.ServiceAccount) {})
|
||||
return err
|
||||
},
|
||||
}
|
||||
svcDeployable = deployable{
|
||||
kind: "Service",
|
||||
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||
svc := new(corev1.Service)
|
||||
if err := yaml.Unmarshal(svcYaml, &svc); err != nil {
|
||||
return fmt.Errorf("error unmarshalling Service yaml: %w", err)
|
||||
}
|
||||
svc.ObjectMeta.Labels = cfg.labels
|
||||
svc.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||
svc.ObjectMeta.Namespace = cfg.namespace
|
||||
_, err := createOrUpdate[corev1.Service](ctx, kubeClient, cfg.namespace, svc, func(*corev1.Service) {})
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmDeployable = deployable{
|
||||
kind: "ConfigMap",
|
||||
updateObj: func(ctx context.Context, cfg *deployConfig, kubeClient client.Client) error {
|
||||
cm := new(corev1.ConfigMap)
|
||||
if err := yaml.Unmarshal(cmYaml, &cm); err != nil {
|
||||
return fmt.Errorf("error unmarshalling ConfigMap yaml: %w", err)
|
||||
}
|
||||
cm.ObjectMeta.Labels = cfg.labels
|
||||
cm.ObjectMeta.OwnerReferences = cfg.ownerRefs
|
||||
cm.ObjectMeta.Namespace = cfg.namespace
|
||||
_, err := createOrUpdate[corev1.ConfigMap](ctx, kubeClient, cfg.namespace, cm, func(cm *corev1.ConfigMap) {})
|
||||
return err
|
||||
},
|
||||
}
|
||||
)
|
||||
118
cmd/k8s-operator/nameserver_test.go
Normal file
118
cmd/k8s-operator/nameserver_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// tailscale-operator provides a way to expose services running in a Kubernetes
|
||||
// cluster to your Tailnet and to make Tailscale nodes available to cluster
|
||||
// workloads
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/yaml"
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
func TestNameserverReconciler(t *testing.T) {
|
||||
dnsCfg := &tsapi.DNSConfig{
|
||||
TypeMeta: metav1.TypeMeta{Kind: "DNSConfig", APIVersion: "tailscale.com/v1alpha1"},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
},
|
||||
Spec: tsapi.DNSConfigSpec{
|
||||
Nameserver: &tsapi.Nameserver{
|
||||
Image: &tsapi.Image{
|
||||
Repo: "test",
|
||||
Tag: "v0.0.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fc := fake.NewClientBuilder().
|
||||
WithScheme(tsapi.GlobalScheme).
|
||||
WithObjects(dnsCfg).
|
||||
WithStatusSubresource(dnsCfg).
|
||||
Build()
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||
nr := &NameserverReconciler{
|
||||
Client: fc,
|
||||
clock: cl,
|
||||
logger: zl.Sugar(),
|
||||
tsNamespace: "tailscale",
|
||||
}
|
||||
expectReconciled(t, nr, "", "test")
|
||||
// Verify that nameserver Deployment has been created and has the expected fields.
|
||||
wantsDeploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: "tailscale"}, TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: appsv1.SchemeGroupVersion.Identifier()}}
|
||||
if err := yaml.Unmarshal(deployYaml, wantsDeploy); err != nil {
|
||||
t.Fatalf("unmarshalling yaml: %v", err)
|
||||
}
|
||||
dnsCfgOwnerRef := metav1.NewControllerRef(dnsCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig"))
|
||||
wantsDeploy.OwnerReferences = []metav1.OwnerReference{*dnsCfgOwnerRef}
|
||||
wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.1"
|
||||
wantsDeploy.Namespace = "tailscale"
|
||||
labels := nameserverResourceLabels("test", "tailscale")
|
||||
wantsDeploy.ObjectMeta.Labels = labels
|
||||
expectEqual(t, fc, wantsDeploy, nil)
|
||||
|
||||
// Verify that DNSConfig advertizes the nameserver's Service IP address,
|
||||
// has the ready status condition and tailscale finalizer.
|
||||
mustUpdate(t, fc, "tailscale", "nameserver", func(svc *corev1.Service) {
|
||||
svc.Spec.ClusterIP = "1.2.3.4"
|
||||
})
|
||||
expectReconciled(t, nr, "", "test")
|
||||
dnsCfg.Status.NameserverStatus = &tsapi.NameserverStatus{
|
||||
IP: "1.2.3.4",
|
||||
}
|
||||
dnsCfg.Finalizers = []string{FinalizerName}
|
||||
dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, tsapi.ConnectorCondition{
|
||||
Type: tsapi.NameserverReady,
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: reasonNameserverCreated,
|
||||
Message: reasonNameserverCreated,
|
||||
LastTransitionTime: &metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
})
|
||||
expectEqual(t, fc, dnsCfg, nil)
|
||||
|
||||
// // Verify that nameserver image gets updated to match DNSConfig spec.
|
||||
mustUpdate(t, fc, "", "test", func(dnsCfg *tsapi.DNSConfig) {
|
||||
dnsCfg.Spec.Nameserver.Image.Tag = "v0.0.2"
|
||||
})
|
||||
expectReconciled(t, nr, "", "test")
|
||||
wantsDeploy.Spec.Template.Spec.Containers[0].Image = "test:v0.0.2"
|
||||
expectEqual(t, fc, wantsDeploy, nil)
|
||||
|
||||
// Verify that when another actor sets ConfigMap data, it does not get
|
||||
// overwritten by nameserver reconciler.
|
||||
dnsRecords := &operatorutils.Records{Version: "v1alpha1", IP4: map[string][]string{"foo.ts.net": {"1.2.3.4"}}}
|
||||
bs, err := json.Marshal(dnsRecords)
|
||||
if err != nil {
|
||||
t.Fatalf("error marshalling ConfigMap contents: %v", err)
|
||||
}
|
||||
mustUpdate(t, fc, "tailscale", "dnsconfig", func(cm *corev1.ConfigMap) {
|
||||
mak.Set(&cm.Data, "dns.json", string(bs))
|
||||
})
|
||||
expectReconciled(t, nr, "", "test")
|
||||
wantCm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsconfig",
|
||||
Namespace: "tailscale", Labels: labels, OwnerReferences: []metav1.OwnerReference{*dnsCfgOwnerRef}},
|
||||
TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"},
|
||||
Data: map[string]string{"dns.json": string(bs)},
|
||||
}
|
||||
expectEqual(t, fc, wantCm, nil)
|
||||
}
|
||||
@@ -47,9 +47,12 @@ import (
|
||||
// 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 staticmanifests
|
||||
|
||||
// Generate Connector CustomResourceDefinition yaml from its Go types.
|
||||
// Generate Connector and ProxyClass CustomResourceDefinition yamls from their 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/...
|
||||
|
||||
// Generate CRD docs from the yamls
|
||||
//go:generate go run fybrik.io/crdoc --resources=./deploy/crds --output=../../k8s-operator/api.md
|
||||
|
||||
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.
|
||||
@@ -220,8 +223,11 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
// resources that we GET via the controller manager's client.
|
||||
Cache: cache.Options{
|
||||
ByObject: map[client.Object]cache.ByObject{
|
||||
&corev1.Secret{}: nsFilter,
|
||||
&appsv1.StatefulSet{}: nsFilter,
|
||||
&corev1.Secret{}: nsFilter,
|
||||
&corev1.ServiceAccount{}: nsFilter,
|
||||
&corev1.ConfigMap{}: nsFilter,
|
||||
&appsv1.StatefulSet{}: nsFilter,
|
||||
&appsv1.Deployment{}: nsFilter,
|
||||
},
|
||||
},
|
||||
Scheme: tsapi.GlobalScheme,
|
||||
@@ -269,12 +275,14 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
// If a ProxyClassChanges, enqueue all Ingresses labeled with that
|
||||
// ProxyClass's name.
|
||||
proxyClassFilterForIngress := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForIngress(mgr.GetClient(), startlog))
|
||||
// Enque Ingress if a managed Service or backend Service associated with a tailscale Ingress changes.
|
||||
svcHandlerForIngress := handler.EnqueueRequestsFromMapFunc(serviceHandlerForIngress(mgr.GetClient(), startlog))
|
||||
err = builder.
|
||||
ControllerManagedBy(mgr).
|
||||
For(&networkingv1.Ingress{}).
|
||||
Watches(&appsv1.StatefulSet{}, ingressChildFilter).
|
||||
Watches(&corev1.Secret{}, ingressChildFilter).
|
||||
Watches(&corev1.Service{}, ingressChildFilter).
|
||||
Watches(&corev1.Service{}, svcHandlerForIngress).
|
||||
Watches(&tsapi.ProxyClass{}, proxyClassFilterForIngress).
|
||||
Complete(&IngressReconciler{
|
||||
ssr: ssr,
|
||||
@@ -303,7 +311,28 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
clock: tstime.DefaultClock{},
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatal("could not create connector reconciler: %v", err)
|
||||
startlog.Fatalf("could not create connector reconciler: %v", err)
|
||||
}
|
||||
// TODO (irbekrm): switch to metadata-only watches for resources whose
|
||||
// spec we don't need to inspect to reduce memory consumption
|
||||
// https://github.com/kubernetes-sigs/controller-runtime/issues/1159
|
||||
nameserverFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("nameserver"))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.DNSConfig{}).
|
||||
Watches(&appsv1.Deployment{}, nameserverFilter).
|
||||
Watches(&corev1.ConfigMap{}, nameserverFilter).
|
||||
Watches(&corev1.Service{}, nameserverFilter).
|
||||
Watches(&corev1.ServiceAccount{}, nameserverFilter).
|
||||
Complete(&NameserverReconciler{
|
||||
recorder: eventRecorder,
|
||||
tsNamespace: tsNamespace,
|
||||
|
||||
Client: mgr.GetClient(),
|
||||
logger: zlog.Named("nameserver-reconciler"),
|
||||
clock: tstime.DefaultClock{},
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create nameserver reconciler: %v", err)
|
||||
}
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.ProxyClass{}).
|
||||
@@ -419,6 +448,46 @@ func proxyClassHandlerForConnector(cl client.Client, logger *zap.SugaredLogger)
|
||||
}
|
||||
}
|
||||
|
||||
// serviceHandlerForIngress returns a handler for Service events for ingress
|
||||
// reconciler that ensures that if the Service associated with an event is of
|
||||
// interest to the reconciler, the associated Ingress(es) gets be reconciled.
|
||||
// The Services of interest are backend Services for tailscale Ingress and
|
||||
// managed Services for an StatefulSet for a proxy configured for tailscale
|
||||
// Ingress
|
||||
func serviceHandlerForIngress(cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
|
||||
return func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
if isManagedByType(o, "ingress") {
|
||||
ingName := parentFromObjectLabels(o)
|
||||
return []reconcile.Request{{NamespacedName: ingName}}
|
||||
}
|
||||
ingList := networkingv1.IngressList{}
|
||||
if err := cl.List(ctx, &ingList, client.InNamespace(o.GetNamespace())); err != nil {
|
||||
logger.Debugf("error listing Ingresses: %v", err)
|
||||
return nil
|
||||
}
|
||||
reqs := make([]reconcile.Request, 0)
|
||||
for _, ing := range ingList.Items {
|
||||
if ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != tailscaleIngressClassName {
|
||||
return nil
|
||||
}
|
||||
if ing.Spec.DefaultBackend != nil && ing.Spec.DefaultBackend.Service != nil && ing.Spec.DefaultBackend.Service.Name == o.GetName() {
|
||||
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)})
|
||||
}
|
||||
for _, rule := range ing.Spec.Rules {
|
||||
if rule.HTTP == nil {
|
||||
continue
|
||||
}
|
||||
for _, path := range rule.HTTP.Paths {
|
||||
if path.Backend.Service != nil && path.Backend.Service.Name == o.GetName() {
|
||||
reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&ing)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
}
|
||||
|
||||
func serviceHandler(_ context.Context, o client.Object) []reconcile.Request {
|
||||
if isManagedByType(o, "svc") {
|
||||
// If this is a Service managed by a Service we want to enqueue its parent
|
||||
@@ -437,7 +506,6 @@ func serviceHandler(_ context.Context, o client.Object) []reconcile.Request {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// isMagicDNSName reports whether name is a full tailnet node FQDN (with or
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.uber.org/zap"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/mak"
|
||||
@@ -69,9 +73,9 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, then verify reconcile again and verify
|
||||
@@ -114,7 +118,7 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
// Turn the service back into a ClusterIP service, which should make the
|
||||
// operator clean up.
|
||||
@@ -153,7 +157,7 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
@@ -210,9 +214,9 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
hostname: "default-test",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
@@ -233,10 +237,10 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
Selector: nil,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, want, nil)
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
// Change the tailscale-target-fqdn annotation which should update the
|
||||
// StatefulSet
|
||||
@@ -320,9 +324,9 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
hostname: "default-test",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
@@ -343,10 +347,10 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
Selector: nil,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, want, nil)
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
// Change the tailscale-target-ip annotation which should update the
|
||||
// StatefulSet
|
||||
@@ -427,9 +431,9 @@ func TestAnnotations(t *testing.T) {
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
@@ -449,7 +453,7 @@ func TestAnnotations(t *testing.T) {
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
// Turn the service back into a ClusterIP service, which should make the
|
||||
// operator clean up.
|
||||
@@ -481,7 +485,7 @@ func TestAnnotations(t *testing.T) {
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestAnnotationIntoLB(t *testing.T) {
|
||||
@@ -535,9 +539,9 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, since it would have normally happened at
|
||||
@@ -570,7 +574,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
// Remove Tailscale's annotation, and at the same time convert the service
|
||||
// into a tailscale LoadBalancer.
|
||||
@@ -581,8 +585,8 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
// None of the proxy machinery should have changed...
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
// ... but the service should have a LoadBalancer status.
|
||||
|
||||
want = &corev1.Service{
|
||||
@@ -614,7 +618,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestLBIntoAnnotation(t *testing.T) {
|
||||
@@ -666,9 +670,9 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, then verify reconcile again and verify
|
||||
@@ -711,7 +715,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
// Turn the service back into a ClusterIP service, but also add the
|
||||
// tailscale annotation.
|
||||
@@ -730,8 +734,8 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
@@ -752,7 +756,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestCustomHostname(t *testing.T) {
|
||||
@@ -807,9 +811,9 @@ func TestCustomHostname(t *testing.T) {
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, o), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
@@ -830,7 +834,7 @@ func TestCustomHostname(t *testing.T) {
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
// Turn the service back into a ClusterIP service, which should make the
|
||||
// operator clean up.
|
||||
@@ -865,7 +869,7 @@ func TestCustomHostname(t *testing.T) {
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestCustomPriorityClassName(t *testing.T) {
|
||||
@@ -922,7 +926,7 @@ func TestCustomPriorityClassName(t *testing.T) {
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
}
|
||||
|
||||
func TestProxyClassForService(t *testing.T) {
|
||||
@@ -983,9 +987,9 @@ func TestProxyClassForService(t *testing.T) {
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, opts), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 2. The Service gets updated with tailscale.com/proxy-class label
|
||||
// pointing at the 'custom-metadata' ProxyClass. The ProxyClass is not
|
||||
@@ -994,7 +998,7 @@ func TestProxyClassForService(t *testing.T) {
|
||||
mak.Set(&svc.Labels, LabelProxyClass, "custom-metadata")
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 3. ProxyClass is set to Ready, the Service gets reconciled by the
|
||||
// services-reconciler and the customization from the ProxyClass is
|
||||
@@ -1009,7 +1013,7 @@ func TestProxyClassForService(t *testing.T) {
|
||||
})
|
||||
opts.proxyClass = pc.Name
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 4. tailscale.com/proxy-class label is removed from the Service, the
|
||||
// configuration from the ProxyClass is removed from the cluster
|
||||
@@ -1019,7 +1023,7 @@ func TestProxyClassForService(t *testing.T) {
|
||||
})
|
||||
opts.proxyClass = ""
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
}
|
||||
|
||||
func TestDefaultLoadBalancer(t *testing.T) {
|
||||
@@ -1063,7 +1067,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
o := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
@@ -1072,7 +1076,8 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
}
|
||||
|
||||
func TestProxyFirewallMode(t *testing.T) {
|
||||
@@ -1125,10 +1130,70 @@ func TestProxyFirewallMode(t *testing.T) {
|
||||
firewallMode: "nftables",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
}
|
||||
|
||||
func TestTailscaledConfigfileHash(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
isDefaultLoadBalancer: true,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
// of objects looks right.
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
// The apiserver is supposed to set the UID, but the fake client
|
||||
// doesn't. So, set it explicitly because other code later depends
|
||||
// on it being set.
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "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",
|
||||
confFileHash: "705e5ffd0bd5326237efdf542c850a65a54101284d5daa30775420fcc64d89c1",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), nil)
|
||||
|
||||
// 2. Hostname gets changed, configfile is updated and a new hash value
|
||||
// is produced.
|
||||
mustUpdate(t, fc, "default", "test", func(svc *corev1.Service) {
|
||||
mak.Set(&svc.Annotations, AnnotationHostname, "another-test")
|
||||
})
|
||||
o.hostname = "another-test"
|
||||
o.confFileHash = "1a087f887825d2b75d3673c7c2b0131f8ec1f0b1cb761d33e236dd28350dfe23"
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), nil)
|
||||
}
|
||||
func Test_isMagicDNSName(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
@@ -1155,3 +1220,134 @@ func Test_isMagicDNSName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_serviceHandlerForIngress(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 1. An event on a headless Service for a tailscale Ingress results in
|
||||
// the Ingress being reconciled.
|
||||
mustCreate(t, fc, &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "ing-1",
|
||||
Namespace: "ns-1",
|
||||
},
|
||||
Spec: networkingv1.IngressSpec{IngressClassName: ptr.To(tailscaleIngressClassName)},
|
||||
})
|
||||
svc1 := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "headless-1",
|
||||
Namespace: "tailscale",
|
||||
Labels: map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelParentName: "ing-1",
|
||||
LabelParentNamespace: "ns-1",
|
||||
LabelParentType: "ingress",
|
||||
},
|
||||
},
|
||||
}
|
||||
mustCreate(t, fc, svc1)
|
||||
wantReqs := []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-1", Name: "ing-1"}}}
|
||||
gotReqs := serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), svc1)
|
||||
if diff := cmp.Diff(gotReqs, wantReqs); diff != "" {
|
||||
t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff)
|
||||
}
|
||||
|
||||
// 2. An event on a Service that is the default backend for a tailscale
|
||||
// Ingress results in the Ingress being reconciled.
|
||||
mustCreate(t, fc, &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "ing-2",
|
||||
Namespace: "ns-2",
|
||||
},
|
||||
Spec: networkingv1.IngressSpec{
|
||||
DefaultBackend: &networkingv1.IngressBackend{
|
||||
Service: &networkingv1.IngressServiceBackend{Name: "def-backend"},
|
||||
},
|
||||
IngressClassName: ptr.To(tailscaleIngressClassName),
|
||||
},
|
||||
})
|
||||
backendSvc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "def-backend",
|
||||
Namespace: "ns-2",
|
||||
},
|
||||
}
|
||||
mustCreate(t, fc, backendSvc)
|
||||
wantReqs = []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-2", Name: "ing-2"}}}
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), backendSvc)
|
||||
if diff := cmp.Diff(gotReqs, wantReqs); diff != "" {
|
||||
t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff)
|
||||
}
|
||||
|
||||
// 3. An event on a Service that is one of the non-default backends for
|
||||
// a tailscale Ingress results in the Ingress being reconciled.
|
||||
mustCreate(t, fc, &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "ing-3",
|
||||
Namespace: "ns-3",
|
||||
},
|
||||
Spec: networkingv1.IngressSpec{
|
||||
IngressClassName: ptr.To(tailscaleIngressClassName),
|
||||
Rules: []networkingv1.IngressRule{{IngressRuleValue: networkingv1.IngressRuleValue{HTTP: &networkingv1.HTTPIngressRuleValue{
|
||||
Paths: []networkingv1.HTTPIngressPath{
|
||||
{Backend: networkingv1.IngressBackend{Service: &networkingv1.IngressServiceBackend{Name: "backend"}}}},
|
||||
}}}},
|
||||
},
|
||||
})
|
||||
backendSvc2 := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend",
|
||||
Namespace: "ns-3",
|
||||
},
|
||||
}
|
||||
mustCreate(t, fc, backendSvc2)
|
||||
wantReqs = []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: "ns-3", Name: "ing-3"}}}
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), backendSvc2)
|
||||
if diff := cmp.Diff(gotReqs, wantReqs); diff != "" {
|
||||
t.Fatalf("unexpected reconcile requests (-got +want):\n%s", diff)
|
||||
}
|
||||
|
||||
// 4. An event on a Service that is a backend for an Ingress that is not
|
||||
// tailscale Ingress does not result in an Ingress reconcile.
|
||||
mustCreate(t, fc, &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "ing-4",
|
||||
Namespace: "ns-4",
|
||||
},
|
||||
Spec: networkingv1.IngressSpec{
|
||||
Rules: []networkingv1.IngressRule{{IngressRuleValue: networkingv1.IngressRuleValue{HTTP: &networkingv1.HTTPIngressRuleValue{
|
||||
Paths: []networkingv1.HTTPIngressPath{
|
||||
{Backend: networkingv1.IngressBackend{Service: &networkingv1.IngressServiceBackend{Name: "non-ts-backend"}}}},
|
||||
}}}},
|
||||
},
|
||||
})
|
||||
nonTSBackend := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "non-ts-backend",
|
||||
Namespace: "ns-4",
|
||||
},
|
||||
}
|
||||
mustCreate(t, fc, nonTSBackend)
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), nonTSBackend)
|
||||
if len(gotReqs) > 0 {
|
||||
t.Errorf("unexpected reconcile request for a Service that does not belong to a Tailscale Ingress: %#+v\n", gotReqs)
|
||||
}
|
||||
|
||||
// 5. An event on a Service not related to any Ingress does not result
|
||||
// in an Ingress reconcile.
|
||||
someSvc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "some-svc",
|
||||
Namespace: "ns-4",
|
||||
},
|
||||
}
|
||||
mustCreate(t, fc, someSvc)
|
||||
gotReqs = serviceHandlerForIngress(fc, zl.Sugar())(context.Background(), someSvc)
|
||||
if len(gotReqs) > 0 {
|
||||
t.Errorf("unexpected reconcile request for a Service that does not belong to any Ingress: %#+v\n", gotReqs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestProxyClass(t *testing.T) {
|
||||
LastTransitionTime: &metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
})
|
||||
|
||||
expectEqual(t, fc, pc)
|
||||
expectEqual(t, fc, pc, nil)
|
||||
|
||||
// 2. An invalid ProxyClass resource gets its status updated to Invalid.
|
||||
pc.Spec.StatefulSet.Labels["foo"] = "?!someVal"
|
||||
@@ -79,5 +79,5 @@ func TestProxyClass(t *testing.T) {
|
||||
expectReconciled(t, pcr, "", "test")
|
||||
msg := `ProxyClass is not valid: .spec.statefulSet.labels: Invalid value: "?!someVal": a valid label must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?')`
|
||||
tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassready, metav1.ConditionFalse, reasonProxyClassInvalid, msg, 0, cl, zl.Sugar())
|
||||
expectEqual(t, fc, pc)
|
||||
expectEqual(t, fc, pc, nil)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
@@ -86,7 +87,6 @@ const (
|
||||
// ensure that it does not get removed when a ProxyClass configuration
|
||||
// is applied.
|
||||
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.
|
||||
@@ -101,7 +101,7 @@ var (
|
||||
// tailscaleManagedLabels are label keys that tailscale operator sets on StatefulSets and Pods.
|
||||
tailscaleManagedLabels = []string{LabelManaged, LabelParentType, LabelParentName, LabelParentNamespace, "app"}
|
||||
// tailscaleManagedAnnotations are annotation keys that tailscale operator sets on StatefulSets and Pods.
|
||||
tailscaleManagedAnnotations = []string{podAnnotationLastSetClusterIP, podAnnotationLastSetHostname, podAnnotationLastSetTailnetTargetIP, podAnnotationLastSetTailnetTargetFQDN, podAnnotationLastSetConfigFileHash}
|
||||
tailscaleManagedAnnotations = []string{podAnnotationLastSetClusterIP, podAnnotationLastSetTailnetTargetIP, podAnnotationLastSetTailnetTargetFQDN, podAnnotationLastSetConfigFileHash}
|
||||
)
|
||||
|
||||
type tailscaleSTSConfig struct {
|
||||
@@ -312,9 +312,9 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
|
||||
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.
|
||||
// Initially it contains only tailscaled config, but when the
|
||||
// proxy starts, it will also store there the state, certs and
|
||||
// ACME account key.
|
||||
sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, stsC.ChildResourceLabels)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -337,17 +337,13 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
|
||||
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))
|
||||
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 {
|
||||
@@ -357,12 +353,12 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
|
||||
}
|
||||
|
||||
if orig != nil {
|
||||
logger.Debugf("patching existing state Secret with values %s", secret.Data[tailscaledConfigKey])
|
||||
logger.Debugf("patching the existing proxy Secret with tailscaled config %s", sanitizeConfigBytes(secret.Data[tailscaledConfigKey]))
|
||||
if err := a.Patch(ctx, secret, client.MergeFrom(orig)); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
} else {
|
||||
logger.Debugf("creating new state Secret with authkey %s", secret.Data[tailscaledConfigKey])
|
||||
logger.Debugf("creating a new Secret for the proxy with tailscaled config %s", sanitizeConfigBytes([]byte(secret.StringData[tailscaledConfigKey])))
|
||||
if err := a.Create(ctx, secret); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -370,6 +366,23 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
|
||||
return secret.Name, hash, nil
|
||||
}
|
||||
|
||||
// sanitizeConfigBytes returns ipn.ConfigVAlpha in string form with redacted
|
||||
// auth key.
|
||||
func sanitizeConfigBytes(bs []byte) string {
|
||||
c := &ipn.ConfigVAlpha{}
|
||||
if err := json.Unmarshal(bs, c); err != nil {
|
||||
return "invalid config"
|
||||
}
|
||||
if c.AuthKey != nil {
|
||||
c.AuthKey = ptr.To("**redacted**")
|
||||
}
|
||||
sanitizedBytes, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "invalid config"
|
||||
}
|
||||
return string(sanitizedBytes)
|
||||
}
|
||||
|
||||
// DeviceInfo returns the device ID and hostname for the Tailscale device
|
||||
// associated with the given labels.
|
||||
func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map[string]string) (id tailcfg.StableNodeID, hostname string, ips []string, err error) {
|
||||
@@ -477,6 +490,10 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
Name: "TS_KUBE_SECRET",
|
||||
Value: proxySecret,
|
||||
},
|
||||
corev1.EnvVar{
|
||||
Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH",
|
||||
Value: "/etc/tsconfig/tailscaled",
|
||||
},
|
||||
)
|
||||
if sts.ForwardClusterTrafficViaL7IngressProxy {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
@@ -484,42 +501,25 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
Value: "true",
|
||||
})
|
||||
}
|
||||
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(&pod.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)
|
||||
pod.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,
|
||||
}},
|
||||
},
|
||||
mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetConfigFileHash, tsConfigHash)
|
||||
pod.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",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{
|
||||
Name: "tailscaledconfig",
|
||||
ReadOnly: true,
|
||||
MountPath: "/etc/tsconfig",
|
||||
})
|
||||
|
||||
if a.tsFirewallMode != "" {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
@@ -668,10 +668,11 @@ func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet)
|
||||
// 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,
|
||||
Version: "alpha0",
|
||||
AcceptDNS: "false",
|
||||
AcceptRoutes: "false", // AcceptRoutes defaults to true
|
||||
Locked: "false",
|
||||
Hostname: &stsC.Hostname,
|
||||
}
|
||||
if stsC.Connector != nil {
|
||||
routes, err := netutil.CalcAdvertiseRoutes(stsC.Connector.routes, stsC.Connector.isExitNode)
|
||||
@@ -828,10 +829,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -247,28 +247,28 @@ func Test_mergeStatefulSetLabelsOrAnnots(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "no custom annots specified and none present in current annots, return current annots",
|
||||
current: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
|
||||
want: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
|
||||
current: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"},
|
||||
want: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"},
|
||||
managed: tailscaleManagedAnnotations,
|
||||
},
|
||||
{
|
||||
name: "no custom annots specified, but some present in current annots, return tailscale managed annots only from the current annots",
|
||||
current: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
|
||||
want: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
|
||||
current: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"},
|
||||
want: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"},
|
||||
managed: tailscaleManagedAnnotations,
|
||||
},
|
||||
{
|
||||
name: "custom annots specified, current annots only contain tailscale managed annots, return a union of both",
|
||||
current: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
|
||||
current: map[string]string{podAnnotationLastSetClusterIP: "1.2.3.4"},
|
||||
custom: map[string]string{"foo": "bar", "something.io/foo": "bar"},
|
||||
want: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
|
||||
want: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"},
|
||||
managed: tailscaleManagedAnnotations,
|
||||
},
|
||||
{
|
||||
name: "custom annots specified, current annots contain tailscale managed annots and custom annots, some of which are not present in the new custom annots, return a union of managed annots and the desired custom annots",
|
||||
current: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
|
||||
current: map[string]string{"foo": "bar", "something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"},
|
||||
custom: map[string]string{"something.io/foo": "bar"},
|
||||
want: map[string]string{"something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4", podAnnotationLastSetHostname: "foo"},
|
||||
want: map[string]string{"something.io/foo": "bar", podAnnotationLastSetClusterIP: "1.2.3.4"},
|
||||
managed: tailscaleManagedAnnotations,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -44,7 +44,6 @@ type configOpts struct {
|
||||
clusterTargetIP string
|
||||
subnetRoutes string
|
||||
isExitNode bool
|
||||
shouldUseDeclarativeConfig bool // tailscaled in proxy should be configured using config file
|
||||
confFileHash string
|
||||
serveConfig *ipn.ServeConfig
|
||||
shouldEnableForwardingClusterTrafficViaIngress bool
|
||||
@@ -58,9 +57,9 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
|
||||
Image: "tailscale/tailscale",
|
||||
Env: []corev1.EnvVar{
|
||||
{Name: "TS_USERSPACE", Value: "false"},
|
||||
{Name: "TS_AUTH_ONCE", Value: "true"},
|
||||
{Name: "POD_IP", ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{APIVersion: "", FieldPath: "status.podIP"}, ResourceFieldRef: nil, ConfigMapKeyRef: nil, SecretKeyRef: nil}},
|
||||
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
|
||||
{Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH", Value: "/etc/tsconfig/tailscaled"},
|
||||
},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Capabilities: &corev1.Capabilities{
|
||||
@@ -77,36 +76,29 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
|
||||
}
|
||||
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",
|
||||
},
|
||||
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",
|
||||
})
|
||||
},
|
||||
}
|
||||
tsContainer.VolumeMounts = []corev1.VolumeMount{{
|
||||
Name: "tailscaledconfig",
|
||||
ReadOnly: true,
|
||||
MountPath: "/etc/tsconfig",
|
||||
}}
|
||||
if opts.confFileHash != "" {
|
||||
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{
|
||||
@@ -211,22 +203,43 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
|
||||
}
|
||||
|
||||
func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *appsv1.StatefulSet {
|
||||
t.Helper()
|
||||
tsContainer := corev1.Container{
|
||||
Name: "tailscale",
|
||||
Image: "tailscale/tailscale",
|
||||
Env: []corev1.EnvVar{
|
||||
{Name: "TS_USERSPACE", Value: "true"},
|
||||
{Name: "TS_AUTH_ONCE", Value: "true"},
|
||||
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
|
||||
{Name: "TS_HOSTNAME", Value: opts.hostname},
|
||||
{Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH", Value: "/etc/tsconfig/tailscaled"},
|
||||
{Name: "TS_SERVE_CONFIG", Value: "/etc/tailscaled/serve-config"},
|
||||
},
|
||||
ImagePullPolicy: "Always",
|
||||
VolumeMounts: []corev1.VolumeMount{{Name: "serve-config", ReadOnly: true, MountPath: "/etc/tailscaled"}},
|
||||
VolumeMounts: []corev1.VolumeMount{
|
||||
{Name: "tailscaledconfig", ReadOnly: true, MountPath: "/etc/tsconfig"},
|
||||
{Name: "serve-config", ReadOnly: true, MountPath: "/etc/tailscaled"},
|
||||
},
|
||||
}
|
||||
volumes := []corev1.Volume{
|
||||
{
|
||||
Name: "tailscaledconfig",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: opts.secretName,
|
||||
Items: []corev1.KeyToPath{
|
||||
{
|
||||
Key: "tailscaled",
|
||||
Path: "tailscaled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Name: "serve-config",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{SecretName: opts.secretName,
|
||||
Items: []corev1.KeyToPath{{Key: "serve-config", Path: "serve-config"}}}},
|
||||
},
|
||||
}
|
||||
annots := make(map[string]string)
|
||||
volumes := []corev1.Volume{{Name: "serve-config", VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: opts.secretName, Items: []corev1.KeyToPath{{Key: "serve-config", Path: "serve-config"}}}}}}
|
||||
annots["tailscale.com/operator-last-set-hostname"] = opts.hostname
|
||||
ss := &appsv1.StatefulSet{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "StatefulSet",
|
||||
@@ -250,7 +263,6 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
|
||||
ServiceName: opts.stsName,
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: annots,
|
||||
DeletionGracePeriodSeconds: ptr.To[int64](10),
|
||||
Labels: map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
@@ -269,6 +281,10 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
|
||||
},
|
||||
},
|
||||
}
|
||||
ss.Spec.Template.Annotations = map[string]string{}
|
||||
if opts.confFileHash != "" {
|
||||
ss.Spec.Template.Annotations["tailscale.com/operator-last-set-config-file-hash"] = opts.confFileHash
|
||||
}
|
||||
// If opts.proxyClass is set, retrieve the ProxyClass and apply
|
||||
// configuration from that to the StatefulSet.
|
||||
if opts.proxyClass != "" {
|
||||
@@ -310,11 +326,6 @@ func expectedHeadlessService(name string, parentType string) *corev1.Service {
|
||||
|
||||
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",
|
||||
@@ -332,37 +343,41 @@ func expectedSecret(t *testing.T, opts configOpts) *corev1.Secret {
|
||||
}
|
||||
mak.Set(&s.StringData, "serve-config", string(serveConfigBs))
|
||||
}
|
||||
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"),
|
||||
conf := &ipn.ConfigVAlpha{
|
||||
Version: "alpha0",
|
||||
AcceptDNS: "false",
|
||||
Hostname: &opts.hostname,
|
||||
Locked: "false",
|
||||
AuthKey: ptr.To("secret-authkey"),
|
||||
AcceptRoutes: "false",
|
||||
}
|
||||
var routes []netip.Prefix
|
||||
if opts.subnetRoutes != "" || opts.isExitNode {
|
||||
r := opts.subnetRoutes
|
||||
if opts.isExitNode {
|
||||
r = "0.0.0.0/0,::/0," + r
|
||||
}
|
||||
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)
|
||||
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))
|
||||
}
|
||||
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 := map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
"tailscale.com/parent-resource-ns": "default",
|
||||
"tailscale.com/parent-resource-type": opts.parentType,
|
||||
}
|
||||
if opts.parentType == "connector" {
|
||||
labels["tailscale.com/parent-resource-ns"] = "" // Connector is cluster scoped
|
||||
}
|
||||
s.Labels = labels
|
||||
@@ -424,7 +439,13 @@ func mustUpdateStatus[T any, O ptrObject[T]](t *testing.T, client client.Client,
|
||||
}
|
||||
}
|
||||
|
||||
func expectEqual[T any, O ptrObject[T]](t *testing.T, client client.Client, want O) {
|
||||
// expectEqual accepts a Kubernetes object and a Kubernetes client. It tests
|
||||
// whether an object with equivalent contents can be retrieved by the passed
|
||||
// client. If you want to NOT test some object fields for equality, ensure that
|
||||
// they are not present in the passed object and use the modify func to remove
|
||||
// them from the cluster object. If no such modifications are needed, you can
|
||||
// pass nil in place of the modify function.
|
||||
func expectEqual[T any, O ptrObject[T]](t *testing.T, client client.Client, want O, modify func(O)) {
|
||||
t.Helper()
|
||||
got := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
@@ -438,6 +459,9 @@ func expectEqual[T any, O ptrObject[T]](t *testing.T, client client.Client, want
|
||||
// so just remove it from both got and want.
|
||||
got.SetResourceVersion("")
|
||||
want.SetResourceVersion("")
|
||||
if modify != nil {
|
||||
modify(got)
|
||||
}
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Fatalf("unexpected object (-got +want):\n%s", diff)
|
||||
}
|
||||
@@ -534,3 +558,11 @@ func (c *fakeTSClient) Deleted() []string {
|
||||
defer c.Unlock()
|
||||
return c.deleted
|
||||
}
|
||||
|
||||
// removeHashAnnotation can be used to remove declarative tailscaled config hash
|
||||
// annotation from proxy StatefulSets to make the tests more maintainable (so
|
||||
// that we don't have to change the annotation in each test case after any
|
||||
// change to the configfile contents).
|
||||
func removeHashAnnotation(sts *appsv1.StatefulSet) {
|
||||
delete(sts.Spec.Template.Annotations, podAnnotationLastSetConfigFileHash)
|
||||
}
|
||||
|
||||
@@ -829,6 +829,10 @@ func TestPrefFlagMapping(t *testing.T) {
|
||||
// Handled by TS_DEBUG_FIREWALL_MODE env var, we don't want to have
|
||||
// a CLI flag for this. The Pref is used by c2n.
|
||||
continue
|
||||
case "TailFSShares":
|
||||
// Handled by the tailscale share subcommand, we don't want a CLI
|
||||
// flag for this.
|
||||
continue
|
||||
}
|
||||
t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName)
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ func updateKubeconfig(cfgYaml []byte, fqdn string) ([]byte, error) {
|
||||
|
||||
var clusters []any
|
||||
if cm, ok := cfg["clusters"]; ok {
|
||||
clusters = cm.([]any)
|
||||
clusters, _ = cm.([]any)
|
||||
}
|
||||
cfg["clusters"] = appendOrSetNamed(clusters, fqdn, map[string]any{
|
||||
"name": fqdn,
|
||||
@@ -130,7 +130,7 @@ func updateKubeconfig(cfgYaml []byte, fqdn string) ([]byte, error) {
|
||||
|
||||
var users []any
|
||||
if um, ok := cfg["users"]; ok {
|
||||
users = um.([]any)
|
||||
users, _ = um.([]any)
|
||||
}
|
||||
cfg["users"] = appendOrSetNamed(users, "tailscale-auth", map[string]any{
|
||||
// We just need one of these, and can reuse it for all clusters.
|
||||
@@ -144,7 +144,7 @@ func updateKubeconfig(cfgYaml []byte, fqdn string) ([]byte, error) {
|
||||
|
||||
var contexts []any
|
||||
if cm, ok := cfg["contexts"]; ok {
|
||||
contexts = cm.([]any)
|
||||
contexts, _ = cm.([]any)
|
||||
}
|
||||
cfg["contexts"] = appendOrSetNamed(contexts, fqdn, map[string]any{
|
||||
"name": fqdn,
|
||||
|
||||
@@ -48,6 +48,31 @@ contexts:
|
||||
current-context: foo.tail-scale.ts.net
|
||||
kind: Config
|
||||
users:
|
||||
- name: tailscale-auth
|
||||
user:
|
||||
token: unused`,
|
||||
},
|
||||
{
|
||||
name: "all configs, clusters, users have been deleted",
|
||||
in: `apiVersion: v1
|
||||
clusters: null
|
||||
contexts: null
|
||||
kind: Config
|
||||
current-context: some-non-existent-cluster
|
||||
users: null`,
|
||||
want: `apiVersion: v1
|
||||
clusters:
|
||||
- cluster:
|
||||
server: https://foo.tail-scale.ts.net
|
||||
name: foo.tail-scale.ts.net
|
||||
contexts:
|
||||
- context:
|
||||
cluster: foo.tail-scale.ts.net
|
||||
user: tailscale-auth
|
||||
name: foo.tail-scale.ts.net
|
||||
current-context: foo.tail-scale.ts.net
|
||||
kind: Config
|
||||
users:
|
||||
- name: tailscale-auth
|
||||
user:
|
||||
token: unused`,
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
var funnelCmd = func() *ffcli.Command {
|
||||
@@ -114,15 +113,8 @@ func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
|
||||
// Nothing to do.
|
||||
return nil
|
||||
}
|
||||
if on {
|
||||
mak.Set(&sc.AllowFunnel, hp, true)
|
||||
} else {
|
||||
delete(sc.AllowFunnel, hp)
|
||||
// clear map mostly for testing
|
||||
if len(sc.AllowFunnel) == 0 {
|
||||
sc.AllowFunnel = nil
|
||||
}
|
||||
}
|
||||
sc.SetFunnel(dnsName, port, on)
|
||||
|
||||
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import (
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
@@ -357,35 +356,12 @@ func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, useTLS bo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
|
||||
|
||||
if sc.IsTCPForwardingOnPort(srvPort) {
|
||||
fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n")
|
||||
return errHelp
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
|
||||
|
||||
if _, ok := sc.Web[hp]; !ok {
|
||||
mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
|
||||
}
|
||||
mak.Set(&sc.Web[hp].Handlers, mount, h)
|
||||
|
||||
for k, v := range sc.Web[hp].Handlers {
|
||||
if v == h {
|
||||
continue
|
||||
}
|
||||
// If the new mount point ends in / and another mount point
|
||||
// shares the same prefix, remove the other handler.
|
||||
// (e.g. /foo/ overwrites /foo)
|
||||
// The opposite example is also handled.
|
||||
m1 := strings.TrimSuffix(mount, "/")
|
||||
m2 := strings.TrimSuffix(k, "/")
|
||||
if m1 == m2 {
|
||||
delete(sc.Web[hp].Handlers, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
sc.SetWebHandler(h, dnsName, srvPort, mount, useTLS)
|
||||
|
||||
if !reflect.DeepEqual(cursc, sc) {
|
||||
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
|
||||
@@ -444,19 +420,7 @@ func (e *serveEnv) handleWebServeRemove(ctx context.Context, srvPort uint16, mou
|
||||
if !sc.WebHandlerExists(hp, mount) {
|
||||
return errors.New("error: handler does not exist")
|
||||
}
|
||||
// delete existing handler, then cascade delete if empty
|
||||
delete(sc.Web[hp].Handlers, mount)
|
||||
if len(sc.Web[hp].Handlers) == 0 {
|
||||
delete(sc.Web, hp)
|
||||
delete(sc.TCP, srvPort)
|
||||
}
|
||||
// clear empty maps mostly for testing
|
||||
if len(sc.Web) == 0 {
|
||||
sc.Web = nil
|
||||
}
|
||||
if len(sc.TCP) == 0 {
|
||||
sc.TCP = nil
|
||||
}
|
||||
sc.RemoveWebHandler(dnsName, srvPort, []string{mount}, false)
|
||||
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -592,15 +556,12 @@ func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort u
|
||||
return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort)
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: fwdAddr})
|
||||
|
||||
dnsName, err := e.getSelfDNSName(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if terminateTLS {
|
||||
sc.TCP[srcPort].TerminateTLS = dnsName
|
||||
}
|
||||
|
||||
sc.SetTCPForwarding(srcPort, fwdAddr, terminateTLS, dnsName)
|
||||
|
||||
if !reflect.DeepEqual(cursc, sc) {
|
||||
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
|
||||
@@ -626,11 +587,7 @@ func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error {
|
||||
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src)
|
||||
}
|
||||
if ph := sc.GetTCPPortHandler(src); ph != nil {
|
||||
delete(sc.TCP, src)
|
||||
// clear map mostly for testing
|
||||
if len(sc.TCP) == 0 {
|
||||
sc.TCP = nil
|
||||
}
|
||||
sc.RemoveTCPForwarding(src)
|
||||
return e.lc.SetServeConfig(ctx, sc)
|
||||
}
|
||||
return errors.New("error: serve config does not exist")
|
||||
@@ -642,6 +599,9 @@ func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error {
|
||||
// Examples:
|
||||
// - tailscale status
|
||||
// - tailscale status --json
|
||||
//
|
||||
// TODO(tyler,marwan,sonia): `status` should also report foreground configs,
|
||||
// currently only reports background config.
|
||||
func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
|
||||
sc, err := e.lc.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
@@ -807,9 +808,11 @@ func TestVerifyFunnelEnabled(t *testing.T) {
|
||||
lc.setQueryFeatureResponse(tt.queryFeatureResponse)
|
||||
|
||||
if tt.caps != nil {
|
||||
oldCaps := fakeStatus.Self.Capabilities
|
||||
defer func() { fakeStatus.Self.Capabilities = oldCaps }() // reset after test
|
||||
fakeStatus.Self.Capabilities = tt.caps
|
||||
cm := make(tailcfg.NodeCapMap)
|
||||
for _, c := range tt.caps {
|
||||
cm[c] = nil
|
||||
}
|
||||
tstest.Replace(t, &fakeStatus.Self.CapMap, cm)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
@@ -853,8 +856,11 @@ type fakeLocalServeClient struct {
|
||||
var fakeStatus = &ipnstate.Status{
|
||||
BackendState: ipn.Running.String(),
|
||||
Self: &ipnstate.PeerStatus{
|
||||
DNSName: "foo.test.ts.net",
|
||||
Capabilities: []tailcfg.NodeCapability{tailcfg.NodeAttrFunnel, tailcfg.CapabilityFunnelPorts + "?ports=443,8443"},
|
||||
DNSName: "foo.test.ts.net",
|
||||
CapMap: tailcfg.NodeCapMap{
|
||||
tailcfg.NodeAttrFunnel: nil,
|
||||
tailcfg.CapabilityFunnelPorts + "?ports=443,8443": nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -334,7 +333,7 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
|
||||
const backgroundExistsMsg = "background configuration already exists, use `tailscale %s --%s=%d off` to remove the existing configuration"
|
||||
|
||||
func (e *serveEnv) validateConfig(sc *ipn.ServeConfig, port uint16, wantServe serveType) error {
|
||||
sc, isFg := findConfig(sc, port)
|
||||
sc, isFg := sc.FindConfig(port)
|
||||
if sc == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -366,24 +365,6 @@ func serveFromPortHandler(tcp *ipn.TCPPortHandler) serveType {
|
||||
}
|
||||
}
|
||||
|
||||
// findConfig finds a config that contains the given port, which can be
|
||||
// the top level background config or an inner foreground one. The second
|
||||
// result is true if it's foreground
|
||||
func findConfig(sc *ipn.ServeConfig, port uint16) (*ipn.ServeConfig, bool) {
|
||||
if sc == nil {
|
||||
return nil, false
|
||||
}
|
||||
if _, ok := sc.TCP[port]; ok {
|
||||
return sc, false
|
||||
}
|
||||
for _, sc := range sc.Foreground {
|
||||
if _, ok := sc.TCP[port]; ok {
|
||||
return sc, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool) error {
|
||||
// update serve config based on the type
|
||||
switch srvType {
|
||||
@@ -516,9 +497,9 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui
|
||||
}
|
||||
h.Text = text
|
||||
case filepath.IsAbs(target):
|
||||
if version.IsSandboxedMacOS() {
|
||||
// don't allow path serving for now on macOS (2022-11-15)
|
||||
return errors.New("path serving is not supported if sandboxed on macOS")
|
||||
if version.IsMacAppStore() || version.IsMacSys() {
|
||||
// The Tailscale network extension cannot serve arbitrary paths on macOS due to sandbox restrictions (2024-03-26)
|
||||
return errors.New("Path serving is not supported on macOS due to sandbox restrictions. To use Tailscale Serve on macOS, switch to the open-source tailscaled distribution. See https://tailscale.com/kb/1065/macos-variants for more information.")
|
||||
}
|
||||
|
||||
target = filepath.Clean(target)
|
||||
@@ -535,7 +516,7 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui
|
||||
}
|
||||
h.Path = target
|
||||
default:
|
||||
t, err := expandProxyTargetDev(target, []string{"http", "https", "https+insecure"}, "http")
|
||||
t, err := ipn.ExpandProxyTargetValue(target, []string{"http", "https", "https+insecure"}, "http")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -547,29 +528,7 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui
|
||||
return errors.New("cannot serve web; already serving TCP")
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
|
||||
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
|
||||
if _, ok := sc.Web[hp]; !ok {
|
||||
mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
|
||||
}
|
||||
mak.Set(&sc.Web[hp].Handlers, mount, h)
|
||||
|
||||
// TODO: handle multiple web handlers from foreground mode
|
||||
for k, v := range sc.Web[hp].Handlers {
|
||||
if v == h {
|
||||
continue
|
||||
}
|
||||
// If the new mount point ends in / and another mount point
|
||||
// shares the same prefix, remove the other handler.
|
||||
// (e.g. /foo/ overwrites /foo)
|
||||
// The opposite example is also handled.
|
||||
m1 := strings.TrimSuffix(mount, "/")
|
||||
m2 := strings.TrimSuffix(k, "/")
|
||||
if m1 == m2 {
|
||||
delete(sc.Web[hp].Handlers, k)
|
||||
}
|
||||
}
|
||||
sc.SetWebHandler(h, dnsName, srvPort, mount, useTLS)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -585,7 +544,7 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
|
||||
return fmt.Errorf("invalid TCP target %q", target)
|
||||
}
|
||||
|
||||
targetURL, err := expandProxyTargetDev(target, []string{"tcp"}, "tcp")
|
||||
targetURL, err := ipn.ExpandProxyTargetValue(target, []string{"tcp"}, "tcp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to expand target: %v", err)
|
||||
}
|
||||
@@ -600,11 +559,7 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se
|
||||
return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort)
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: dstURL.Host})
|
||||
|
||||
if terminateTLS {
|
||||
sc.TCP[srcPort].TerminateTLS = dnsName
|
||||
}
|
||||
sc.SetTCPForwarding(srcPort, dstURL.Host, terminateTLS, dnsName)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -618,14 +573,10 @@ func (e *serveEnv) applyFunnel(sc *ipn.ServeConfig, dnsName string, srvPort uint
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
|
||||
// TODO: should ensure there is no other conflicting funnel
|
||||
// TODO: add error handling for if toggling for existing sc
|
||||
if allowFunnel {
|
||||
mak.Set(&sc.AllowFunnel, hp, true)
|
||||
} else if _, exists := sc.AllowFunnel[hp]; exists {
|
||||
fmt.Fprintf(e.stderr(), "Removing Funnel for %s\n", hp)
|
||||
delete(sc.AllowFunnel, hp)
|
||||
if _, exists := sc.AllowFunnel[hp]; exists && !allowFunnel {
|
||||
fmt.Fprintf(e.stderr(), "Removing Funnel for %s:%s\n", dnsName, hp)
|
||||
}
|
||||
sc.SetFunnel(dnsName, srvPort, allowFunnel)
|
||||
}
|
||||
|
||||
// unsetServe removes the serve config for the given serve port.
|
||||
@@ -814,34 +765,7 @@ func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, dnsName string, srvPort u
|
||||
}
|
||||
}
|
||||
|
||||
// delete existing handler, then cascade delete if empty
|
||||
for _, m := range mounts {
|
||||
delete(sc.Web[hp].Handlers, m)
|
||||
}
|
||||
if len(sc.Web[hp].Handlers) == 0 {
|
||||
delete(sc.Web, hp)
|
||||
delete(sc.AllowFunnel, hp)
|
||||
delete(sc.TCP, srvPort)
|
||||
}
|
||||
|
||||
// clear empty maps mostly for testing
|
||||
if len(sc.Web) == 0 {
|
||||
sc.Web = nil
|
||||
}
|
||||
|
||||
if len(sc.TCP) == 0 {
|
||||
sc.TCP = nil
|
||||
}
|
||||
|
||||
// disable funnel if no remaining mounts exist for the serve port
|
||||
if sc.Web == nil && sc.TCP == nil {
|
||||
delete(sc.AllowFunnel, hp)
|
||||
}
|
||||
|
||||
if len(sc.AllowFunnel) == 0 {
|
||||
sc.AllowFunnel = nil
|
||||
}
|
||||
|
||||
sc.RemoveWebHandler(dnsName, srvPort, mounts, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -857,68 +781,10 @@ func (e *serveEnv) removeTCPServe(sc *ipn.ServeConfig, src uint16) error {
|
||||
if sc.IsServingWeb(src) {
|
||||
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src)
|
||||
}
|
||||
delete(sc.TCP, src)
|
||||
// clear map mostly for testing
|
||||
if len(sc.TCP) == 0 {
|
||||
sc.TCP = nil
|
||||
}
|
||||
sc.RemoveTCPForwarding(src)
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandProxyTargetDev expands the supported target values to be proxied
|
||||
// allowing for input values to be a port number, a partial URL, or a full URL
|
||||
// including a path.
|
||||
//
|
||||
// examples:
|
||||
// - 3000
|
||||
// - localhost:3000
|
||||
// - tcp://localhost:3000
|
||||
// - http://localhost:3000
|
||||
// - https://localhost:3000
|
||||
// - https-insecure://localhost:3000
|
||||
// - https-insecure://localhost:3000/foo
|
||||
func expandProxyTargetDev(target string, supportedSchemes []string, defaultScheme string) (string, error) {
|
||||
const host = "127.0.0.1"
|
||||
|
||||
// support target being a port number
|
||||
if port, err := strconv.ParseUint(target, 10, 16); err == nil {
|
||||
return fmt.Sprintf("%s://%s:%d", defaultScheme, host, port), nil
|
||||
}
|
||||
|
||||
// prepend scheme if not present
|
||||
if !strings.Contains(target, "://") {
|
||||
target = defaultScheme + "://" + target
|
||||
}
|
||||
|
||||
// make sure we can parse the target
|
||||
u, err := url.ParseRequestURI(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL %w", err)
|
||||
}
|
||||
|
||||
// ensure a supported scheme
|
||||
if !slices.Contains(supportedSchemes, u.Scheme) {
|
||||
return "", fmt.Errorf("must be a URL starting with one of the supported schemes: %v", supportedSchemes)
|
||||
}
|
||||
|
||||
// validate the host.
|
||||
switch u.Hostname() {
|
||||
case "localhost", "127.0.0.1":
|
||||
default:
|
||||
return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported")
|
||||
}
|
||||
|
||||
// validate the port
|
||||
port, err := strconv.ParseUint(u.Port(), 10, 16)
|
||||
if err != nil || port == 0 {
|
||||
return "", fmt.Errorf("invalid port %q", u.Port())
|
||||
}
|
||||
|
||||
u.Host = fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// cleanURLPath ensures the path is clean and has a leading "/".
|
||||
func cleanURLPath(urlPath string) (string, error) {
|
||||
if urlPath == "" {
|
||||
|
||||
@@ -1041,63 +1041,6 @@ func TestSrcTypeFromFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandProxyTargetDev(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
defaultScheme string
|
||||
supportedSchemes []string
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "port-only", input: "8080", expected: "http://127.0.0.1:8080"},
|
||||
{name: "hostname+port", input: "localhost:8080", expected: "http://127.0.0.1:8080"},
|
||||
{name: "convert-localhost", input: "http://localhost:8080", expected: "http://127.0.0.1:8080"},
|
||||
{name: "no-change", input: "http://127.0.0.1:8080", expected: "http://127.0.0.1:8080"},
|
||||
{name: "include-path", input: "http://127.0.0.1:8080/foo", expected: "http://127.0.0.1:8080/foo"},
|
||||
{name: "https-scheme", input: "https://localhost:8080", expected: "https://127.0.0.1:8080"},
|
||||
{name: "https+insecure-scheme", input: "https+insecure://localhost:8080", expected: "https+insecure://127.0.0.1:8080"},
|
||||
{name: "change-default-scheme", input: "localhost:8080", defaultScheme: "https", expected: "https://127.0.0.1:8080"},
|
||||
{name: "change-supported-schemes", input: "localhost:8080", defaultScheme: "tcp", supportedSchemes: []string{"tcp"}, expected: "tcp://127.0.0.1:8080"},
|
||||
|
||||
// errors
|
||||
{name: "invalid-port", input: "localhost:9999999", wantErr: true},
|
||||
{name: "unsupported-scheme", input: "ftp://localhost:8080", expected: "", wantErr: true},
|
||||
{name: "not-localhost", input: "https://tailscale.com:8080", expected: "", wantErr: true},
|
||||
{name: "empty-input", input: "", expected: "", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
defaultScheme := "http"
|
||||
supportedSchemes := []string{"http", "https", "https+insecure"}
|
||||
|
||||
if tt.supportedSchemes != nil {
|
||||
supportedSchemes = tt.supportedSchemes
|
||||
}
|
||||
if tt.defaultScheme != "" {
|
||||
defaultScheme = tt.defaultScheme
|
||||
}
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual, err := expandProxyTargetDev(tt.input, supportedSchemes, defaultScheme)
|
||||
|
||||
if tt.wantErr == true && err == nil {
|
||||
t.Errorf("Expected an error but got none")
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantErr == false && err != nil {
|
||||
t.Errorf("Got an error, but didn't expect one: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if actual != tt.expected {
|
||||
t.Errorf("Got: %q; expected: %q", actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanURLPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
@@ -38,24 +38,25 @@ Only settings explicitly mentioned will be set. There are no default values.`,
|
||||
}
|
||||
|
||||
type setArgsT struct {
|
||||
acceptRoutes bool
|
||||
acceptDNS bool
|
||||
exitNodeIP string
|
||||
exitNodeAllowLANAccess bool
|
||||
shieldsUp bool
|
||||
runSSH bool
|
||||
runWebClient bool
|
||||
hostname string
|
||||
advertiseRoutes string
|
||||
advertiseDefaultRoute bool
|
||||
advertiseConnector bool
|
||||
opUser string
|
||||
acceptedRisks string
|
||||
profileName string
|
||||
forceDaemon bool
|
||||
updateCheck bool
|
||||
updateApply bool
|
||||
postureChecking bool
|
||||
acceptRoutes bool
|
||||
acceptDNS bool
|
||||
exitNodeIP string
|
||||
exitNodeAllowLANAccess bool
|
||||
exitDestinationFlowLogs bool
|
||||
shieldsUp bool
|
||||
runSSH bool
|
||||
runWebClient bool
|
||||
hostname string
|
||||
advertiseRoutes string
|
||||
advertiseDefaultRoute bool
|
||||
advertiseConnector bool
|
||||
opUser string
|
||||
acceptedRisks string
|
||||
profileName string
|
||||
forceDaemon bool
|
||||
updateCheck bool
|
||||
updateApply bool
|
||||
postureChecking bool
|
||||
}
|
||||
|
||||
func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
|
||||
@@ -66,6 +67,7 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
|
||||
setf.BoolVar(&setArgs.acceptDNS, "accept-dns", false, "accept DNS configuration from the admin panel")
|
||||
setf.StringVar(&setArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node")
|
||||
setf.BoolVar(&setArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
|
||||
setf.BoolVar(&setArgs.exitDestinationFlowLogs, "exit-destination-flow-logs", false, "Enable exit node destination in network flow logs")
|
||||
setf.BoolVar(&setArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
|
||||
setf.BoolVar(&setArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy")
|
||||
setf.StringVar(&setArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
|
||||
@@ -75,7 +77,7 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
|
||||
setf.BoolVar(&setArgs.updateCheck, "update-check", true, "notify about available Tailscale updates")
|
||||
setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version")
|
||||
setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, "HIDDEN: allow management plane to gather device posture information")
|
||||
setf.BoolVar(&setArgs.runWebClient, "webclient", false, "run a web interface for managing this node, served over Tailscale at port 5252")
|
||||
setf.BoolVar(&setArgs.runWebClient, "webclient", false, "expose the web interface for managing this node over Tailscale at port 5252")
|
||||
|
||||
if safesocket.GOOSUsesPeerCreds(goos) {
|
||||
setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
|
||||
@@ -106,16 +108,17 @@ func runSet(ctx context.Context, args []string) (retErr error) {
|
||||
|
||||
maskedPrefs := &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
ProfileName: setArgs.profileName,
|
||||
RouteAll: setArgs.acceptRoutes,
|
||||
CorpDNS: setArgs.acceptDNS,
|
||||
ExitNodeAllowLANAccess: setArgs.exitNodeAllowLANAccess,
|
||||
ShieldsUp: setArgs.shieldsUp,
|
||||
RunSSH: setArgs.runSSH,
|
||||
RunWebClient: setArgs.runWebClient,
|
||||
Hostname: setArgs.hostname,
|
||||
OperatorUser: setArgs.opUser,
|
||||
ForceDaemon: setArgs.forceDaemon,
|
||||
ProfileName: setArgs.profileName,
|
||||
RouteAll: setArgs.acceptRoutes,
|
||||
CorpDNS: setArgs.acceptDNS,
|
||||
ExitNodeAllowLANAccess: setArgs.exitNodeAllowLANAccess,
|
||||
ExitDestinationFlowLogs: setArgs.exitDestinationFlowLogs,
|
||||
ShieldsUp: setArgs.shieldsUp,
|
||||
RunSSH: setArgs.runSSH,
|
||||
RunWebClient: setArgs.runWebClient,
|
||||
Hostname: setArgs.hostname,
|
||||
OperatorUser: setArgs.opUser,
|
||||
ForceDaemon: setArgs.forceDaemon,
|
||||
AutoUpdate: ipn.AutoUpdatePrefs{
|
||||
Check: setArgs.updateCheck,
|
||||
Apply: opt.NewBool(setArgs.updateApply),
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
@@ -15,7 +14,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
shareAddUsage = "share add <name> <path>"
|
||||
shareSetUsage = "share set <name> <path>"
|
||||
shareRenameUsage = "share rename <oldname> <newname>"
|
||||
shareRemoveUsage = "share remove <name>"
|
||||
shareListUsage = "share list"
|
||||
)
|
||||
@@ -24,7 +24,7 @@ var shareCmd = &ffcli.Command{
|
||||
Name: "share",
|
||||
ShortHelp: "Share a directory with your tailnet",
|
||||
ShortUsage: strings.Join([]string{
|
||||
shareAddUsage,
|
||||
shareSetUsage,
|
||||
shareRemoveUsage,
|
||||
shareListUsage,
|
||||
}, "\n "),
|
||||
@@ -32,9 +32,15 @@ var shareCmd = &ffcli.Command{
|
||||
UsageFunc: usageFuncNoDefaultValues,
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "add",
|
||||
Exec: runShareAdd,
|
||||
ShortHelp: "[ALPHA] add a share",
|
||||
Name: "set",
|
||||
Exec: runShareSet,
|
||||
ShortHelp: "[ALPHA] set a share",
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
{
|
||||
Name: "rename",
|
||||
ShortHelp: "[ALPHA] rename a share",
|
||||
Exec: runShareRename,
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
{
|
||||
@@ -55,20 +61,20 @@ var shareCmd = &ffcli.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// runShareAdd is the entry point for the "tailscale share add" command.
|
||||
func runShareAdd(ctx context.Context, args []string) error {
|
||||
// runShareSet is the entry point for the "tailscale share set" command.
|
||||
func runShareSet(ctx context.Context, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("usage: tailscale %v", shareAddUsage)
|
||||
return fmt.Errorf("usage: tailscale %v", shareSetUsage)
|
||||
}
|
||||
|
||||
name, path := args[0], args[1]
|
||||
|
||||
err := localClient.TailFSShareAdd(ctx, &tailfs.Share{
|
||||
err := localClient.TailFSShareSet(ctx, &tailfs.Share{
|
||||
Name: name,
|
||||
Path: path,
|
||||
})
|
||||
if err == nil {
|
||||
fmt.Printf("Added share %q at %q\n", name, path)
|
||||
fmt.Printf("Set share %q at %q\n", name, path)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -87,24 +93,31 @@ func runShareRemove(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// runShareRename is the entry point for the "tailscale share rename" command.
|
||||
func runShareRename(ctx context.Context, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("usage: tailscale %v", shareRenameUsage)
|
||||
}
|
||||
oldName := args[0]
|
||||
newName := args[1]
|
||||
|
||||
err := localClient.TailFSShareRename(ctx, oldName, newName)
|
||||
if err == nil {
|
||||
fmt.Printf("Renamed share %q to %q\n", oldName, newName)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// runShareList is the entry point for the "tailscale share list" command.
|
||||
func runShareList(ctx context.Context, args []string) error {
|
||||
if len(args) != 0 {
|
||||
return fmt.Errorf("usage: tailscale %v", shareListUsage)
|
||||
}
|
||||
|
||||
sharesMap, err := localClient.TailFSShareList(ctx)
|
||||
shares, err := localClient.TailFSShareList(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shares := make([]*tailfs.Share, 0, len(sharesMap))
|
||||
for _, share := range sharesMap {
|
||||
shares = append(shares, share)
|
||||
}
|
||||
|
||||
sort.Slice(shares, func(i, j int) bool {
|
||||
return shares[i].Name < shares[j].Name
|
||||
})
|
||||
|
||||
longestName := 4 // "name"
|
||||
longestPath := 4 // "path"
|
||||
@@ -157,7 +170,7 @@ For example, to enable sharing and accessing shares for all member nodes:
|
||||
|
||||
Each share is identified by a name and points to a directory at a specific path. For example, to share the path /Users/me/Documents under the name "docs", you would run:
|
||||
|
||||
$ tailscale share add docs /Users/me/Documents
|
||||
$ tailscale share set docs /Users/me/Documents
|
||||
|
||||
Note that the system forces share names to lowercase to avoid problems with clients that don't support case-sensitive filenames.
|
||||
|
||||
@@ -211,9 +224,13 @@ To categorically give yourself access to all your shares, you can use the below
|
||||
|
||||
Whenever either you or anyone in the group "home" connects to the share, they connect as if they are using your local machine user. They'll be able to read the same files as your user and if they create files, those files will be owned by your user.%s
|
||||
|
||||
You can rename shares, for example you could rename the above share by running:
|
||||
|
||||
$ tailscale share rename docs newdocs
|
||||
|
||||
You can remove shares by name, for example you could remove the above share by running:
|
||||
|
||||
$ tailscale share remove docs
|
||||
$ tailscale share remove newdocs
|
||||
|
||||
You can get a list of currently published shares by running:
|
||||
|
||||
@@ -223,4 +240,4 @@ var shareLongHelpAs = `
|
||||
|
||||
If you want a share to be accessed as a different user, you can use sudo to accomplish this. For example, to create the aforementioned share as "theuser", you could run:
|
||||
|
||||
$ sudo -u theuser tailscale share add docs /Users/theuser/Documents`
|
||||
$ sudo -u theuser tailscale share set docs /Users/theuser/Documents`
|
||||
|
||||
@@ -48,8 +48,8 @@ The 'tailscale ssh' wrapper adds a few things:
|
||||
}
|
||||
|
||||
func runSSH(ctx context.Context, args []string) error {
|
||||
if runtime.GOOS == "darwin" && version.IsSandboxedMacOS() && !envknob.UseWIPCode() {
|
||||
return errors.New("The 'tailscale ssh' subcommand is not available on sandboxed macOS builds.\nUse the regular 'ssh' client instead.")
|
||||
if runtime.GOOS == "darwin" && version.IsMacAppStore() && !envknob.UseWIPCode() {
|
||||
return errors.New("The 'tailscale ssh' subcommand is not available on macOS builds distributed through the App Store or TestFlight.\nInstall the Standalone variant of Tailscale (download it from https://pkgs.tailscale.com), or use the regular 'ssh' client instead.")
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: ssh [user@]<host>")
|
||||
|
||||
@@ -652,6 +652,7 @@ func upWorthyWarning(s string) bool {
|
||||
return strings.Contains(s, healthmsg.TailscaleSSHOnBut) ||
|
||||
strings.Contains(s, healthmsg.WarnAcceptRoutesOff) ||
|
||||
strings.Contains(s, healthmsg.LockedOut) ||
|
||||
strings.Contains(s, healthmsg.WarnExitNodeUsage) ||
|
||||
strings.Contains(strings.ToLower(s), "update available: ")
|
||||
}
|
||||
|
||||
@@ -722,6 +723,7 @@ func init() {
|
||||
addPrefFlagMapping("auto-update", "AutoUpdate.Apply")
|
||||
addPrefFlagMapping("advertise-connector", "AppConnector")
|
||||
addPrefFlagMapping("posture-checking", "PostureChecking")
|
||||
addPrefFlagMapping("exit-destination-flow-logs", "ExitDestinationFlowLogs")
|
||||
}
|
||||
|
||||
func addPrefFlagMapping(flagName string, prefNames ...string) {
|
||||
@@ -950,6 +952,8 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) {
|
||||
set(exitNodeIPStr())
|
||||
case "exit-node-allow-lan-access":
|
||||
set(prefs.ExitNodeAllowLANAccess)
|
||||
case "exit-destination-flow-logs":
|
||||
set(prefs.ExitDestinationFlowLogs)
|
||||
case "advertise-tags":
|
||||
set(strings.Join(prefs.AdvertiseTags, ","))
|
||||
case "hostname":
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
var updateCmd = &ffcli.Command{
|
||||
Name: "update",
|
||||
ShortUsage: "update",
|
||||
ShortHelp: "[BETA] Update Tailscale to the latest/different version",
|
||||
ShortHelp: "Update Tailscale to the latest/different version",
|
||||
Exec: runUpdate,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("update")
|
||||
|
||||
@@ -78,6 +78,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
L github.com/aws/smithy-go/transport/http from github.com/aws/aws-sdk-go-v2/aws/middleware+
|
||||
L github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
|
||||
L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm
|
||||
github.com/bits-and-blooms/bitset from github.com/gaissmai/bart
|
||||
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
|
||||
L github.com/coreos/go-systemd/v22/dbus from tailscale.com/clientupdate
|
||||
LD 💣 github.com/creack/pty from tailscale.com/ssh/tailssh
|
||||
@@ -89,6 +90,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
LW 💣 github.com/digitalocean/go-smbios/smbios from tailscale.com/posture
|
||||
💣 github.com/djherbis/times from tailscale.com/tailfs/tailfsimpl
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
github.com/gaissmai/bart from tailscale.com/net/tstun
|
||||
W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+
|
||||
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
|
||||
L 💣 github.com/godbus/dbus/v5 from github.com/coreos/go-systemd/v22/dbus+
|
||||
@@ -100,7 +102,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
L github.com/google/nftables/expr from github.com/google/nftables+
|
||||
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
|
||||
L github.com/google/nftables/xt from github.com/google/nftables/expr+
|
||||
github.com/google/uuid from tailscale.com/clientupdate
|
||||
github.com/google/uuid from tailscale.com/clientupdate+
|
||||
github.com/gorilla/csrf from tailscale.com/client/web
|
||||
github.com/gorilla/securecookie from github.com/gorilla/csrf
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/clientupdate/distsign+
|
||||
@@ -119,7 +121,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
github.com/klauspost/compress/huff0 from github.com/klauspost/compress/zstd
|
||||
github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/huff0+
|
||||
github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd
|
||||
github.com/klauspost/compress/zstd from tailscale.com/smallzstd
|
||||
github.com/klauspost/compress/zstd from tailscale.com/util/zstdframe
|
||||
github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd
|
||||
github.com/kortschak/wol from tailscale.com/ipn/ipnlocal
|
||||
LD github.com/kr/fs from github.com/pkg/sftp
|
||||
@@ -308,14 +310,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/tsdial from tailscale.com/cmd/tailscaled+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/clientupdate/distsign+
|
||||
tailscale.com/net/tstun from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/net/tstun/table from tailscale.com/net/tstun
|
||||
tailscale.com/net/wsconn from tailscale.com/control/controlhttp+
|
||||
tailscale.com/paths from tailscale.com/client/tailscale+
|
||||
💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/posture from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/proxymap from tailscale.com/tsd+
|
||||
💣 tailscale.com/safesocket from tailscale.com/client/tailscale+
|
||||
tailscale.com/smallzstd from tailscale.com/control/controlclient+
|
||||
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/syncs from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale+
|
||||
@@ -325,7 +325,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/tailfs/tailfsimpl/compositedav from tailscale.com/tailfs/tailfsimpl
|
||||
tailscale.com/tailfs/tailfsimpl/dirfs from tailscale.com/tailfs/tailfsimpl+
|
||||
tailscale.com/tailfs/tailfsimpl/shared from tailscale.com/tailfs/tailfsimpl+
|
||||
💣 tailscale.com/tempfork/device from tailscale.com/net/tstun/table
|
||||
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
|
||||
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||
@@ -377,6 +376,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
W 💣 tailscale.com/util/osdiag/internal/wsc from tailscale.com/util/osdiag
|
||||
tailscale.com/util/osshare from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/util/osuser from tailscale.com/ipn/localapi+
|
||||
tailscale.com/util/progresstracking from tailscale.com/ipn/localapi
|
||||
tailscale.com/util/race from tailscale.com/net/dns/resolver
|
||||
tailscale.com/util/racebuild from tailscale.com/logpolicy
|
||||
tailscale.com/util/rands from tailscale.com/ipn/ipnlocal+
|
||||
@@ -393,6 +393,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+
|
||||
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/util/zstdframe from tailscale.com/control/controlclient+
|
||||
tailscale.com/version from tailscale.com/client/web+
|
||||
tailscale.com/version/distro from tailscale.com/client/web+
|
||||
W tailscale.com/wf from tailscale.com/cmd/tailscaled
|
||||
@@ -405,7 +406,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/wgengine/router from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
|
||||
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine
|
||||
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine+
|
||||
tailscale.com/wgengine/wglog from tailscale.com/wgengine
|
||||
W 💣 tailscale.com/wgengine/winnet from tailscale.com/wgengine/router
|
||||
golang.org/x/crypto/argon2 from tailscale.com/tka
|
||||
@@ -522,7 +523,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
math/rand from github.com/mdlayher/netlink+
|
||||
math/rand/v2 from tailscale.com/util/rands
|
||||
mime from github.com/tailscale/xnet/webdav+
|
||||
mime/multipart from net/http
|
||||
mime/multipart from net/http+
|
||||
mime/quotedprintable from mime/multipart
|
||||
net from crypto/tls+
|
||||
net/http from expvar+
|
||||
|
||||
@@ -432,13 +432,26 @@ func startIPNServer(ctx context.Context, logf logger.Logf, logID logid.PublicID,
|
||||
if sigPipe != nil {
|
||||
signal.Ignore(sigPipe)
|
||||
}
|
||||
wgEngineCreated := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case s := <-interrupt:
|
||||
logf("tailscaled got signal %v; shutting down", s)
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
// continue
|
||||
var wgEngineClosed <-chan struct{}
|
||||
wgEngineCreated := wgEngineCreated // local shadow
|
||||
for {
|
||||
select {
|
||||
case s := <-interrupt:
|
||||
logf("tailscaled got signal %v; shutting down", s)
|
||||
cancel()
|
||||
return
|
||||
case <-wgEngineClosed:
|
||||
logf("wgengine has been closed; shutting down")
|
||||
cancel()
|
||||
return
|
||||
case <-wgEngineCreated:
|
||||
wgEngineClosed = sys.Engine.Get().Done()
|
||||
wgEngineCreated = nil
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -464,6 +477,7 @@ func startIPNServer(ctx context.Context, logf logger.Logf, logID logid.PublicID,
|
||||
if err == nil {
|
||||
logf("got LocalBackend in %v", time.Since(t0).Round(time.Millisecond))
|
||||
srv.SetLocalBackend(lb)
|
||||
close(wgEngineCreated)
|
||||
return
|
||||
}
|
||||
lbErr.Store(err) // before the following cancel
|
||||
|
||||
@@ -90,8 +90,11 @@ func newIPN(jsConfig js.Value) map[string]any {
|
||||
c := logtail.Config{
|
||||
Collection: lpc.Collection,
|
||||
PrivateID: lpc.PrivateID,
|
||||
// NewZstdEncoder is intentionally not passed in, compressed requests
|
||||
// set HTTP headers that are not supported by the no-cors fetching mode.
|
||||
|
||||
// Compressed requests set HTTP headers that are not supported by the
|
||||
// no-cors fetching mode:
|
||||
CompressLogs: false,
|
||||
|
||||
HTTPC: &http.Client{Transport: &noCORSTransport{http.DefaultTransport}},
|
||||
}
|
||||
logtail := logtail.NewLogger(c, log.Printf)
|
||||
|
||||
@@ -172,6 +172,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
|
||||
switch elem.String() {
|
||||
case "byte":
|
||||
args.FieldType = it.QualifiedName(fieldType)
|
||||
it.Import("tailscale.com/types/views")
|
||||
writeTemplate("byteSliceField")
|
||||
default:
|
||||
args.FieldType = it.QualifiedName(elem)
|
||||
|
||||
@@ -42,7 +42,6 @@ import (
|
||||
"tailscale.com/net/tlsdial"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/net/tshttpproxy"
|
||||
"tailscale.com/smallzstd"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/tstime"
|
||||
@@ -57,6 +56,7 @@ import (
|
||||
"tailscale.com/util/singleflight"
|
||||
"tailscale.com/util/syspolicy"
|
||||
"tailscale.com/util/systemd"
|
||||
"tailscale.com/util/zstdframe"
|
||||
)
|
||||
|
||||
// Direct is the client that connects to a tailcontrol server for a node.
|
||||
@@ -180,11 +180,6 @@ type Pinger interface {
|
||||
Ping(ctx context.Context, ip netip.Addr, pingType tailcfg.PingType, size int) (*ipnstate.PingResult, error)
|
||||
}
|
||||
|
||||
type Decompressor interface {
|
||||
DecodeAll(input, dst []byte) ([]byte, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
// NetmapUpdater is the interface needed by the controlclient to enact change in
|
||||
// the world as a function of updates received from the network.
|
||||
type NetmapUpdater interface {
|
||||
@@ -641,6 +636,9 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
if err != nil {
|
||||
return regen, opt.URL, nil, err
|
||||
}
|
||||
addLBHeader(req, request.OldNodeKey)
|
||||
addLBHeader(req, request.NodeKey)
|
||||
|
||||
res, err := httpc.Do(req)
|
||||
if err != nil {
|
||||
return regen, opt.URL, nil, fmt.Errorf("register request: %w", err)
|
||||
@@ -884,10 +882,11 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
vlogf = c.logf
|
||||
}
|
||||
|
||||
nodeKey := persist.PublicNodeKey()
|
||||
request := &tailcfg.MapRequest{
|
||||
Version: tailcfg.CurrentCapabilityVersion,
|
||||
KeepAlive: true,
|
||||
NodeKey: persist.PublicNodeKey(),
|
||||
NodeKey: nodeKey,
|
||||
DiscoKey: c.discoPubKey,
|
||||
Endpoints: eps,
|
||||
EndpointTypes: epTypes,
|
||||
@@ -942,10 +941,35 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
url = strings.Replace(url, "http:", "https:", 1)
|
||||
}
|
||||
|
||||
// Create a watchdog timer that breaks the connection if we don't receive a
|
||||
// MapResponse from the network at least once every two minutes. The
|
||||
// watchdog timer is stopped every time we receive a MapResponse (so it
|
||||
// doesn't run when we're processing a MapResponse message, including any
|
||||
// long-running requested operations like Debug.Sleep) and is reset whenever
|
||||
// we go back to blocking on network reads.
|
||||
// The watchdog timer also covers the initial request (effectively the
|
||||
// pre-body and initial-body read timeouts) as we do not have any other
|
||||
// keep-alive mechanism for the initial request.
|
||||
watchdogTimer, watchdogTimedOut := c.clock.NewTimer(watchdogTimeout)
|
||||
defer watchdogTimer.Stop()
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
vlogf("netmap: ending timeout goroutine")
|
||||
return
|
||||
case <-watchdogTimedOut:
|
||||
c.logf("map response long-poll timed out!")
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addLBHeader(req, nodeKey)
|
||||
|
||||
res, err := httpc.Do(req)
|
||||
if err != nil {
|
||||
@@ -962,6 +986,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
defer res.Body.Close()
|
||||
|
||||
health.NoteMapRequestHeard(request)
|
||||
watchdogTimer.Reset(watchdogTimeout)
|
||||
|
||||
if nu == nil {
|
||||
io.Copy(io.Discard, res.Body)
|
||||
@@ -993,27 +1018,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, isStreaming bool, nu Netmap
|
||||
c.expiry = nm.Expiry
|
||||
}
|
||||
|
||||
// Create a watchdog timer that breaks the connection if we don't receive a
|
||||
// MapResponse from the network at least once every two minutes. The
|
||||
// watchdog timer is stopped every time we receive a MapResponse (so it
|
||||
// doesn't run when we're processing a MapResponse message, including any
|
||||
// long-running requested operations like Debug.Sleep) and is reset whenever
|
||||
// we go back to blocking on network reads.
|
||||
watchdogTimer, watchdogTimedOut := c.clock.NewTimer(watchdogTimeout)
|
||||
defer watchdogTimer.Stop()
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
vlogf("netmap: ending timeout goroutine")
|
||||
return
|
||||
case <-watchdogTimedOut:
|
||||
c.logf("map response long-poll timed out!")
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// gotNonKeepAliveMessage is whether we've yet received a MapResponse message without
|
||||
// KeepAlive set.
|
||||
var gotNonKeepAliveMessage bool
|
||||
@@ -1203,12 +1207,7 @@ func (c *Direct) decodeMsg(msg []byte, v any, mkey key.MachinePrivate) error {
|
||||
} else {
|
||||
decrypted = msg
|
||||
}
|
||||
decoder, err := smallzstd.NewDecoder(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer decoder.Close()
|
||||
b, err := decoder.DecodeAll(decrypted, nil)
|
||||
b, err := zstdframe.AppendDecode(nil, decrypted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1537,7 +1536,7 @@ func (c *Direct) setDNSNoise(ctx context.Context, req *tailcfg.SetDNSRequest) er
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := nc.post(ctx, "/machine/set-dns", &newReq)
|
||||
res, err := nc.post(ctx, "/machine/set-dns", newReq.NodeKey, &newReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1714,8 +1713,13 @@ func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) {
|
||||
// Don't report errors to control if the server doesn't support noise.
|
||||
return
|
||||
}
|
||||
nodeKey, ok := c.GetPersist().PublicNodeKeyOK()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req := &tailcfg.HealthChangeRequest{
|
||||
Subsys: string(sys),
|
||||
Subsys: string(sys),
|
||||
NodeKey: nodeKey,
|
||||
}
|
||||
if sysErr != nil {
|
||||
req.Error = sysErr.Error()
|
||||
@@ -1724,7 +1728,7 @@ func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) {
|
||||
// Best effort, no logging:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
res, err := np.post(ctx, "/machine/update-health", req)
|
||||
res, err := np.post(ctx, "/machine/update-health", nodeKey, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -1768,6 +1772,12 @@ func decodeWrappedAuthkey(key string, logf logger.Logf) (authKey string, isWrapp
|
||||
return authKey, true, sig, priv
|
||||
}
|
||||
|
||||
func addLBHeader(req *http.Request, nodeKey key.NodePublic) {
|
||||
if !nodeKey.IsZero() {
|
||||
req.Header.Add(tailcfg.LBHeader, nodeKey.String())
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
metricMapRequestsActive = clientmetric.NewGauge("controlclient_map_requests_active")
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -28,6 +31,7 @@ import (
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/wgengine/filter"
|
||||
)
|
||||
|
||||
@@ -71,6 +75,7 @@ type mapSession struct {
|
||||
// Fields storing state over the course of multiple MapResponses.
|
||||
lastPrintMap time.Time
|
||||
lastNode tailcfg.NodeView
|
||||
lastCapSet set.Set[tailcfg.NodeCapability]
|
||||
peers map[tailcfg.NodeID]*tailcfg.NodeView // pointer to view (oddly). same pointers as sortedPeers.
|
||||
sortedPeers []*tailcfg.NodeView // same pointers as peers, but sorted by Node.ID
|
||||
lastDNSConfig *tailcfg.DNSConfig
|
||||
@@ -167,7 +172,15 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
|
||||
resp.Node.Capabilities = nil
|
||||
resp.Node.CapMap = nil
|
||||
}
|
||||
ms.controlKnobs.UpdateFromNodeAttributes(resp.Node.Capabilities, resp.Node.CapMap)
|
||||
// If the server is old and is still sending us Capabilities instead of
|
||||
// CapMap, convert it to CapMap early so the rest of the client code can
|
||||
// work only in terms of CapMap.
|
||||
for _, c := range resp.Node.Capabilities {
|
||||
if _, ok := resp.Node.CapMap[c]; !ok {
|
||||
mak.Set(&resp.Node.CapMap, c, nil)
|
||||
}
|
||||
}
|
||||
ms.controlKnobs.UpdateFromNodeAttributes(resp.Node.CapMap)
|
||||
}
|
||||
|
||||
// Call Node.InitDisplayNames on any changed nodes.
|
||||
@@ -186,6 +199,12 @@ func (ms *mapSession) HandleNonKeepAliveMapResponse(ctx context.Context, resp *t
|
||||
// our UpdateFullNetmap call). This is the part we tried to avoid but
|
||||
// some field mutations (especially rare ones) aren't yet handled.
|
||||
|
||||
if runtime.GOOS == "ios" {
|
||||
// Memory is tight on iOS. Free what we can while we
|
||||
// can before this potential burst of in-use memory.
|
||||
debug.FreeOSMemory()
|
||||
}
|
||||
|
||||
nm := ms.netmap()
|
||||
ms.lastNetmapSummary = nm.VeryConcise()
|
||||
ms.occasionallyPrintSummary(ms.lastNetmapSummary)
|
||||
@@ -230,6 +249,15 @@ func (ms *mapSession) updateStateFromResponse(resp *tailcfg.MapResponse) {
|
||||
|
||||
if resp.Node != nil {
|
||||
ms.lastNode = resp.Node.View()
|
||||
|
||||
capSet := set.Set[tailcfg.NodeCapability]{}
|
||||
for _, c := range resp.Node.Capabilities {
|
||||
capSet.Add(c)
|
||||
}
|
||||
for c := range resp.Node.CapMap {
|
||||
capSet.Add(c)
|
||||
}
|
||||
ms.lastCapSet = capSet
|
||||
}
|
||||
|
||||
for _, up := range resp.UserProfiles {
|
||||
@@ -334,7 +362,6 @@ var (
|
||||
patchOnline = clientmetric.NewCounter("controlclient_patch_online")
|
||||
patchLastSeen = clientmetric.NewCounter("controlclient_patch_lastseen")
|
||||
patchKeyExpiry = clientmetric.NewCounter("controlclient_patch_keyexpiry")
|
||||
patchCapabilities = clientmetric.NewCounter("controlclient_patch_capabilities")
|
||||
patchCapMap = clientmetric.NewCounter("controlclient_patch_capmap")
|
||||
patchKeySignature = clientmetric.NewCounter("controlclient_patch_keysig")
|
||||
|
||||
@@ -456,10 +483,6 @@ func (ms *mapSession) updatePeersStateFromResponse(resp *tailcfg.MapResponse) (s
|
||||
mut.KeyExpiry = *v
|
||||
patchKeyExpiry.Add(1)
|
||||
}
|
||||
if v := pc.Capabilities; v != nil {
|
||||
mut.Capabilities = *v
|
||||
patchCapabilities.Add(1)
|
||||
}
|
||||
if v := pc.KeySignature; v != nil {
|
||||
mut.KeySignature = v
|
||||
patchKeySignature.Add(1)
|
||||
@@ -581,6 +604,9 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
|
||||
continue
|
||||
case "DataPlaneAuditLogID":
|
||||
// Not sent for peers.
|
||||
case "Capabilities":
|
||||
// Deprecated; see https://github.com/tailscale/tailscale/issues/11508
|
||||
// And it was never sent by any known control server.
|
||||
case "ID":
|
||||
if was.ID() != n.ID {
|
||||
return nil, false
|
||||
@@ -664,9 +690,22 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
|
||||
pc().Cap = n.Cap
|
||||
}
|
||||
case "CapMap":
|
||||
if n.CapMap != nil {
|
||||
pc().CapMap = n.CapMap
|
||||
if len(n.CapMap) != was.CapMap().Len() {
|
||||
if n.CapMap == nil {
|
||||
pc().CapMap = make(tailcfg.NodeCapMap)
|
||||
} else {
|
||||
pc().CapMap = maps.Clone(n.CapMap)
|
||||
}
|
||||
break
|
||||
}
|
||||
was.CapMap().Range(func(k tailcfg.NodeCapability, v views.Slice[tailcfg.RawMessage]) bool {
|
||||
nv, ok := n.CapMap[k]
|
||||
if !ok || !views.SliceEqual(v, views.SliceOf(nv)) {
|
||||
pc().CapMap = maps.Clone(n.CapMap)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
case "Tags":
|
||||
if !views.SliceEqual(was.Tags(), views.SliceOf(n.Tags)) {
|
||||
return nil, false
|
||||
@@ -689,10 +728,6 @@ func peerChangeDiff(was tailcfg.NodeView, n *tailcfg.Node) (_ *tailcfg.PeerChang
|
||||
if was.MachineAuthorized() != n.MachineAuthorized {
|
||||
return nil, false
|
||||
}
|
||||
case "Capabilities":
|
||||
if !views.SliceEqual(was.Capabilities(), views.SliceOf(n.Capabilities)) {
|
||||
pc().Capabilities = ptr.To(n.Capabilities)
|
||||
}
|
||||
case "UnsignedPeerAPIOnly":
|
||||
if was.UnsignedPeerAPIOnly() != n.UnsignedPeerAPIOnly {
|
||||
return nil, false
|
||||
@@ -781,6 +816,7 @@ func (ms *mapSession) netmap() *netmap.NetworkMap {
|
||||
nm.SelfNode = node
|
||||
nm.Expiry = node.KeyExpiry()
|
||||
nm.Name = node.Name()
|
||||
nm.AllCaps = ms.lastCapSet
|
||||
}
|
||||
|
||||
ms.addUserProfile(nm, nm.User())
|
||||
|
||||
@@ -331,23 +331,7 @@ func TestUpdatePeersStateFromResponse(t *testing.T) {
|
||||
}),
|
||||
wantStats: updateStats{changed: 1},
|
||||
},
|
||||
{
|
||||
name: "change_capabilities",
|
||||
prev: peers(n(1, "foo")),
|
||||
mapRes: &tailcfg.MapResponse{
|
||||
PeersChangedPatch: []*tailcfg.PeerChange{{
|
||||
NodeID: 1,
|
||||
Capabilities: ptr.To([]tailcfg.NodeCapability{"foo"}),
|
||||
}},
|
||||
},
|
||||
want: peers(&tailcfg.Node{
|
||||
ID: 1,
|
||||
Name: "foo",
|
||||
Capabilities: []tailcfg.NodeCapability{"foo"},
|
||||
}),
|
||||
wantStats: updateStats{changed: 1},
|
||||
}}
|
||||
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if !tt.curTime.IsZero() {
|
||||
@@ -783,18 +767,6 @@ func TestPeerChangeDiff(t *testing.T) {
|
||||
b: &tailcfg.Node{ID: 1, LastSeen: ptr.To(time.Unix(2, 0))},
|
||||
want: &tailcfg.PeerChange{NodeID: 1, LastSeen: ptr.To(time.Unix(2, 0))},
|
||||
},
|
||||
{
|
||||
name: "patch-capabilities-to-nonempty",
|
||||
a: &tailcfg.Node{ID: 1, Capabilities: []tailcfg.NodeCapability{"foo"}},
|
||||
b: &tailcfg.Node{ID: 1, Capabilities: []tailcfg.NodeCapability{"bar"}},
|
||||
want: &tailcfg.PeerChange{NodeID: 1, Capabilities: ptr.To([]tailcfg.NodeCapability{"bar"})},
|
||||
},
|
||||
{
|
||||
name: "patch-capabilities-to-empty",
|
||||
a: &tailcfg.Node{ID: 1, Capabilities: []tailcfg.NodeCapability{"foo"}},
|
||||
b: &tailcfg.Node{ID: 1},
|
||||
want: &tailcfg.PeerChange{NodeID: 1, Capabilities: ptr.To([]tailcfg.NodeCapability(nil))},
|
||||
},
|
||||
{
|
||||
name: "patch-online-to-true",
|
||||
a: &tailcfg.Node{ID: 1, Online: ptr.To(false)},
|
||||
@@ -848,7 +820,41 @@ func TestPeerChangeDiff(t *testing.T) {
|
||||
a: &tailcfg.Node{ID: 1, SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3456"))},
|
||||
b: &tailcfg.Node{ID: 1, SelfNodeV6MasqAddrForThisPeer: ptr.To(netip.MustParseAddr("2001::3006"))},
|
||||
want: nil,
|
||||
}}
|
||||
},
|
||||
{
|
||||
name: "patch-capmap-add-value-to-existing-key",
|
||||
a: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
|
||||
b: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: []tailcfg.RawMessage{"true"}}},
|
||||
want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: []tailcfg.RawMessage{"true"}}},
|
||||
},
|
||||
{
|
||||
name: "patch-capmap-add-new-key",
|
||||
a: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
|
||||
b: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil, tailcfg.CapabilityDebug: nil}},
|
||||
want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil, tailcfg.CapabilityDebug: nil}},
|
||||
}, {
|
||||
name: "patch-capmap-remove-key",
|
||||
a: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
|
||||
b: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{}},
|
||||
want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{}},
|
||||
}, {
|
||||
name: "patch-capmap-remove-as-nil",
|
||||
a: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
|
||||
b: &tailcfg.Node{ID: 1},
|
||||
want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{}},
|
||||
}, {
|
||||
name: "patch-capmap-add-key-to-empty-map",
|
||||
a: &tailcfg.Node{ID: 1},
|
||||
b: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
|
||||
want: &tailcfg.PeerChange{NodeID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
|
||||
},
|
||||
{
|
||||
name: "patch-capmap-no-change",
|
||||
a: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
|
||||
b: &tailcfg.Node{ID: 1, CapMap: tailcfg.NodeCapMap{tailcfg.CapabilityAdmin: nil}},
|
||||
wantEqual: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pc, ok := peerChangeDiff(tt.a.View(), tt.b)
|
||||
|
||||
@@ -484,7 +484,9 @@ func (nc *NoiseClient) dial(ctx context.Context) (*noiseConn, error) {
|
||||
return ncc, nil
|
||||
}
|
||||
|
||||
func (nc *NoiseClient) post(ctx context.Context, path string, body any) (*http.Response, error) {
|
||||
// post does a POST to the control server at the given path, JSON-encoding body.
|
||||
// The provided nodeKey is an optional load balancing hint.
|
||||
func (nc *NoiseClient) post(ctx context.Context, path string, nodeKey key.NodePublic, body any) (*http.Response, error) {
|
||||
jbody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -493,6 +495,7 @@ func (nc *NoiseClient) post(ctx context.Context, path string, body any) (*http.R
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addLBHeader(req, nodeKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
conn, err := nc.getConn(ctx)
|
||||
|
||||
@@ -128,7 +128,7 @@ func (tt noiseClientTest) run(t *testing.T) {
|
||||
checkRes(t, res)
|
||||
|
||||
// And try using the high-level nc.post API as well.
|
||||
res, err = nc.post(context.Background(), "/", nil)
|
||||
res, err = nc.post(context.Background(), "/", key.NodePublic{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
package controlknobs
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
|
||||
"tailscale.com/syncs"
|
||||
@@ -77,14 +76,11 @@ type Knobs struct {
|
||||
|
||||
// UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
|
||||
// node attributes (Node.Capabilities).
|
||||
func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability, capMap tailcfg.NodeCapMap) {
|
||||
func (k *Knobs) UpdateFromNodeAttributes(capMap tailcfg.NodeCapMap) {
|
||||
if k == nil {
|
||||
return
|
||||
}
|
||||
has := func(attr tailcfg.NodeCapability) bool {
|
||||
_, ok := capMap[attr]
|
||||
return ok || slices.Contains(selfNodeAttrs, attr)
|
||||
}
|
||||
has := capMap.Contains
|
||||
var (
|
||||
keepFullWG = has(tailcfg.NodeAttrDebugDisableWGTrim)
|
||||
disableDRPO = has(tailcfg.NodeAttrDebugDisableDRPO)
|
||||
|
||||
@@ -795,7 +795,7 @@ func (c *Client) dialNodeUsingProxy(ctx context.Context, n *tailcfg.DERPNode, pr
|
||||
authHeader = fmt.Sprintf("Proxy-Authorization: %s\r\n", v)
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(proxyConn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n%s\r\n", target, pu.Hostname(), authHeader); err != nil {
|
||||
if _, err := fmt.Fprintf(proxyConn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n%s\r\n", target, target, authHeader); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Overview
|
||||
|
||||
There are quite a few ways of running Tailscale inside a Kubernetes Cluster, some of the common ones are covered in this doc.
|
||||
There are quite a few ways of running Tailscale inside a Kubernetes Cluster.
|
||||
This doc covers creating and managing your own Tailscale node deployments in cluster.
|
||||
If you want a higher level of automation, easier configuration, automated cleanup of stopped Tailscale devices, or a mechanism for exposing the [Kubernetes API](https://kubernetes.io/docs/concepts/overview/kubernetes-api/) server to the tailnet, take a look at [Tailscale Kubernetes operator](https://tailscale.com/kb/1236/kubernetes-operator).
|
||||
|
||||
:warning: Note that the manifests generated by the following commands are not intended for production use, and you will need to tweak them based on your environment and use case. For example, the commands to generate a standalone proxy manifest, will create a standalone `Pod`- this will not persist across cluster upgrades etc. :warning:
|
||||
|
||||
## Instructions
|
||||
|
||||
@@ -153,3 +157,74 @@ the entire Kubernetes cluster network (assuming NetworkPolicies allow) over Tail
|
||||
INTERNAL_PORT=8080
|
||||
curl http://$INTERNAL_IP:$INTERNAL_PORT
|
||||
```
|
||||
|
||||
## Multiple replicas
|
||||
|
||||
Note that if you want to use the `Pod` manifests generated by the commands above in a multi-replica setup (i.e a multi-replica `StatefulSet`) you will need to change the mechanism for storing tailscale state to ensure that multiple replicas are not attemting to use a single Kubernetes `Secret` to store their individual states.
|
||||
|
||||
To avoid proxy state clashes you could either store the state in memory or an `emptyDir` volume, or you could change the provided state `Secret` name to ensure that a unique name gets generated for each replica.
|
||||
|
||||
### Option 1: storing in an `emptyDir`
|
||||
|
||||
You can mount an [`emptyDir` volume](https://kubernetes.io/docs/concepts/storage/volumes/#emptydir) and configure the mount as the tailscale state store via `TS_STATE_DIR` env var.
|
||||
You must also set `TS_KUBE_SECRET` to an empty string.
|
||||
|
||||
An example:
|
||||
|
||||
```yaml
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: subnetrouter
|
||||
spec:
|
||||
replicas: 2
|
||||
...
|
||||
template:
|
||||
...
|
||||
spec:
|
||||
...
|
||||
volumes:
|
||||
- name: tsstate
|
||||
emptyDir: {}
|
||||
containers:
|
||||
- name: tailscale
|
||||
env:
|
||||
- name: TS_STATE_DIR
|
||||
value: /tsstate
|
||||
- name: TS_KUBE_SECRET
|
||||
value: ""
|
||||
volumeMounts:
|
||||
- name: tsstate
|
||||
mountPath: /tsstate
|
||||
```
|
||||
|
||||
The downside of this approach is that the state will be lost when a `Pod` is
|
||||
deleted. In practice this means that when you, for example, upgrade proxy
|
||||
versions you will get a new set of Tailscale devices with different hostnames.
|
||||
|
||||
### Option 2: dynamically generating unique `Secret` names
|
||||
|
||||
If you run the proxy as a `StatefulSet`, the `Pod`s get [stable identifiers](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#stable-network-id).
|
||||
You can use that to pass an individual, static state `Secret` name to each proxy:
|
||||
|
||||
```yaml
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: subnetrouter
|
||||
spec:
|
||||
replicas: 2
|
||||
...
|
||||
template:
|
||||
...
|
||||
spec:
|
||||
...
|
||||
containers:
|
||||
- name: tailscale
|
||||
env:
|
||||
- name: TS_KUBE_SECRET
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
apiVersion: v1
|
||||
fieldPath: metadata.name
|
||||
```
|
||||
|
||||
In this case, each replica will store its state in a `Secret` named the same as the `Pod` and as `Pod` names for a `StatefulSet` do not change if `Pod`s get recreated, proxy state will persist across cluster and proxy version updates etc.
|
||||
|
||||
@@ -32,6 +32,8 @@ spec:
|
||||
value: "{{TS_KUBE_SECRET}}"
|
||||
- name: TS_USERSPACE
|
||||
value: "false"
|
||||
- name: TS_DEBUG_FIREWALL_MODE
|
||||
value: auto
|
||||
- name: TS_AUTHKEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -18,6 +18,8 @@ spec:
|
||||
value: "{{TS_KUBE_SECRET}}"
|
||||
- name: TS_USERSPACE
|
||||
value: "false"
|
||||
- name: TS_DEBUG_FIREWALL_MODE
|
||||
value: auto
|
||||
- name: TS_AUTHKEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -17,7 +17,9 @@ spec:
|
||||
- name: TS_KUBE_SECRET
|
||||
value: "{{TS_KUBE_SECRET}}"
|
||||
- name: TS_USERSPACE
|
||||
value: "true"
|
||||
value: "false"
|
||||
- name: TS_DEBUG_FIREWALL_MODE
|
||||
value: auto
|
||||
- name: TS_AUTHKEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -492,7 +492,11 @@ func ApplyDiskConfig() (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
// Stash away our return error for the healthcheck package to use.
|
||||
applyDiskConfigErr = fmt.Errorf("error parsing %s: %w", f.Name(), err)
|
||||
if f != nil {
|
||||
applyDiskConfigErr = fmt.Errorf("error parsing %s: %w", f.Name(), err)
|
||||
} else {
|
||||
applyDiskConfigErr = fmt.Errorf("error applying disk config: %w", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -120,4 +120,4 @@
|
||||
in
|
||||
flake-utils.lib.eachDefaultSystem (system: flakeForSystem nixpkgs system);
|
||||
}
|
||||
# nix-direnv cache busting line: sha256-1g50+BwoUCwc/tBmnP2KO6e3GwL8QQ/wJ+XoxCzzk3k=
|
||||
# nix-direnv cache busting line: sha256-pFnWnB9jiI+gm9g+bzuYH9cm1n3t9jLOyoXYgfWAlc4=
|
||||
|
||||
11
go.mod
11
go.mod
@@ -4,6 +4,7 @@ go 1.22.0
|
||||
|
||||
require (
|
||||
filippo.io/mkcert v1.4.4
|
||||
fybrik.io/crdoc v0.6.3
|
||||
github.com/akutz/memconn v0.1.0
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa
|
||||
github.com/andybalholm/brotli v1.1.0
|
||||
@@ -27,6 +28,7 @@ require (
|
||||
github.com/evanw/esbuild v0.19.11
|
||||
github.com/frankban/quicktest v1.14.6
|
||||
github.com/fxamacker/cbor/v2 v2.5.0
|
||||
github.com/gaissmai/bart v0.4.1
|
||||
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0
|
||||
github.com/go-logr/zapr v1.3.0
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
@@ -73,7 +75,7 @@ require (
|
||||
github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734
|
||||
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85
|
||||
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1
|
||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6
|
||||
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272
|
||||
github.com/tailscale/xnet v0.0.0-20240117122442-62b9a7c569f9
|
||||
@@ -92,14 +94,14 @@ require (
|
||||
golang.org/x/net v0.20.0
|
||||
golang.org/x/oauth2 v0.16.0
|
||||
golang.org/x/sync v0.6.0
|
||||
golang.org/x/sys v0.16.0
|
||||
golang.org/x/sys v0.17.0
|
||||
golang.org/x/term v0.16.0
|
||||
golang.org/x/time v0.5.0
|
||||
golang.org/x/tools v0.17.0
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3
|
||||
gopkg.in/square/go-jose.v2 v2.6.0
|
||||
gvisor.dev/gvisor v0.0.0-20240119233241-c9c1d4f9b186
|
||||
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3
|
||||
honnef.co/go/tools v0.4.6
|
||||
k8s.io/api v0.29.1
|
||||
k8s.io/apimachinery v0.29.1
|
||||
@@ -114,6 +116,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.13.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
|
||||
github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 // indirect
|
||||
github.com/dave/brenda v1.1.0 // indirect
|
||||
@@ -179,7 +182,7 @@ require (
|
||||
github.com/denis-tingaikin/go-header v0.4.3 // indirect
|
||||
github.com/docker/cli v25.0.0+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker v25.0.0+incompatible // indirect
|
||||
github.com/docker/docker v25.0.5+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.8.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.2 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-1g50+BwoUCwc/tBmnP2KO6e3GwL8QQ/wJ+XoxCzzk3k=
|
||||
sha256-pFnWnB9jiI+gm9g+bzuYH9cm1n3t9jLOyoXYgfWAlc4=
|
||||
|
||||
22
go.sum
22
go.sum
@@ -46,6 +46,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
||||
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
|
||||
fybrik.io/crdoc v0.6.3 h1:jNNAVINu8up5vrLa0jrV7z7HSlyHF/6lNOrAtrXwYlI=
|
||||
fybrik.io/crdoc v0.6.3/go.mod h1:kvZRt7VAzOyrmDpIqREtcKAVFSJYEBoAyniYebsJGtQ=
|
||||
github.com/Abirdcfly/dupword v0.0.11 h1:z6v8rMETchZXUIuHxYNmlUAuKuB21PeaSymTed16wgU=
|
||||
github.com/Abirdcfly/dupword v0.0.11/go.mod h1:wH8mVGuf3CP5fsBTkfWwwwKTjDnVVCxtU8d8rgeVYXA=
|
||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||
@@ -165,6 +167,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
|
||||
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bkielbasa/cyclop v1.2.0 h1:7Jmnh0yL2DjKfw28p86YTd/B4lRGcNuu12sKE35sM7A=
|
||||
github.com/bkielbasa/cyclop v1.2.0/go.mod h1:qOI0yy6A7dYC4Zgsa72Ppm9kONl0RoIlPbzot9mhmeI=
|
||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4=
|
||||
@@ -250,8 +254,8 @@ github.com/docker/cli v25.0.0+incompatible h1:zaimaQdnX7fYWFqzN88exE9LDEvRslexpF
|
||||
github.com/docker/cli v25.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v25.0.0+incompatible h1:g9b6wZTblhMgzOT2tspESstfw6ySZ9kdm94BLDKaZac=
|
||||
github.com/docker/docker v25.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v25.0.5+incompatible h1:UmQydMduGkrD5nQde1mecF/YnSbTOaPeFIeP5C4W+DE=
|
||||
github.com/docker/docker v25.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker-credential-helpers v0.8.1 h1:j/eKUktUltBtMzKqmfLB0PAgqYyMHOp5vfsD1807oKo=
|
||||
github.com/docker/docker-credential-helpers v0.8.1/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
|
||||
github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
|
||||
@@ -292,6 +296,8 @@ github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADi
|
||||
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
|
||||
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
|
||||
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
|
||||
github.com/gaissmai/bart v0.4.1 h1:G1t58voWkNmT47lBDawH5QhtTDsdqRIO+ftq5x4P9Ls=
|
||||
github.com/gaissmai/bart v0.4.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg=
|
||||
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
|
||||
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
|
||||
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
|
||||
@@ -879,8 +885,8 @@ github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQ
|
||||
github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
|
||||
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w=
|
||||
github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61 h1:G6/VUGQkHbBffO0s3f51DThcHCWrShlWklcS4Zxh5BU=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20240208184856-443a64766f61/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g=
|
||||
github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
|
||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
|
||||
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
|
||||
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 h1:zwsem4CaamMdC3tFoTpzrsUSMDPV0K6rhnQdF7kXekQ=
|
||||
@@ -1172,8 +1178,8 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -1415,8 +1421,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
|
||||
gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
|
||||
gvisor.dev/gvisor v0.0.0-20240119233241-c9c1d4f9b186 h1:VWRSJX9ghfqsRSZGMAILL6QpYRKWnHcYPi24SCubQRs=
|
||||
gvisor.dev/gvisor v0.0.0-20240119233241-c9c1d4f9b186/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk=
|
||||
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 h1:/8/t5pz/mgdRXhYOIeqqYhFAQLE4DDGegc0Y4ZjyFJM=
|
||||
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3/go.mod h1:NQHVAzMwvZ+Qe3ElSiHmq9RUm1MdNHpUZ52fiEqvn+0=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -1 +1 @@
|
||||
66fe5734c4555397ef1b9de3e1ec958bf0a2086e
|
||||
f86d7c8ef64a0f8a2516fc23652eee28abc8d8e0
|
||||
|
||||
@@ -103,6 +103,16 @@ func WithMapDebugFlag(name string) WarnableOpt {
|
||||
})
|
||||
}
|
||||
|
||||
// WithConnectivityImpact returns an option which makes a Warnable annotated as
|
||||
// something that could be breaking external network connectivity on the
|
||||
// machine. This will make the warnable returned by OverallError alongside
|
||||
// network connectivity errors.
|
||||
func WithConnectivityImpact() WarnableOpt {
|
||||
return warnOptFunc(func(w *Warnable) {
|
||||
w.hasConnectivityImpact = true
|
||||
})
|
||||
}
|
||||
|
||||
type warnOptFunc func(*Warnable)
|
||||
|
||||
func (f warnOptFunc) mod(w *Warnable) { f(w) }
|
||||
@@ -112,6 +122,10 @@ func (f warnOptFunc) mod(w *Warnable) { f(w) }
|
||||
type Warnable struct {
|
||||
debugFlag string // optional MapRequest.DebugFlag to send when unhealthy
|
||||
|
||||
// If true, this warning is related to configuration of networking stack
|
||||
// on the machine that impacts connectivity.
|
||||
hasConnectivityImpact bool
|
||||
|
||||
isSet atomic.Bool
|
||||
mu sync.Mutex
|
||||
err error
|
||||
@@ -442,9 +456,35 @@ func OverallError() error {
|
||||
|
||||
var fakeErrForTesting = envknob.RegisterString("TS_DEBUG_FAKE_HEALTH_ERROR")
|
||||
|
||||
// networkErrorf creates an error that indicates issues with outgoing network
|
||||
// connectivity. Any active warnings related to network connectivity will
|
||||
// automatically be appended to it.
|
||||
func networkErrorf(format string, a ...any) error {
|
||||
errs := []error{
|
||||
fmt.Errorf(format, a...),
|
||||
}
|
||||
for w := range warnables {
|
||||
if !w.hasConnectivityImpact {
|
||||
continue
|
||||
}
|
||||
if err := w.get(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) == 1 {
|
||||
return errs[0]
|
||||
}
|
||||
return multierr.New(errs...)
|
||||
}
|
||||
|
||||
var errNetworkDown = networkErrorf("network down")
|
||||
var errNotInMapPoll = networkErrorf("not in map poll")
|
||||
var errNoDERPHome = errors.New("no DERP home")
|
||||
var errNoUDP4Bind = networkErrorf("no udp4 bind")
|
||||
|
||||
func overallErrorLocked() error {
|
||||
if !anyInterfaceUp {
|
||||
return errors.New("network down")
|
||||
return errNetworkDown
|
||||
}
|
||||
if localLogConfigErr != nil {
|
||||
return localLogConfigErr
|
||||
@@ -457,26 +497,26 @@ func overallErrorLocked() error {
|
||||
}
|
||||
now := time.Now()
|
||||
if !inMapPoll && (lastMapPollEndedAt.IsZero() || now.Sub(lastMapPollEndedAt) > 10*time.Second) {
|
||||
return errors.New("not in map poll")
|
||||
return errNotInMapPoll
|
||||
}
|
||||
const tooIdle = 2*time.Minute + 5*time.Second
|
||||
if d := now.Sub(lastStreamedMapResponse).Round(time.Second); d > tooIdle {
|
||||
return fmt.Errorf("no map response in %v", d)
|
||||
return networkErrorf("no map response in %v", d)
|
||||
}
|
||||
if !derpHomeless {
|
||||
rid := derpHomeRegion
|
||||
if rid == 0 {
|
||||
return errors.New("no DERP home")
|
||||
return errNoDERPHome
|
||||
}
|
||||
if !derpRegionConnected[rid] {
|
||||
return fmt.Errorf("not connected to home DERP region %v", rid)
|
||||
return networkErrorf("not connected to home DERP region %v", rid)
|
||||
}
|
||||
if d := now.Sub(derpRegionLastFrame[rid]).Round(time.Second); d > tooIdle {
|
||||
return fmt.Errorf("haven't heard from home DERP region %v in %v", rid, d)
|
||||
return networkErrorf("haven't heard from home DERP region %v in %v", rid, d)
|
||||
}
|
||||
}
|
||||
if udp4Unbound {
|
||||
return errors.New("no udp4 bind")
|
||||
return errNoUDP4Bind
|
||||
}
|
||||
|
||||
// TODO: use
|
||||
|
||||
@@ -11,4 +11,5 @@ const (
|
||||
WarnAcceptRoutesOff = "Some peers are advertising routes but --accept-routes is false"
|
||||
TailscaleSSHOnBut = "Tailscale SSH enabled, but " // + ... something from caller
|
||||
LockedOut = "this node is locked out; it will not have connectivity until it is signed. For more info, see https://tailscale.com/s/locked-out"
|
||||
WarnExitNodeUsage = "The following issues on your machine will likely make usage of exit nodes impossible"
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ func New() *tailcfg.Hostinfo {
|
||||
GoArchVar: lazyGoArchVar.Get(),
|
||||
GoVersion: runtime.Version(),
|
||||
Machine: condCall(unameMachine),
|
||||
DeviceModel: deviceModel(),
|
||||
DeviceModel: deviceModelCached(),
|
||||
Cloud: string(cloudenv.Get()),
|
||||
NoLogsNoSupport: envknob.NoLogsNoSupport(),
|
||||
AllowsUpdate: envknob.AllowsRemoteUpdate(),
|
||||
@@ -68,6 +68,7 @@ var (
|
||||
distroVersion func() string
|
||||
distroCodeName func() string
|
||||
unameMachine func() string
|
||||
deviceModel func() string
|
||||
)
|
||||
|
||||
func condCall[T any](fn func() T) T {
|
||||
@@ -176,6 +177,20 @@ var (
|
||||
// SetDeviceModel sets the device model for use in Hostinfo updates.
|
||||
func SetDeviceModel(model string) { deviceModelAtomic.Store(model) }
|
||||
|
||||
func deviceModelCached() string {
|
||||
if v, _ := deviceModelAtomic.Load().(string); v != "" {
|
||||
return v
|
||||
}
|
||||
if deviceModel == nil {
|
||||
return ""
|
||||
}
|
||||
v := deviceModel()
|
||||
if v != "" {
|
||||
deviceModelAtomic.Store(v)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// SetOSVersion sets the OS version.
|
||||
func SetOSVersion(v string) { osVersionAtomic.Store(v) }
|
||||
|
||||
@@ -193,11 +208,6 @@ func SetPackage(v string) { packagingType.Store(v) }
|
||||
// and "k8s-operator".
|
||||
func SetApp(v string) { appType.Store(v) }
|
||||
|
||||
func deviceModel() string {
|
||||
s, _ := deviceModelAtomic.Load().(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// FirewallMode returns the firewall mode for the app.
|
||||
// It is empty if unset.
|
||||
func FirewallMode() string {
|
||||
|
||||
@@ -22,9 +22,7 @@ func init() {
|
||||
distroName = distroNameLinux
|
||||
distroVersion = distroVersionLinux
|
||||
distroCodeName = distroCodeNameLinux
|
||||
if v := linuxDeviceModel(); v != "" {
|
||||
SetDeviceModel(v)
|
||||
}
|
||||
deviceModel = deviceModelLinux
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -50,7 +48,7 @@ func distroCodeNameLinux() string {
|
||||
return lazyVersionMeta.Get().DistroCodeName
|
||||
}
|
||||
|
||||
func linuxDeviceModel() string {
|
||||
func deviceModelLinux() string {
|
||||
for _, path := range []string{
|
||||
// First try the Synology-specific location.
|
||||
// Example: "DS916+-j"
|
||||
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tailfs"
|
||||
"tailscale.com/types/empty"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/types/structs"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
type State int
|
||||
@@ -65,8 +67,9 @@ const (
|
||||
NotifyInitialPrefs // if set, the first Notify message (sent immediately) will contain the current Prefs
|
||||
NotifyInitialNetMap // if set, the first Notify message (sent immediately) will contain the current NetMap
|
||||
|
||||
NotifyNoPrivateKeys // if set, private keys that would normally be sent in updates are zeroed out
|
||||
NotifyInitialTailFSShares // if set, the first Notify message (sent immediately) will contain the current TailFS Shares
|
||||
NotifyNoPrivateKeys // if set, private keys that would normally be sent in updates are zeroed out
|
||||
NotifyInitialTailFSShares // if set, the first Notify message (sent immediately) will contain the current TailFS Shares
|
||||
NotifyInitialOutgoingFiles // if set, the first Notify message (sent immediately) will contain the current Taildrop OutgoingFiles
|
||||
)
|
||||
|
||||
// Notify is a communication from a backend (e.g. tailscaled) to a frontend
|
||||
@@ -112,6 +115,11 @@ type Notify struct {
|
||||
// Deprecated: use LocalClient.AwaitWaitingFiles instead.
|
||||
IncomingFiles []PartialFile `json:",omitempty"`
|
||||
|
||||
// OutgoingFiles, if non-nil, tracks which files are in the process of
|
||||
// being sent via TailDrop, including files that finished, whether
|
||||
// successful or failed. This slice is sorted by Started time, then Name.
|
||||
OutgoingFiles []*OutgoingFile `json:",omitempty"`
|
||||
|
||||
// LocalTCPPort, if non-nil, informs the UI frontend which
|
||||
// (non-zero) localhost TCP port it's listening on.
|
||||
// This is currently only used by Tailscale when run in the
|
||||
@@ -123,11 +131,12 @@ type Notify struct {
|
||||
ClientVersion *tailcfg.ClientVersion `json:",omitempty"`
|
||||
|
||||
// TailFSShares tracks the full set of current TailFSShares that we're
|
||||
// publishing as name->path. Some client applications, like the MacOS and
|
||||
// Windows clients, will listen for updates to this and handle serving
|
||||
// these shares under the identity of the unprivileged user that is running
|
||||
// the application.
|
||||
TailFSShares map[string]string `json:",omitempty"`
|
||||
// publishing. Some client applications, like the MacOS and Windows clients,
|
||||
// will listen for updates to this and handle serving these shares under
|
||||
// the identity of the unprivileged user that is running the application. A
|
||||
// nil value here means that we're not broadcasting shares information, an
|
||||
// empty value means that there are no shares.
|
||||
TailFSShares views.SliceView[*tailfs.Share, tailfs.ShareView]
|
||||
|
||||
// type is mirrored in xcode/Shared/IPN.swift
|
||||
}
|
||||
@@ -172,7 +181,7 @@ func (n Notify) String() string {
|
||||
return s[0:len(s)-1] + "}"
|
||||
}
|
||||
|
||||
// PartialFile represents an in-progress file transfer.
|
||||
// PartialFile represents an in-progress incoming file transfer.
|
||||
type PartialFile struct {
|
||||
Name string // e.g. "foo.jpg"
|
||||
Started time.Time // time transfer started
|
||||
@@ -191,6 +200,18 @@ type PartialFile struct {
|
||||
Done bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
// OutgoingFile represents an in-progress outgoing file transfer.
|
||||
type OutgoingFile struct {
|
||||
ID string `json:",omitempty"` // unique identifier for this transfer (a type 4 UUID)
|
||||
PeerID tailcfg.StableNodeID `json:",omitempty"` // identifier for the peer to which this is being transferred
|
||||
Name string `json:",omitempty"` // e.g. "foo.jpg"
|
||||
Started time.Time // time transfer started
|
||||
DeclaredSize int64 // or -1 if unknown
|
||||
Sent int64 // bytes copied thus far
|
||||
Finished bool // indicates whether or not the transfer finished
|
||||
Succeeded bool // for a finished transfer, indicates whether or not it was successful
|
||||
}
|
||||
|
||||
// StateKey is an opaque identifier for a set of LocalBackend state
|
||||
// (preferences, private keys, etc.). It is also used as a key for
|
||||
// the various LoginProfiles that the instance may be signed into.
|
||||
|
||||
@@ -23,8 +23,8 @@ type ConfigVAlpha struct {
|
||||
OperatorUser *string `json:",omitempty"` // local user name who is allowed to operate tailscaled without being root or using sudo
|
||||
Hostname *string `json:",omitempty"`
|
||||
|
||||
AcceptDNS opt.Bool `json:"acceptDNS,omitempty"` // --accept-dns
|
||||
AcceptRoutes opt.Bool `json:"acceptRoutes,omitempty"`
|
||||
AcceptDNS opt.Bool `json:"acceptDNS,omitempty"` // --accept-dns
|
||||
AcceptRoutes opt.Bool `json:"acceptRoutes,omitempty"` // --accept-routes defaults to true
|
||||
|
||||
ExitNode *string `json:"exitNode,omitempty"` // IP, StableID, or MagicDNS base name
|
||||
AllowLANWhileUsingExitNode opt.Bool `json:"allowLANWhileUsingExitNode,omitempty"`
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tailfs"
|
||||
"tailscale.com/types/persist"
|
||||
"tailscale.com/types/preftype"
|
||||
)
|
||||
@@ -24,39 +25,47 @@ func (src *Prefs) Clone() *Prefs {
|
||||
*dst = *src
|
||||
dst.AdvertiseTags = append(src.AdvertiseTags[:0:0], src.AdvertiseTags...)
|
||||
dst.AdvertiseRoutes = append(src.AdvertiseRoutes[:0:0], src.AdvertiseRoutes...)
|
||||
if src.TailFSShares != nil {
|
||||
dst.TailFSShares = make([]*tailfs.Share, len(src.TailFSShares))
|
||||
for i := range dst.TailFSShares {
|
||||
dst.TailFSShares[i] = src.TailFSShares[i].Clone()
|
||||
}
|
||||
}
|
||||
dst.Persist = src.Persist.Clone()
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _PrefsCloneNeedsRegeneration = Prefs(struct {
|
||||
ControlURL string
|
||||
RouteAll bool
|
||||
AllowSingleHosts bool
|
||||
ExitNodeID tailcfg.StableNodeID
|
||||
ExitNodeIP netip.Addr
|
||||
ExitNodeAllowLANAccess bool
|
||||
CorpDNS bool
|
||||
RunSSH bool
|
||||
RunWebClient bool
|
||||
WantRunning bool
|
||||
LoggedOut bool
|
||||
ShieldsUp bool
|
||||
AdvertiseTags []string
|
||||
Hostname string
|
||||
NotepadURLs bool
|
||||
ForceDaemon bool
|
||||
Egg bool
|
||||
AdvertiseRoutes []netip.Prefix
|
||||
NoSNAT bool
|
||||
NetfilterMode preftype.NetfilterMode
|
||||
OperatorUser string
|
||||
ProfileName string
|
||||
AutoUpdate AutoUpdatePrefs
|
||||
AppConnector AppConnectorPrefs
|
||||
PostureChecking bool
|
||||
NetfilterKind string
|
||||
Persist *persist.Persist
|
||||
ControlURL string
|
||||
RouteAll bool
|
||||
AllowSingleHosts bool
|
||||
ExitNodeID tailcfg.StableNodeID
|
||||
ExitNodeIP netip.Addr
|
||||
ExitNodeAllowLANAccess bool
|
||||
ExitDestinationFlowLogs bool
|
||||
CorpDNS bool
|
||||
RunSSH bool
|
||||
RunWebClient bool
|
||||
WantRunning bool
|
||||
LoggedOut bool
|
||||
ShieldsUp bool
|
||||
AdvertiseTags []string
|
||||
Hostname string
|
||||
NotepadURLs bool
|
||||
ForceDaemon bool
|
||||
Egg bool
|
||||
AdvertiseRoutes []netip.Prefix
|
||||
NoSNAT bool
|
||||
NetfilterMode preftype.NetfilterMode
|
||||
OperatorUser string
|
||||
ProfileName string
|
||||
AutoUpdate AutoUpdatePrefs
|
||||
AppConnector AppConnectorPrefs
|
||||
PostureChecking bool
|
||||
NetfilterKind string
|
||||
TailFSShares []*tailfs.Share
|
||||
Persist *persist.Persist
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of ServeConfig.
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tailfs"
|
||||
"tailscale.com/types/persist"
|
||||
"tailscale.com/types/preftype"
|
||||
"tailscale.com/types/views"
|
||||
@@ -69,6 +70,7 @@ func (v PrefsView) AllowSingleHosts() bool { return v.ж.AllowSingle
|
||||
func (v PrefsView) ExitNodeID() tailcfg.StableNodeID { return v.ж.ExitNodeID }
|
||||
func (v PrefsView) ExitNodeIP() netip.Addr { return v.ж.ExitNodeIP }
|
||||
func (v PrefsView) ExitNodeAllowLANAccess() bool { return v.ж.ExitNodeAllowLANAccess }
|
||||
func (v PrefsView) ExitDestinationFlowLogs() bool { return v.ж.ExitDestinationFlowLogs }
|
||||
func (v PrefsView) CorpDNS() bool { return v.ж.CorpDNS }
|
||||
func (v PrefsView) RunSSH() bool { return v.ж.RunSSH }
|
||||
func (v PrefsView) RunWebClient() bool { return v.ж.RunWebClient }
|
||||
@@ -91,37 +93,42 @@ func (v PrefsView) AutoUpdate() AutoUpdatePrefs { return v.ж.AutoUpda
|
||||
func (v PrefsView) AppConnector() AppConnectorPrefs { return v.ж.AppConnector }
|
||||
func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking }
|
||||
func (v PrefsView) NetfilterKind() string { return v.ж.NetfilterKind }
|
||||
func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() }
|
||||
func (v PrefsView) TailFSShares() views.SliceView[*tailfs.Share, tailfs.ShareView] {
|
||||
return views.SliceOfViews[*tailfs.Share, tailfs.ShareView](v.ж.TailFSShares)
|
||||
}
|
||||
func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _PrefsViewNeedsRegeneration = Prefs(struct {
|
||||
ControlURL string
|
||||
RouteAll bool
|
||||
AllowSingleHosts bool
|
||||
ExitNodeID tailcfg.StableNodeID
|
||||
ExitNodeIP netip.Addr
|
||||
ExitNodeAllowLANAccess bool
|
||||
CorpDNS bool
|
||||
RunSSH bool
|
||||
RunWebClient bool
|
||||
WantRunning bool
|
||||
LoggedOut bool
|
||||
ShieldsUp bool
|
||||
AdvertiseTags []string
|
||||
Hostname string
|
||||
NotepadURLs bool
|
||||
ForceDaemon bool
|
||||
Egg bool
|
||||
AdvertiseRoutes []netip.Prefix
|
||||
NoSNAT bool
|
||||
NetfilterMode preftype.NetfilterMode
|
||||
OperatorUser string
|
||||
ProfileName string
|
||||
AutoUpdate AutoUpdatePrefs
|
||||
AppConnector AppConnectorPrefs
|
||||
PostureChecking bool
|
||||
NetfilterKind string
|
||||
Persist *persist.Persist
|
||||
ControlURL string
|
||||
RouteAll bool
|
||||
AllowSingleHosts bool
|
||||
ExitNodeID tailcfg.StableNodeID
|
||||
ExitNodeIP netip.Addr
|
||||
ExitNodeAllowLANAccess bool
|
||||
ExitDestinationFlowLogs bool
|
||||
CorpDNS bool
|
||||
RunSSH bool
|
||||
RunWebClient bool
|
||||
WantRunning bool
|
||||
LoggedOut bool
|
||||
ShieldsUp bool
|
||||
AdvertiseTags []string
|
||||
Hostname string
|
||||
NotepadURLs bool
|
||||
ForceDaemon bool
|
||||
Egg bool
|
||||
AdvertiseRoutes []netip.Prefix
|
||||
NoSNAT bool
|
||||
NetfilterMode preftype.NetfilterMode
|
||||
OperatorUser string
|
||||
ProfileName string
|
||||
AutoUpdate AutoUpdatePrefs
|
||||
AppConnector AppConnectorPrefs
|
||||
PostureChecking bool
|
||||
NetfilterKind string
|
||||
TailFSShares []*tailfs.Share
|
||||
Persist *persist.Persist
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of ServeConfig.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
@@ -48,8 +49,13 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
|
||||
req("/debug/metrics"): handleC2NDebugMetrics,
|
||||
req("/debug/component-logging"): handleC2NDebugComponentLogging,
|
||||
req("/debug/logheap"): handleC2NDebugLogHeap,
|
||||
req("POST /logtail/flush"): handleC2NLogtailFlush,
|
||||
req("POST /sockstats"): handleC2NSockStats,
|
||||
|
||||
// PPROF - We only expose a subset of typical pprof endpoints for security.
|
||||
req("/debug/pprof/heap"): handleC2NPprof,
|
||||
req("/debug/pprof/allocs"): handleC2NPprof,
|
||||
|
||||
req("POST /logtail/flush"): handleC2NLogtailFlush,
|
||||
req("POST /sockstats"): handleC2NSockStats,
|
||||
|
||||
// Check TLS certificate status.
|
||||
req("GET /tls-cert-status"): handleC2NTLSCertStatus,
|
||||
@@ -178,6 +184,19 @@ func handleC2NDebugLogHeap(b *LocalBackend, w http.ResponseWriter, r *http.Reque
|
||||
c2nLogHeap(w, r)
|
||||
}
|
||||
|
||||
var c2nPprof func(http.ResponseWriter, *http.Request, string) // non-nil on most platforms (c2n_pprof.go)
|
||||
|
||||
func handleC2NPprof(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
||||
if c2nPprof == nil {
|
||||
// Not implemented on platforms trying to optimize for binary size or
|
||||
// reduced memory usage.
|
||||
http.Error(w, "not implemented", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
_, profile := path.Split(r.URL.Path)
|
||||
c2nPprof(w, r, profile)
|
||||
}
|
||||
|
||||
func handleC2NSSHUsernames(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
|
||||
var req tailcfg.C2NSSHUsernamesRequest
|
||||
if r.Method == "POST" {
|
||||
|
||||
@@ -6,12 +6,40 @@
|
||||
package ipnlocal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"runtime/pprof"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func init() {
|
||||
c2nLogHeap = func(w http.ResponseWriter, r *http.Request) {
|
||||
// Support same optional gc parameter as net/http/pprof:
|
||||
if gc, _ := strconv.Atoi(r.FormValue("gc")); gc > 0 {
|
||||
runtime.GC()
|
||||
}
|
||||
pprof.WriteHeapProfile(w)
|
||||
}
|
||||
|
||||
c2nPprof = func(w http.ResponseWriter, r *http.Request, profile string) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
p := pprof.Lookup(string(profile))
|
||||
if p == nil {
|
||||
http.Error(w, "Unknown profile", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
gc, _ := strconv.Atoi(r.FormValue("gc"))
|
||||
if profile == "heap" && gc > 0 {
|
||||
runtime.GC()
|
||||
}
|
||||
debug, _ := strconv.Atoi(r.FormValue("debug"))
|
||||
if debug != 0 {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, profile))
|
||||
}
|
||||
p.WriteTo(w, debug)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"maps"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -68,6 +69,7 @@ import (
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/taildrop"
|
||||
"tailscale.com/tailfs"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/tstime"
|
||||
@@ -85,6 +87,7 @@ import (
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/deephash"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/multierr"
|
||||
"tailscale.com/util/osshare"
|
||||
@@ -175,10 +178,15 @@ type LocalBackend struct {
|
||||
logFlushFunc func() // or nil if SetLogFlusher wasn't called
|
||||
em *expiryManager // non-nil
|
||||
sshAtomicBool atomic.Bool
|
||||
webClientAtomicBool atomic.Bool
|
||||
shutdownCalled bool // if Shutdown has been called
|
||||
debugSink *capture.Sink
|
||||
sockstatLogger *sockstatlog.Logger
|
||||
// webClientAtomicBool controls whether the web client is running. This should
|
||||
// be true unless the disable-web-client node attribute has been set.
|
||||
webClientAtomicBool atomic.Bool
|
||||
// exposeRemoteWebClientAtomicBool controls whether the web client is exposed over
|
||||
// Tailscale on port 5252.
|
||||
exposeRemoteWebClientAtomicBool atomic.Bool
|
||||
shutdownCalled bool // if Shutdown has been called
|
||||
debugSink *capture.Sink
|
||||
sockstatLogger *sockstatlog.Logger
|
||||
|
||||
// getTCPHandlerForFunnelFlow returns a handler for an incoming TCP flow for
|
||||
// the provided srcAddr and dstPort if one exists.
|
||||
@@ -252,8 +260,8 @@ type LocalBackend struct {
|
||||
peerAPIListeners []*peerAPIListener
|
||||
loginFlags controlclient.LoginFlags
|
||||
fileWaiters set.HandleSet[context.CancelFunc] // of wake-up funcs
|
||||
notifyWatchers set.HandleSet[*watchSession]
|
||||
lastStatusTime time.Time // status.AsOf value of the last processed status update
|
||||
notifyWatchers map[string]*watchSession // by session ID
|
||||
lastStatusTime time.Time // status.AsOf value of the last processed status update
|
||||
// directFileRoot, if non-empty, means to write received files
|
||||
// directly to this directory, without staging them in an
|
||||
// intermediate buffered directory for "pick-up" later. If
|
||||
@@ -278,9 +286,8 @@ type LocalBackend struct {
|
||||
capForcedNetfilter string
|
||||
|
||||
// ServeConfig fields. (also guarded by mu)
|
||||
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
|
||||
serveConfig ipn.ServeConfigView // or !Valid if none
|
||||
activeWatchSessions set.Set[string] // of WatchIPN SessionID
|
||||
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
|
||||
serveConfig ipn.ServeConfigView // or !Valid if none
|
||||
|
||||
webClient webClient
|
||||
webClientListeners map[netip.AddrPort]*localListener // listeners for local web client traffic
|
||||
@@ -308,6 +315,13 @@ type LocalBackend struct {
|
||||
|
||||
// Last ClientVersion received in MapResponse, guarded by mu.
|
||||
lastClientVersion *tailcfg.ClientVersion
|
||||
|
||||
// lastNotifiedTailFSShares keeps track of the last set of shares that we
|
||||
// notified about.
|
||||
lastNotifiedTailFSShares atomic.Pointer[views.SliceView[*tailfs.Share, tailfs.ShareView]]
|
||||
|
||||
// outgoingFiles keeps track of Taildrop outgoing files keyed to their OutgoingFile.ID
|
||||
outgoingFiles map[string]*ipn.OutgoingFile
|
||||
}
|
||||
|
||||
type updateStatus struct {
|
||||
@@ -382,7 +396,6 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
gotPortPollRes: make(chan struct{}),
|
||||
loginFlags: loginFlags,
|
||||
clock: clock,
|
||||
activeWatchSessions: make(set.Set[string]),
|
||||
selfUpdateProgress: make([]ipnstate.UpdateProgress, 0),
|
||||
lastSelfUpdateState: ipnstate.UpdateFinished,
|
||||
}
|
||||
@@ -392,8 +405,8 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
if err != nil {
|
||||
log.Printf("error setting up sockstat logger: %v", err)
|
||||
}
|
||||
// Enable sockstats logs only on unstable builds
|
||||
if version.IsUnstableBuild() && b.sockstatLogger != nil {
|
||||
// Enable sockstats logs only on non-mobile unstable builds
|
||||
if version.IsUnstableBuild() && !version.IsMobile() && b.sockstatLogger != nil {
|
||||
b.sockstatLogger.SetLoggingEnabled(true)
|
||||
}
|
||||
|
||||
@@ -432,10 +445,12 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
|
||||
// initialize TailFS shares from saved state
|
||||
fs, ok := b.sys.TailFSForRemote.GetOK()
|
||||
if ok {
|
||||
b.mu.Lock()
|
||||
shares, err := b.tailFSGetSharesLocked()
|
||||
b.mu.Unlock()
|
||||
if err == nil && len(shares) > 0 {
|
||||
currentShares := b.pm.prefs.TailFSShares()
|
||||
if currentShares.Len() > 0 {
|
||||
var shares []*tailfs.Share
|
||||
for i := 0; i < currentShares.Len(); i++ {
|
||||
shares = append(shares, currentShares.At(i).AsStruct())
|
||||
}
|
||||
fs.SetShares(shares)
|
||||
}
|
||||
}
|
||||
@@ -608,6 +623,7 @@ func (b *LocalBackend) linkChange(delta *netmon.ChangeDelta) {
|
||||
// If the local network configuration has changed, our filter may
|
||||
// need updating to tweak default routes.
|
||||
b.updateFilterLocked(b.netMap, b.pm.CurrentPrefs())
|
||||
updateExitNodeUsageWarning(b.pm.CurrentPrefs(), delta.New)
|
||||
|
||||
if peerAPIListenAsync && b.netMap != nil && b.state == ipn.Running {
|
||||
want := b.netMap.GetAddresses().Len()
|
||||
@@ -678,7 +694,7 @@ func (b *LocalBackend) Shutdown() {
|
||||
}
|
||||
b.ctxCancel()
|
||||
b.e.Close()
|
||||
b.e.Wait()
|
||||
<-b.e.Done()
|
||||
}
|
||||
|
||||
func stripKeysFromPrefs(p ipn.PrefsView) ipn.PrefsView {
|
||||
@@ -812,15 +828,16 @@ func (b *LocalBackend) UpdateStatus(sb *ipnstate.StatusBuilder) {
|
||||
ss.UserID = b.netMap.User()
|
||||
if sn := b.netMap.SelfNode; sn.Valid() {
|
||||
peerStatusFromNode(ss, sn)
|
||||
if c := sn.Capabilities(); c.Len() > 0 {
|
||||
ss.Capabilities = c.AsSlice()
|
||||
}
|
||||
if cm := sn.CapMap(); cm.Len() > 0 {
|
||||
ss.Capabilities = make([]tailcfg.NodeCapability, 1, cm.Len()+1)
|
||||
ss.Capabilities[0] = "HTTPS://TAILSCALE.COM/s/DEPRECATED-NODE-CAPS#see-https://github.com/tailscale/tailscale/issues/11508"
|
||||
ss.CapMap = make(tailcfg.NodeCapMap, sn.CapMap().Len())
|
||||
cm.Range(func(k tailcfg.NodeCapability, v views.Slice[tailcfg.RawMessage]) bool {
|
||||
ss.CapMap[k] = v.AsSlice()
|
||||
ss.Capabilities = append(ss.Capabilities, k)
|
||||
return true
|
||||
})
|
||||
slices.Sort(ss.Capabilities[1:])
|
||||
}
|
||||
}
|
||||
for _, addr := range tailscaleIPs {
|
||||
@@ -1125,6 +1142,9 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
|
||||
if setExitNodeID(prefs, st.NetMap) {
|
||||
prefsChanged = true
|
||||
}
|
||||
if setExitDstFlowLogs(prefs) {
|
||||
prefsChanged = true
|
||||
}
|
||||
if applySysPolicy(prefs) {
|
||||
prefsChanged = true
|
||||
}
|
||||
@@ -1153,8 +1173,8 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
|
||||
|
||||
// Perform all reconfiguration based on the netmap here.
|
||||
if st.NetMap != nil {
|
||||
b.capTailnetLock = hasCapability(st.NetMap, tailcfg.CapabilityTailnetLock)
|
||||
b.setWebClientAtomicBoolLocked(st.NetMap, prefs.View())
|
||||
b.capTailnetLock = st.NetMap.HasCap(tailcfg.CapabilityTailnetLock)
|
||||
b.setWebClientAtomicBoolLocked(st.NetMap)
|
||||
|
||||
b.mu.Unlock() // respect locking rules for tkaSyncIfNeeded
|
||||
if err := b.tkaSyncIfNeeded(st.NetMap, prefs.View()); err != nil {
|
||||
@@ -1190,7 +1210,7 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
|
||||
}
|
||||
|
||||
if st.NetMap != nil {
|
||||
if envknob.NoLogsNoSupport() && hasCapability(st.NetMap, tailcfg.CapabilityDataPlaneAuditLogs) {
|
||||
if envknob.NoLogsNoSupport() && st.NetMap.HasCap(tailcfg.CapabilityDataPlaneAuditLogs) {
|
||||
msg := "tailnet requires logging to be enabled. Remove --no-logs-no-support from tailscaled command line."
|
||||
health.SetLocalLogConfigHealth(errors.New(msg))
|
||||
// Connecting to this tailnet without logging is forbidden; boot us outta here.
|
||||
@@ -1218,6 +1238,7 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
|
||||
|
||||
b.e.SetNetworkMap(st.NetMap)
|
||||
b.MagicConn().SetDERPMap(st.NetMap.DERPMap)
|
||||
b.MagicConn().SetOnlyTCP443(st.NetMap.HasCap(tailcfg.NodeAttrOnlyTCP443))
|
||||
|
||||
// Update our cached DERP map
|
||||
dnsfallback.UpdateCache(st.NetMap.DERPMap, b.logf)
|
||||
@@ -1309,6 +1330,15 @@ func applySysPolicy(prefs *ipn.Prefs) (anyChange bool) {
|
||||
return anyChange
|
||||
}
|
||||
|
||||
func setExitDstFlowLogs(prefs *ipn.Prefs) (anyChange bool) {
|
||||
fmt.Printf("set exit dst flow pref")
|
||||
if enable, err := syspolicy.GetBoolean(syspolicy.ExitDestinationFlowLogs, prefs.ExitDestinationFlowLogs); err == nil && prefs.ExitDestinationFlowLogs != enable {
|
||||
prefs.ExitDestinationFlowLogs = enable
|
||||
anyChange = true
|
||||
}
|
||||
return anyChange
|
||||
}
|
||||
|
||||
var _ controlclient.NetmapDeltaUpdater = (*LocalBackend)(nil)
|
||||
|
||||
// UpdateNetmapDelta implements controlclient.NetmapDeltaUpdater.
|
||||
@@ -2265,7 +2295,6 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
|
||||
var ini *ipn.Notify
|
||||
|
||||
b.mu.Lock()
|
||||
b.activeWatchSessions.Add(sessionID)
|
||||
|
||||
const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap | ipn.NotifyInitialTailFSShares
|
||||
if mask&initialBits != 0 {
|
||||
@@ -2284,25 +2313,16 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa
|
||||
ini.NetMap = b.netMap
|
||||
}
|
||||
if mask&ipn.NotifyInitialTailFSShares != 0 && b.tailFSSharingEnabledLocked() {
|
||||
shares, err := b.tailFSGetSharesLocked()
|
||||
if err != nil {
|
||||
b.logf("unable to notify initial tailfs shares: %v", err)
|
||||
} else {
|
||||
ini.TailFSShares = make(map[string]string, len(shares))
|
||||
for _, share := range shares {
|
||||
ini.TailFSShares[share.Name] = share.Path
|
||||
}
|
||||
}
|
||||
ini.TailFSShares = b.pm.prefs.TailFSShares()
|
||||
}
|
||||
}
|
||||
|
||||
handle := b.notifyWatchers.Add(&watchSession{ch, sessionID})
|
||||
mak.Set(&b.notifyWatchers, sessionID, &watchSession{ch, sessionID})
|
||||
b.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
b.mu.Lock()
|
||||
delete(b.notifyWatchers, handle)
|
||||
delete(b.activeWatchSessions, sessionID)
|
||||
delete(b.notifyWatchers, sessionID)
|
||||
b.mu.Unlock()
|
||||
}()
|
||||
|
||||
@@ -2371,6 +2391,20 @@ func (b *LocalBackend) DebugNotify(n ipn.Notify) {
|
||||
b.send(n)
|
||||
}
|
||||
|
||||
// DebugNotifyLastNetMap injects a fake notify message to clients,
|
||||
// repeating whatever the last netmap was.
|
||||
//
|
||||
// It should only be used via the LocalAPI's debug handler.
|
||||
func (b *LocalBackend) DebugNotifyLastNetMap() {
|
||||
b.mu.Lock()
|
||||
nm := b.netMap
|
||||
b.mu.Unlock()
|
||||
|
||||
if nm != nil {
|
||||
b.send(ipn.Notify{NetMap: nm})
|
||||
}
|
||||
}
|
||||
|
||||
// DebugForceNetmapUpdate forces a full no-op netmap update of the current
|
||||
// netmap in all the various subsystems (wireguard, magicsock, LocalBackend).
|
||||
//
|
||||
@@ -2493,11 +2527,21 @@ func (b *LocalBackend) validPopBrowserURL(urlStr string) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
serverURL := b.Prefs().ControlURLOrDefault()
|
||||
if ipn.IsLoginServerSynonym(serverURL) {
|
||||
// When connected to the official Tailscale control plane, only allow
|
||||
// URLs from tailscale.com or its subdomains.
|
||||
if h := u.Hostname(); h != "tailscale.com" && !strings.HasSuffix(u.Hostname(), ".tailscale.com") {
|
||||
return false
|
||||
}
|
||||
// When using a different ControlURL, we cannot be sure what legitimate
|
||||
// PopBrowserURLs they will send. Allow any domain there to avoid
|
||||
// breaking existing user setups.
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
return true
|
||||
case "http":
|
||||
serverURL := b.Prefs().ControlURLOrDefault()
|
||||
// If the control server is using plain HTTP (likely a dev server),
|
||||
// then permit http://.
|
||||
return strings.HasPrefix(serverURL, "http://")
|
||||
@@ -2699,11 +2743,12 @@ func (b *LocalBackend) setTCPPortsIntercepted(ports []uint16) {
|
||||
b.shouldInterceptTCPPortAtomic.Store(f)
|
||||
}
|
||||
|
||||
// setAtomicValuesFromPrefsLocked populates sshAtomicBool, containsViaIPFuncAtomic
|
||||
// and shouldInterceptTCPPortAtomic from the prefs p, which may be !Valid().
|
||||
// setAtomicValuesFromPrefsLocked populates sshAtomicBool, containsViaIPFuncAtomic,
|
||||
// shouldInterceptTCPPortAtomic, and exposeRemoteWebClientAtomicBool from the prefs p,
|
||||
// which may be !Valid().
|
||||
func (b *LocalBackend) setAtomicValuesFromPrefsLocked(p ipn.PrefsView) {
|
||||
b.sshAtomicBool.Store(p.Valid() && p.RunSSH() && envknob.CanSSHD())
|
||||
b.setWebClientAtomicBoolLocked(b.netMap, p)
|
||||
b.setExposeRemoteWebClientAtomicBoolLocked(p)
|
||||
|
||||
if !p.Valid() {
|
||||
b.containsViaIPFuncAtomic.Store(tsaddr.FalseContainsIPFunc())
|
||||
@@ -3042,7 +3087,7 @@ func (b *LocalBackend) checkSSHPrefsLocked(p *ipn.Prefs) error {
|
||||
return nil
|
||||
}
|
||||
if b.netMap != nil {
|
||||
if !hasCapability(b.netMap, tailcfg.CapabilitySSH) {
|
||||
if !b.netMap.HasCap(tailcfg.CapabilitySSH) {
|
||||
if b.isDefaultServerLocked() {
|
||||
return errors.New("Unable to enable local Tailscale SSH server; not enabled on Tailnet. See https://tailscale.com/s/ssh")
|
||||
}
|
||||
@@ -3067,9 +3112,8 @@ func (b *LocalBackend) sshOnButUnusableHealthCheckMessageLocked() (healthMessage
|
||||
return ""
|
||||
}
|
||||
isDefault := b.isDefaultServerLocked()
|
||||
isAdmin := hasCapability(nm, tailcfg.CapabilityAdmin)
|
||||
|
||||
if !isAdmin {
|
||||
if !nm.HasCap(tailcfg.CapabilityAdmin) {
|
||||
return healthmsg.TailscaleSSHOnBut + "access controls don't allow anyone to access this device. Ask your admin to update your tailnet's ACLs to allow access."
|
||||
}
|
||||
if !isDefault {
|
||||
@@ -3086,6 +3130,22 @@ func (b *LocalBackend) isDefaultServerLocked() bool {
|
||||
return prefs.ControlURLOrDefault() == ipn.DefaultControlURL
|
||||
}
|
||||
|
||||
var warnExitNodeUsage = health.NewWarnable(health.WithConnectivityImpact())
|
||||
|
||||
// updateExitNodeUsageWarning updates a warnable meant to notify users of
|
||||
// configuration issues that could break exit node usage.
|
||||
func updateExitNodeUsageWarning(p ipn.PrefsView, state *interfaces.State) {
|
||||
var result error
|
||||
if p.ExitNodeIP().IsValid() || p.ExitNodeID() != "" {
|
||||
warn, _ := netutil.CheckReversePathFiltering(state)
|
||||
const comment = "please set rp_filter=2 instead of rp_filter=1; see https://github.com/tailscale/tailscale/issues/3310"
|
||||
if len(warn) > 0 {
|
||||
result = fmt.Errorf("%s: %v, %s", healthmsg.WarnExitNodeUsage, warn, comment)
|
||||
}
|
||||
}
|
||||
warnExitNodeUsage.Set(result)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) checkExitNodePrefsLocked(p *ipn.Prefs) error {
|
||||
if (p.ExitNodeIP.IsValid() || p.ExitNodeID != "") && p.AdvertisesExitNode() {
|
||||
return errors.New("Cannot advertise an exit node and use an exit node at the same time.")
|
||||
@@ -3191,6 +3251,7 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) ipn
|
||||
// everything in this function treats b.prefs as completely new
|
||||
// anyway. No-op if no exit node resolution is needed.
|
||||
setExitNodeID(newp, netMap)
|
||||
setExitDstFlowLogs(newp)
|
||||
// applySysPolicy does likewise so we can also ignore its return value.
|
||||
applySysPolicy(newp)
|
||||
// We do this to avoid holding the lock while doing everything else.
|
||||
@@ -3312,13 +3373,26 @@ var (
|
||||
// TCPHandlerForDst returns a TCP handler for connections to dst, or nil if
|
||||
// no handler is needed. It also returns a list of TCP socket options to
|
||||
// apply to the socket before calling the handler.
|
||||
// TCPHandlerForDst is called both for connections to our node's local IP
|
||||
// as well as to the service IP (quad 100).
|
||||
func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c net.Conn) error, opts []tcpip.SettableSocketOption) {
|
||||
if dst.Port() == 80 && (dst.Addr() == magicDNSIP || dst.Addr() == magicDNSIPv6) {
|
||||
if b.ShouldRunWebClient() {
|
||||
return b.handleWebClientConn, opts
|
||||
// First handle internal connections to the service IP
|
||||
hittingServiceIP := dst.Addr() == magicDNSIP || dst.Addr() == magicDNSIPv6
|
||||
if hittingServiceIP {
|
||||
switch dst.Port() {
|
||||
case 80:
|
||||
// TODO(mpminardi): do we want to show an error message if the web client
|
||||
// has been disabled instead of the more "basic" web UI?
|
||||
if b.ShouldRunWebClient() {
|
||||
return b.handleWebClientConn, opts
|
||||
}
|
||||
return b.HandleQuad100Port80Conn, opts
|
||||
case TailFSLocalPort:
|
||||
return b.handleTailFSConn, opts
|
||||
}
|
||||
return b.HandleQuad100Port80Conn, opts
|
||||
}
|
||||
|
||||
// Then handle external connections to the local IP.
|
||||
if !b.isLocalIP(dst.Addr()) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -3333,21 +3407,9 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
|
||||
return b.handleSSHConn, opts
|
||||
}
|
||||
// TODO(will,sonia): allow customizing web client port ?
|
||||
if dst.Port() == webClientPort && b.ShouldRunWebClient() {
|
||||
if dst.Port() == webClientPort && b.ShouldExposeRemoteWebClient() {
|
||||
return b.handleWebClientConn, opts
|
||||
}
|
||||
if dst.Port() == TailFSLocalPort {
|
||||
fs, ok := b.sys.TailFSForLocal.GetOK()
|
||||
if ok {
|
||||
return func(conn net.Conn) error {
|
||||
if !b.TailFSAccessEnabled() {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
return fs.HandleConn(conn, conn.RemoteAddr())
|
||||
}, opts
|
||||
}
|
||||
}
|
||||
if port, ok := b.GetPeerAPIPort(dst.Addr()); ok && dst.Port() == port {
|
||||
return func(c net.Conn) error {
|
||||
b.handlePeerAPIConn(src, dst, c)
|
||||
@@ -3360,6 +3422,15 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) handleTailFSConn(conn net.Conn) error {
|
||||
fs, ok := b.sys.TailFSForLocal.GetOK()
|
||||
if !ok || !b.TailFSAccessEnabled() {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
return fs.HandleConn(conn, conn.RemoteAddr())
|
||||
}
|
||||
|
||||
func (b *LocalBackend) peerAPIServicesLocked() (ret []tailcfg.Service) {
|
||||
for _, pln := range b.peerAPIListeners {
|
||||
proto := tailcfg.PeerAPI4
|
||||
@@ -3513,7 +3584,7 @@ func (b *LocalBackend) authReconfig() {
|
||||
prefs := b.pm.CurrentPrefs()
|
||||
nm := b.netMap
|
||||
hasPAC := b.prevIfState.HasPAC()
|
||||
disableSubnetsIfPAC := hasCapability(nm, tailcfg.NodeAttrDisableSubnetsIfPAC)
|
||||
disableSubnetsIfPAC := nm.HasCap(tailcfg.NodeAttrDisableSubnetsIfPAC)
|
||||
dohURL, dohURLOK := exitNodeCanProxyDNS(nm, b.peers, prefs.ExitNodeID())
|
||||
dcfg := dnsConfigForNetmap(nm, b.peers, prefs, b.logf, version.OS())
|
||||
// If the current node is an app connector, ensure the app connector machine is started
|
||||
@@ -3562,6 +3633,8 @@ func (b *LocalBackend) authReconfig() {
|
||||
return
|
||||
}
|
||||
|
||||
cfg.NetworkLogging.ExitDestinationFlowLogs = prefs.ExitDestinationFlowLogs()
|
||||
|
||||
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.ControlKnobs(), version.OS())
|
||||
rcfg := b.routerConfig(cfg, prefs, oneCGNATRoute)
|
||||
|
||||
@@ -4464,14 +4537,39 @@ func (b *LocalBackend) ShouldRunSSH() bool { return b.sshAtomicBool.Load() && en
|
||||
// call regardless of whether b.mu is held or not.
|
||||
func (b *LocalBackend) ShouldRunWebClient() bool { return b.webClientAtomicBool.Load() }
|
||||
|
||||
func (b *LocalBackend) setWebClientAtomicBoolLocked(nm *netmap.NetworkMap, prefs ipn.PrefsView) {
|
||||
shouldRun := prefs.Valid() && prefs.RunWebClient()
|
||||
// ShouldExposeRemoteWebClient reports whether the web client should
|
||||
// accept connections via [tailscale IP]:5252 in addition to the default
|
||||
// behaviour of accepting local connections over 100.100.100.100.
|
||||
//
|
||||
// This function checks both the web client user pref via
|
||||
// exposeRemoteWebClientAtomicBool and the disable-web-client node attr
|
||||
// via ShouldRunWebClient to determine whether the web client should be
|
||||
// exposed.
|
||||
func (b *LocalBackend) ShouldExposeRemoteWebClient() bool {
|
||||
return b.ShouldRunWebClient() && b.exposeRemoteWebClientAtomicBool.Load()
|
||||
}
|
||||
|
||||
// setWebClientAtomicBoolLocked sets webClientAtomicBool based on whether
|
||||
// tailcfg.NodeAttrDisableWebClient has been set in the netmap.NetworkMap.
|
||||
//
|
||||
// b.mu must be held.
|
||||
func (b *LocalBackend) setWebClientAtomicBoolLocked(nm *netmap.NetworkMap) {
|
||||
shouldRun := !nm.HasCap(tailcfg.NodeAttrDisableWebClient)
|
||||
wasRunning := b.webClientAtomicBool.Swap(shouldRun)
|
||||
if wasRunning && !shouldRun {
|
||||
go b.webClientShutdown() // stop web client
|
||||
}
|
||||
}
|
||||
|
||||
// setExposeRemoteWebClientAtomicBoolLocked sets exposeRemoteWebClientAtomicBool
|
||||
// based on whether the RunWebClient pref is set.
|
||||
//
|
||||
// b.mu must be held.
|
||||
func (b *LocalBackend) setExposeRemoteWebClientAtomicBoolLocked(prefs ipn.PrefsView) {
|
||||
shouldExpose := prefs.Valid() && prefs.RunWebClient()
|
||||
b.exposeRemoteWebClientAtomicBool.Store(shouldExpose)
|
||||
}
|
||||
|
||||
// ShouldHandleViaIP reports whether ip is an IPv6 address in the
|
||||
// Tailscale ULA's v6 "via" range embedding an IPv4 address to be forwarded to
|
||||
// by Tailscale.
|
||||
@@ -4553,13 +4651,6 @@ func (b *LocalBackend) setNetInfo(ni *tailcfg.NetInfo) {
|
||||
cc.SetNetInfo(ni)
|
||||
}
|
||||
|
||||
func hasCapability(nm *netmap.NetworkMap, cap tailcfg.NodeCapability) bool {
|
||||
if nm != nil {
|
||||
return nm.SelfNode.HasCap(cap)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setNetMapLocked updates the LocalBackend state to reflect the newly
|
||||
// received nm. If nm is nil, it resets all configuration as though
|
||||
// Tailscale is turned off.
|
||||
@@ -4588,15 +4679,15 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
}
|
||||
|
||||
// Determine if file sharing is enabled
|
||||
fs := hasCapability(nm, tailcfg.CapabilityFileSharing)
|
||||
fs := nm.HasCap(tailcfg.CapabilityFileSharing)
|
||||
if fs != b.capFileSharing {
|
||||
osshare.SetFileSharingEnabled(fs, b.logf)
|
||||
}
|
||||
b.capFileSharing = fs
|
||||
|
||||
if hasCapability(nm, tailcfg.NodeAttrLinuxMustUseIPTables) {
|
||||
if nm.HasCap(tailcfg.NodeAttrLinuxMustUseIPTables) {
|
||||
b.capForcedNetfilter = "iptables"
|
||||
} else if hasCapability(nm, tailcfg.NodeAttrLinuxMustUseNfTables) {
|
||||
} else if nm.HasCap(tailcfg.NodeAttrLinuxMustUseNfTables) {
|
||||
b.capForcedNetfilter = "nftables"
|
||||
} else {
|
||||
b.capForcedNetfilter = "" // empty string means client can auto-detect
|
||||
@@ -4608,8 +4699,8 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
b.setDebugLogsByCapabilityLocked(nm)
|
||||
|
||||
// See the netns package for documentation on what this capability does.
|
||||
netns.SetBindToInterfaceByRoute(hasCapability(nm, tailcfg.CapabilityBindToInterfaceByRoute))
|
||||
netns.SetDisableBindConnToInterface(hasCapability(nm, tailcfg.CapabilityDebugDisableBindConnToInterface))
|
||||
netns.SetBindToInterfaceByRoute(nm.HasCap(tailcfg.CapabilityBindToInterfaceByRoute))
|
||||
netns.SetDisableBindConnToInterface(nm.HasCap(tailcfg.CapabilityDebugDisableBindConnToInterface))
|
||||
|
||||
b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs())
|
||||
if nm == nil {
|
||||
@@ -4645,10 +4736,8 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
|
||||
}
|
||||
}
|
||||
|
||||
if b.tailFSSharingEnabledLocked() {
|
||||
b.updateTailFSPeersLocked(nm)
|
||||
b.tailFSNotifyCurrentSharesLocked()
|
||||
}
|
||||
b.updateTailFSPeersLocked(nm)
|
||||
b.tailFSNotifyCurrentSharesLocked()
|
||||
}
|
||||
|
||||
func (b *LocalBackend) updatePeersFromNetmapLocked(nm *netmap.NetworkMap) {
|
||||
@@ -4682,7 +4771,111 @@ type tailFSTransport struct {
|
||||
b *LocalBackend
|
||||
}
|
||||
|
||||
func (t *tailFSTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// responseBodyWrapper wraps an io.ReadCloser and stores
|
||||
// the number of bytesRead.
|
||||
type responseBodyWrapper struct {
|
||||
io.ReadCloser
|
||||
bytesRx int64
|
||||
bytesTx int64
|
||||
log logger.Logf
|
||||
method string
|
||||
statusCode int
|
||||
contentType string
|
||||
fileExtension string
|
||||
shareNodeKey string
|
||||
selfNodeKey string
|
||||
contentLength int64
|
||||
}
|
||||
|
||||
// logAccess logs the tailfs: access: log line. If the logger is nil,
|
||||
// the log will not be written.
|
||||
func (rbw *responseBodyWrapper) logAccess(err string) {
|
||||
if rbw.log == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Some operating systems create and copy lots of 0 length hidden files for
|
||||
// tracking various states. Omit these to keep logs from being too verbose.
|
||||
if rbw.contentLength > 0 {
|
||||
rbw.log("tailfs: access: %s from %s to %s: status-code=%d ext=%q content-type=%q content-length=%.f tx=%.f rx=%.f err=%q", rbw.method, rbw.selfNodeKey, rbw.shareNodeKey, rbw.statusCode, rbw.fileExtension, rbw.contentType, roundTraffic(rbw.contentLength), roundTraffic(rbw.bytesTx), roundTraffic(rbw.bytesRx), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read implements the io.Reader interface.
|
||||
func (rbw *responseBodyWrapper) Read(b []byte) (int, error) {
|
||||
n, err := rbw.ReadCloser.Read(b)
|
||||
rbw.bytesRx += int64(n)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
rbw.logAccess(err.Error())
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close implements the io.Close interface.
|
||||
func (rbw *responseBodyWrapper) Close() error {
|
||||
err := rbw.ReadCloser.Close()
|
||||
var errStr string
|
||||
if err != nil {
|
||||
errStr = err.Error()
|
||||
}
|
||||
rbw.logAccess(errStr)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *tailFSTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
|
||||
bw := &requestBodyWrapper{}
|
||||
if req.Body != nil {
|
||||
bw.ReadCloser = req.Body
|
||||
req.Body = bw
|
||||
}
|
||||
|
||||
defer func() {
|
||||
contentType := "unknown"
|
||||
switch req.Method {
|
||||
case httpm.PUT:
|
||||
if ct := req.Header.Get("Content-Type"); ct != "" {
|
||||
contentType = ct
|
||||
}
|
||||
case httpm.GET:
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||||
contentType = ct
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
t.b.mu.Lock()
|
||||
selfNodeKey := t.b.netMap.SelfNode.Key().ShortString()
|
||||
t.b.mu.Unlock()
|
||||
n, _, ok := t.b.WhoIs(netip.MustParseAddrPort(req.URL.Host))
|
||||
shareNodeKey := "unknown"
|
||||
if ok {
|
||||
shareNodeKey = string(n.Key().ShortString())
|
||||
}
|
||||
|
||||
rbw := responseBodyWrapper{
|
||||
log: t.b.logf,
|
||||
method: req.Method,
|
||||
bytesTx: int64(bw.bytesRead),
|
||||
selfNodeKey: selfNodeKey,
|
||||
shareNodeKey: shareNodeKey,
|
||||
contentType: contentType,
|
||||
contentLength: resp.ContentLength,
|
||||
fileExtension: parseTailFSFileExtensionForLog(req.URL.Path),
|
||||
statusCode: resp.StatusCode,
|
||||
ReadCloser: resp.Body,
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
// in case of error response, just log immediately
|
||||
rbw.logAccess("")
|
||||
} else {
|
||||
resp.Body = &rbw
|
||||
}
|
||||
}()
|
||||
|
||||
// dialTimeout is fairly aggressive to avoid hangs on contacting offline or
|
||||
// unreachable hosts.
|
||||
dialTimeout := 1 * time.Second // TODO(oxtoacart): tune this
|
||||
@@ -4697,12 +4890,38 @@ func (t *tailFSTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return tr.RoundTrip(req)
|
||||
}
|
||||
|
||||
// roundTraffic rounds bytes. This is used to preserve user privacy within logs.
|
||||
func roundTraffic(bytes int64) float64 {
|
||||
var x float64
|
||||
switch {
|
||||
case bytes <= 5:
|
||||
return float64(bytes)
|
||||
case bytes < 1000:
|
||||
x = 10
|
||||
case bytes < 10_000:
|
||||
x = 100
|
||||
case bytes < 100_000:
|
||||
x = 1000
|
||||
case bytes < 1_000_000:
|
||||
x = 10_000
|
||||
case bytes < 10_000_000:
|
||||
x = 100_000
|
||||
case bytes < 100_000_000:
|
||||
x = 1_000_000
|
||||
case bytes < 1_000_000_000:
|
||||
x = 10_000_000
|
||||
default:
|
||||
x = 100_000_000
|
||||
}
|
||||
return math.Round(float64(bytes)/x) * x
|
||||
}
|
||||
|
||||
// setDebugLogsByCapabilityLocked sets debug logging based on the self node's
|
||||
// capabilities in the provided NetMap.
|
||||
func (b *LocalBackend) setDebugLogsByCapabilityLocked(nm *netmap.NetworkMap) {
|
||||
// These are sufficiently cheap (atomic bools) that we don't need to
|
||||
// store state and compare.
|
||||
if hasCapability(nm, tailcfg.CapabilityDebugTSDNSResolution) {
|
||||
if nm.HasCap(tailcfg.CapabilityDebugTSDNSResolution) {
|
||||
dnscache.SetDebugLoggingEnabled(true)
|
||||
} else {
|
||||
dnscache.SetDebugLoggingEnabled(false)
|
||||
@@ -4743,8 +4962,9 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
|
||||
}
|
||||
|
||||
// remove inactive sessions
|
||||
maps.DeleteFunc(conf.Foreground, func(s string, sc *ipn.ServeConfig) bool {
|
||||
return !b.activeWatchSessions.Contains(s)
|
||||
maps.DeleteFunc(conf.Foreground, func(sessionID string, sc *ipn.ServeConfig) bool {
|
||||
_, ok := b.notifyWatchers[sessionID]
|
||||
return !ok
|
||||
})
|
||||
|
||||
b.serveConfig = conf.View()
|
||||
@@ -4760,7 +4980,7 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
|
||||
if prefs.Valid() && prefs.RunSSH() && envknob.CanSSHD() {
|
||||
handlePorts = append(handlePorts, 22)
|
||||
}
|
||||
if b.ShouldRunWebClient() {
|
||||
if b.ShouldExposeRemoteWebClient() {
|
||||
handlePorts = append(handlePorts, webClientPort)
|
||||
|
||||
// don't listen on netmap addresses if we're in userspace mode
|
||||
|
||||
@@ -5,16 +5,20 @@ package ipnlocal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go4.org/netipx"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/appc"
|
||||
@@ -25,6 +29,8 @@ import (
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tailfs"
|
||||
"tailscale.com/tailfs/tailfsimpl"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/dnstype"
|
||||
@@ -34,10 +40,10 @@ import (
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/util/syspolicy"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/filter"
|
||||
@@ -765,9 +771,6 @@ var _ legacyBackend = (*LocalBackend)(nil)
|
||||
|
||||
func TestWatchNotificationsCallbacks(t *testing.T) {
|
||||
b := new(LocalBackend)
|
||||
// activeWatchSessions is typically set in NewLocalBackend
|
||||
// so WatchNotifications expects it to be non-empty.
|
||||
b.activeWatchSessions = make(set.Set[string])
|
||||
n := new(ipn.Notify)
|
||||
b.WatchNotifications(context.Background(), 0, func() {
|
||||
b.mu.Lock()
|
||||
@@ -2173,3 +2176,360 @@ func TestOnTailnetDefaultAutoUpdate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPHandlerForDst(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
dst string
|
||||
intercept bool
|
||||
}{
|
||||
{
|
||||
desc: "intercept port 80 (Web UI) on quad100 IPv4",
|
||||
dst: "100.100.100.100:80",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 80 (Web UI) on quad100 IPv6",
|
||||
dst: "[fd7a:115c:a1e0::53]:80",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 80 on local ip",
|
||||
dst: "100.100.103.100:80",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 8080 (TailFS) on quad100 IPv4",
|
||||
dst: "100.100.100.100:8080",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "intercept port 8080 (TailFS) on quad100 IPv6",
|
||||
dst: "[fd7a:115c:a1e0::53]:8080",
|
||||
intercept: true,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 8080 on local ip",
|
||||
dst: "100.100.103.100:8080",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 9080 on quad100 IPv4",
|
||||
dst: "100.100.100.100:9080",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 9080 on quad100 IPv6",
|
||||
dst: "[fd7a:115c:a1e0::53]:9080",
|
||||
intercept: false,
|
||||
},
|
||||
{
|
||||
desc: "don't intercept port 9080 on local ip",
|
||||
dst: "100.100.103.100:9080",
|
||||
intercept: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.dst, func(t *testing.T) {
|
||||
t.Log(tt.desc)
|
||||
src := netip.MustParseAddrPort("100.100.102.100:51234")
|
||||
h, _ := b.TCPHandlerForDst(src, netip.MustParseAddrPort(tt.dst))
|
||||
if !tt.intercept && h != nil {
|
||||
t.Error("intercepted traffic we shouldn't have")
|
||||
} else if tt.intercept && h == nil {
|
||||
t.Error("failed to intercept traffic we should have")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailFSManageShares(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
disabled bool
|
||||
existing []*tailfs.Share
|
||||
add *tailfs.Share
|
||||
remove string
|
||||
rename [2]string
|
||||
expect any
|
||||
}{
|
||||
{
|
||||
name: "append",
|
||||
existing: []*tailfs.Share{
|
||||
{Name: "b"},
|
||||
{Name: "d"},
|
||||
},
|
||||
add: &tailfs.Share{Name: " E "},
|
||||
expect: []*tailfs.Share{
|
||||
{Name: "b"},
|
||||
{Name: "d"},
|
||||
{Name: "e"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prepend",
|
||||
existing: []*tailfs.Share{
|
||||
{Name: "b"},
|
||||
{Name: "d"},
|
||||
},
|
||||
add: &tailfs.Share{Name: " A "},
|
||||
expect: []*tailfs.Share{
|
||||
{Name: "a"},
|
||||
{Name: "b"},
|
||||
{Name: "d"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "insert",
|
||||
existing: []*tailfs.Share{
|
||||
{Name: "b"},
|
||||
{Name: "d"},
|
||||
},
|
||||
add: &tailfs.Share{Name: " C "},
|
||||
expect: []*tailfs.Share{
|
||||
{Name: "b"},
|
||||
{Name: "c"},
|
||||
{Name: "d"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "replace",
|
||||
existing: []*tailfs.Share{
|
||||
{Name: "b", Path: "i"},
|
||||
{Name: "d"},
|
||||
},
|
||||
add: &tailfs.Share{Name: " B ", Path: "ii"},
|
||||
expect: []*tailfs.Share{
|
||||
{Name: "b", Path: "ii"},
|
||||
{Name: "d"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add_bad_name",
|
||||
add: &tailfs.Share{Name: "$"},
|
||||
expect: ErrInvalidShareName,
|
||||
},
|
||||
{
|
||||
name: "add_disabled",
|
||||
disabled: true,
|
||||
add: &tailfs.Share{Name: "a"},
|
||||
expect: ErrTailFSNotEnabled,
|
||||
},
|
||||
{
|
||||
name: "remove",
|
||||
existing: []*tailfs.Share{
|
||||
{Name: "a"},
|
||||
{Name: "b"},
|
||||
{Name: "c"},
|
||||
},
|
||||
remove: "b",
|
||||
expect: []*tailfs.Share{
|
||||
{Name: "a"},
|
||||
{Name: "c"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove_non_existing",
|
||||
existing: []*tailfs.Share{
|
||||
{Name: "a"},
|
||||
{Name: "b"},
|
||||
{Name: "c"},
|
||||
},
|
||||
remove: "D",
|
||||
expect: os.ErrNotExist,
|
||||
},
|
||||
{
|
||||
name: "remove_disabled",
|
||||
disabled: true,
|
||||
remove: "b",
|
||||
expect: ErrTailFSNotEnabled,
|
||||
},
|
||||
{
|
||||
name: "rename",
|
||||
existing: []*tailfs.Share{
|
||||
{Name: "a"},
|
||||
{Name: "b"},
|
||||
},
|
||||
rename: [2]string{"a", " C "},
|
||||
expect: []*tailfs.Share{
|
||||
{Name: "b"},
|
||||
{Name: "c"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rename_not_exist",
|
||||
existing: []*tailfs.Share{
|
||||
{Name: "a"},
|
||||
{Name: "b"},
|
||||
},
|
||||
rename: [2]string{"d", "c"},
|
||||
expect: os.ErrNotExist,
|
||||
},
|
||||
{
|
||||
name: "rename_exists",
|
||||
existing: []*tailfs.Share{
|
||||
{Name: "a"},
|
||||
{Name: "b"},
|
||||
},
|
||||
rename: [2]string{"a", "b"},
|
||||
expect: os.ErrExist,
|
||||
},
|
||||
{
|
||||
name: "rename_bad_name",
|
||||
rename: [2]string{"a", "$"},
|
||||
expect: ErrInvalidShareName,
|
||||
},
|
||||
{
|
||||
name: "rename_disabled",
|
||||
disabled: true,
|
||||
rename: [2]string{"a", "c"},
|
||||
expect: ErrTailFSNotEnabled,
|
||||
},
|
||||
}
|
||||
|
||||
tailfs.DisallowShareAs = true
|
||||
t.Cleanup(func() {
|
||||
tailfs.DisallowShareAs = false
|
||||
})
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
b.mu.Lock()
|
||||
if tt.existing != nil {
|
||||
b.tailFSSetSharesLocked(tt.existing)
|
||||
}
|
||||
if !tt.disabled {
|
||||
self := b.netMap.SelfNode.AsStruct()
|
||||
self.CapMap = tailcfg.NodeCapMap{tailcfg.NodeAttrsTailFSShare: nil}
|
||||
b.netMap.SelfNode = self.View()
|
||||
b.sys.Set(tailfsimpl.NewFileSystemForRemote(b.logf))
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result := make(chan views.SliceView[*tailfs.Share, tailfs.ShareView], 1)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go b.WatchNotifications(
|
||||
ctx,
|
||||
0,
|
||||
func() { wg.Done() },
|
||||
func(n *ipn.Notify) bool {
|
||||
select {
|
||||
case result <- n.TailFSShares:
|
||||
default:
|
||||
//
|
||||
}
|
||||
return false
|
||||
},
|
||||
)
|
||||
wg.Wait()
|
||||
|
||||
var err error
|
||||
switch {
|
||||
case tt.add != nil:
|
||||
err = b.TailFSSetShare(tt.add)
|
||||
case tt.remove != "":
|
||||
err = b.TailFSRemoveShare(tt.remove)
|
||||
default:
|
||||
err = b.TailFSRenameShare(tt.rename[0], tt.rename[1])
|
||||
}
|
||||
|
||||
switch e := tt.expect.(type) {
|
||||
case error:
|
||||
if !errors.Is(err, e) {
|
||||
t.Errorf("expected error, want: %v got: %v", e, err)
|
||||
}
|
||||
case []*tailfs.Share:
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
} else {
|
||||
r := <-result
|
||||
|
||||
got, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("can't marshal got: %v", err)
|
||||
}
|
||||
want, err := json.MarshalIndent(e, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("can't marshal want: %v", err)
|
||||
}
|
||||
if diff := cmp.Diff(string(got), string(want)); diff != "" {
|
||||
t.Errorf("wrong shares; (-got+want):%v", diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidPopBrowserURL(t *testing.T) {
|
||||
b := newTestBackend(t)
|
||||
tests := []struct {
|
||||
desc string
|
||||
controlURL string
|
||||
popBrowserURL string
|
||||
want bool
|
||||
}{
|
||||
{"saas_login", "https://login.tailscale.com", "https://login.tailscale.com/a/foo", true},
|
||||
{"saas_controlplane", "https://controlplane.tailscale.com", "https://controlplane.tailscale.com/a/foo", true},
|
||||
{"saas_root", "https://login.tailscale.com", "https://tailscale.com/", true},
|
||||
{"saas_bad_hostname", "https://login.tailscale.com", "https://example.com/a/foo", false},
|
||||
{"localhost", "http://localhost", "http://localhost/a/foo", true},
|
||||
{"custom_control_url_https", "https://example.com", "https://example.com/a/foo", true},
|
||||
{"custom_control_url_https_diff_domain", "https://example.com", "https://other.com/a/foo", true},
|
||||
{"custom_control_url_http", "http://example.com", "http://example.com/a/foo", true},
|
||||
{"custom_control_url_http_diff_domain", "http://example.com", "http://other.com/a/foo", true},
|
||||
{"bad_scheme", "https://example.com", "http://example.com/a/foo", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
if _, err := b.EditPrefs(&ipn.MaskedPrefs{
|
||||
ControlURLSet: true,
|
||||
Prefs: ipn.Prefs{
|
||||
ControlURL: tt.controlURL,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := b.validPopBrowserURL(tt.popBrowserURL)
|
||||
if got != tt.want {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTraffic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bytes int64
|
||||
want float64
|
||||
}{
|
||||
{name: "under 5 bytes", bytes: 4, want: 4},
|
||||
{name: "under 1000 bytes", bytes: 987, want: 990},
|
||||
{name: "under 10_000 bytes", bytes: 8875, want: 8900},
|
||||
{name: "under 100_000 bytes", bytes: 77777, want: 78000},
|
||||
{name: "under 1_000_000 bytes", bytes: 666523, want: 670000},
|
||||
{name: "under 10_000_000 bytes", bytes: 22556677, want: 23000000},
|
||||
{name: "under 1_000_000_000 bytes", bytes: 1234234234, want: 1200000000},
|
||||
{name: "under 1_000_000_000 bytes", bytes: 123423423499, want: 123400000000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if result := roundTraffic(tt.bytes); result != tt.want {
|
||||
t.Errorf("unexpected rounding got %v want %v", result, tt.want)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -42,6 +43,7 @@ import (
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/httphdr"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/wgengine/filter"
|
||||
)
|
||||
|
||||
@@ -1104,8 +1106,44 @@ func writePrettyDNSReply(w io.Writer, res []byte) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpResponseWrapper wraps an http.ResponseWrite and
|
||||
// stores the status code and content length.
|
||||
type httpResponseWrapper struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
contentLength int64
|
||||
}
|
||||
|
||||
// WriteHeader implements the WriteHeader interface.
|
||||
func (hrw *httpResponseWrapper) WriteHeader(status int) {
|
||||
hrw.statusCode = status
|
||||
hrw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// Write implements the Write interface.
|
||||
func (hrw *httpResponseWrapper) Write(b []byte) (int, error) {
|
||||
n, err := hrw.ResponseWriter.Write(b)
|
||||
hrw.contentLength += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// requestBodyWrapper wraps an io.ReadCloser and stores
|
||||
// the number of bytesRead.
|
||||
type requestBodyWrapper struct {
|
||||
io.ReadCloser
|
||||
bytesRead int64
|
||||
}
|
||||
|
||||
// Read implements the io.Reader interface.
|
||||
func (rbw *requestBodyWrapper) Read(b []byte) (int, error) {
|
||||
n, err := rbw.ReadCloser.Read(b)
|
||||
rbw.bytesRead += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeTailFS(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.ps.b.TailFSSharingEnabled() {
|
||||
h.logf("tailfs: not enabled")
|
||||
http.Error(w, "tailfs not enabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -1113,6 +1151,7 @@ func (h *peerAPIHandler) handleServeTailFS(w http.ResponseWriter, r *http.Reques
|
||||
capsMap := h.peerCaps()
|
||||
tailfsCaps, ok := capsMap[tailcfg.PeerCapabilityTailFS]
|
||||
if !ok {
|
||||
h.logf("tailfs: not permitted")
|
||||
http.Error(w, "tailfs not permitted", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -1124,17 +1163,63 @@ func (h *peerAPIHandler) handleServeTailFS(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
p, err := tailfs.ParsePermissions(rawPerms)
|
||||
if err != nil {
|
||||
h.logf("tailfs: error parsing permissions: %w", err.Error())
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fs, ok := h.ps.b.sys.TailFSForRemote.GetOK()
|
||||
if !ok {
|
||||
http.Error(w, "tailfs not enabled", http.StatusNotFound)
|
||||
h.logf("tailfs: not supported on platform")
|
||||
http.Error(w, "tailfs not supported on platform", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
wr := &httpResponseWrapper{
|
||||
ResponseWriter: w,
|
||||
}
|
||||
bw := &requestBodyWrapper{
|
||||
ReadCloser: r.Body,
|
||||
}
|
||||
r.Body = bw
|
||||
|
||||
if r.Method == httpm.PUT || r.Method == httpm.GET {
|
||||
defer func() {
|
||||
switch wr.statusCode {
|
||||
case 304:
|
||||
// 304s are particularly chatty so skip logging.
|
||||
default:
|
||||
contentType := "unknown"
|
||||
if ct := wr.Header().Get("Content-Type"); ct != "" {
|
||||
contentType = ct
|
||||
}
|
||||
|
||||
h.logf("tailfs: share: %s from %s to %s: status-code=%d ext=%q content-type=%q tx=%.f rx=%.f", r.Method, h.peerNode.Key().ShortString(), h.selfNode.Key().ShortString(), wr.statusCode, parseTailFSFileExtensionForLog(r.URL.Path), contentType, roundTraffic(wr.contentLength), roundTraffic(bw.bytesRead))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, tailFSPrefix)
|
||||
fs.ServeHTTPWithPerms(p, w, r)
|
||||
fs.ServeHTTPWithPerms(p, wr, r)
|
||||
}
|
||||
|
||||
// parseTailFSFileExtensionForLog parses the file extension, if available.
|
||||
// If a file extension is not present or parsable, the file extension is
|
||||
// set to "unknown". If the file extension contains a double quote, it is
|
||||
// replaced with "removed".
|
||||
// All whitespace is removed from a parsed file extension.
|
||||
// File extensions including the leading ., e.g. ".gif".
|
||||
func parseTailFSFileExtensionForLog(path string) string {
|
||||
fileExt := "unknown"
|
||||
if fe := filepath.Ext(path); fe != "" {
|
||||
if strings.Contains(fe, "\"") {
|
||||
// Do not log include file extensions with quotes within them.
|
||||
return "removed"
|
||||
}
|
||||
// Remove white space from user defined inputs.
|
||||
fileExt = strings.ReplaceAll(fe, " ", "")
|
||||
}
|
||||
|
||||
return fileExt
|
||||
}
|
||||
|
||||
// newFakePeerAPIListener creates a new net.Listener that acts like
|
||||
|
||||
@@ -522,7 +522,7 @@ func TestHandlePeerAPI(t *testing.T) {
|
||||
},
|
||||
}
|
||||
if tt.debugCap {
|
||||
selfNode.Capabilities = append(selfNode.Capabilities, tailcfg.CapabilityDebug)
|
||||
selfNode.CapMap = tailcfg.NodeCapMap{tailcfg.CapabilityDebug: nil}
|
||||
}
|
||||
var e peerAPITestEnv
|
||||
lb := &LocalBackend{
|
||||
|
||||
@@ -400,7 +400,14 @@ func TestStateMachine(t *testing.T) {
|
||||
// Attempted non-interactive login with no key; indicate that
|
||||
// the user needs to visit a login URL.
|
||||
t.Logf("\n\nLogin (url response)")
|
||||
notifies.expect(1)
|
||||
|
||||
notifies.expect(2)
|
||||
b.EditPrefs(&ipn.MaskedPrefs{
|
||||
ControlURLSet: true,
|
||||
Prefs: ipn.Prefs{
|
||||
ControlURL: "https://localhost:1/",
|
||||
},
|
||||
})
|
||||
url1 := "https://localhost:1/1"
|
||||
cc.send(nil, url1, false, nil)
|
||||
{
|
||||
@@ -409,11 +416,11 @@ func TestStateMachine(t *testing.T) {
|
||||
// ...but backend eats that notification, because the user
|
||||
// didn't explicitly request interactive login yet, and
|
||||
// we're already in NeedsLogin state.
|
||||
nn := notifies.drain(1)
|
||||
nn := notifies.drain(2)
|
||||
|
||||
c.Assert(nn[0].Prefs, qt.IsNotNil)
|
||||
c.Assert(nn[0].Prefs.LoggedOut(), qt.IsTrue)
|
||||
c.Assert(nn[0].Prefs.WantRunning(), qt.IsFalse)
|
||||
c.Assert(nn[1].Prefs, qt.IsNotNil)
|
||||
c.Assert(nn[1].Prefs.LoggedOut(), qt.IsTrue)
|
||||
c.Assert(nn[1].Prefs.WantRunning(), qt.IsFalse)
|
||||
c.Assert(ipn.NeedsLogin, qt.Equals, b.State())
|
||||
}
|
||||
|
||||
@@ -775,12 +782,18 @@ func TestStateMachine(t *testing.T) {
|
||||
// We want to try logging in as a different user, while Stopped.
|
||||
// First, start the login process (without logging out first).
|
||||
t.Logf("\n\nLoginDifferent")
|
||||
notifies.expect(1)
|
||||
notifies.expect(2)
|
||||
b.EditPrefs(&ipn.MaskedPrefs{
|
||||
ControlURLSet: true,
|
||||
Prefs: ipn.Prefs{
|
||||
ControlURL: "https://localhost:1/",
|
||||
},
|
||||
})
|
||||
b.StartLoginInteractive()
|
||||
url3 := "https://localhost:1/3"
|
||||
cc.send(nil, url3, false, nil)
|
||||
{
|
||||
nn := notifies.drain(1)
|
||||
nn := notifies.drain(2)
|
||||
// It might seem like WantRunning should switch to true here,
|
||||
// but that would be risky since we already have a valid
|
||||
// user account. It might try to reconnect to the old account
|
||||
@@ -789,8 +802,8 @@ func TestStateMachine(t *testing.T) {
|
||||
// Because the login hasn't yet completed, the old login
|
||||
// is still valid, so it's correct that we stay paused.
|
||||
cc.assertCalls("Login")
|
||||
c.Assert(nn[0].BrowseToURL, qt.IsNotNil)
|
||||
c.Assert(*nn[0].BrowseToURL, qt.Equals, url3)
|
||||
c.Assert(nn[1].BrowseToURL, qt.IsNotNil)
|
||||
c.Assert(*nn[1].BrowseToURL, qt.Equals, url3)
|
||||
}
|
||||
|
||||
// Now, let's complete the interactive login, using a different
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user