Compare commits
462 Commits
andrew/con
...
v1.68.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52ddf0d016 | ||
|
|
4c01ce9f43 | ||
|
|
f5936d132a | ||
|
|
a95ea31a4e | ||
|
|
3511d1f8a2 | ||
|
|
4cdc4ed7db | ||
|
|
4b6a0c42c8 | ||
|
|
3672f66c74 | ||
|
|
93cd2ab224 | ||
|
|
bc53ebd4a0 | ||
|
|
6f2bae019f | ||
|
|
df86576989 | ||
|
|
c3e2b7347b | ||
|
|
ba46495e11 | ||
|
|
807934f00c | ||
|
|
53d9cac196 | ||
|
|
23e26e589f | ||
|
|
3a6d3f1a5b | ||
|
|
916c4db75b | ||
|
|
0219317372 | ||
|
|
7a7e314096 | ||
|
|
b65221999c | ||
|
|
e88a5dbc92 | ||
|
|
34e8820301 | ||
|
|
8a11a43c28 | ||
|
|
6e106712f6 | ||
|
|
1ca323ac65 | ||
|
|
8450a18aa9 | ||
|
|
95f266f1ce | ||
|
|
b8cf852881 | ||
|
|
36e8e8cd64 | ||
|
|
573c8bd8c7 | ||
|
|
4a8cb1d9f3 | ||
|
|
d2d459d442 | ||
|
|
9cdb33e2a4 | ||
|
|
cf1e6c6e55 | ||
|
|
6d3c10579e | ||
|
|
347e3f3d9a | ||
|
|
82576190a7 | ||
|
|
d636407f14 | ||
|
|
cf9f507d47 | ||
|
|
1dc3136a24 | ||
|
|
379e2bf189 | ||
|
|
ba0dd493c8 | ||
|
|
bc4c8b65c7 | ||
|
|
2f2f588c80 | ||
|
|
e84751217a | ||
|
|
0b1a8586eb | ||
|
|
7b193de6b9 | ||
|
|
3bf2bddbb5 | ||
|
|
d21c00205d | ||
|
|
1fad06429e | ||
|
|
e06862b8d8 | ||
|
|
db6447ce63 | ||
|
|
ced9a0d413 | ||
|
|
01847e0123 | ||
|
|
42cfbf427c | ||
|
|
bcb55fdeb6 | ||
|
|
c2a4719e9e | ||
|
|
36d0ac6f8e | ||
|
|
0a5bd63d32 | ||
|
|
1ec0273473 | ||
|
|
f227083539 | ||
|
|
7e357e1636 | ||
|
|
0380cbc90d | ||
|
|
32120932a5 | ||
|
|
776a05223b | ||
|
|
1ea100e2e5 | ||
|
|
2d2b62c400 | ||
|
|
909a292a8d | ||
|
|
0acb61fbf8 | ||
|
|
dd77111462 | ||
|
|
08a9551a73 | ||
|
|
f1d10c12ac | ||
|
|
5ad0dad15e | ||
|
|
d0d33f257f | ||
|
|
8e4a29433f | ||
|
|
87ee559b6f | ||
|
|
9a64c06a20 | ||
|
|
4214e5f71b | ||
|
|
538c2e8f7c | ||
|
|
3c9be07214 | ||
|
|
72f0f53ed0 | ||
|
|
9351eec3e1 | ||
|
|
c9179bc261 | ||
|
|
6db1219185 | ||
|
|
4f4f317174 | ||
|
|
964282d34f | ||
|
|
1384c24e41 | ||
|
|
47b3476eb7 | ||
|
|
c56e0c4934 | ||
|
|
adb7a86559 | ||
|
|
8d1249550a | ||
|
|
6831a29f8b | ||
|
|
e5f67f90a2 | ||
|
|
59848fe14b | ||
|
|
87f00d76c4 | ||
|
|
76c30e014d | ||
|
|
8feb4ff5d2 | ||
|
|
359ef61263 | ||
|
|
89947606b2 | ||
|
|
b094e8c925 | ||
|
|
e3dec086e6 | ||
|
|
7f83f9fc83 | ||
|
|
6877d44965 | ||
|
|
1f51bb6891 | ||
|
|
60266be298 | ||
|
|
c6d42b1093 | ||
|
|
7ef2f72135 | ||
|
|
8aa5c3534d | ||
|
|
7b3e30f391 | ||
|
|
79b2d425cf | ||
|
|
fc1ae97e10 | ||
|
|
486a423716 | ||
|
|
7209c4f91e | ||
|
|
d86d1e7601 | ||
|
|
e070af7414 | ||
|
|
5708fc0639 | ||
|
|
25e32cc3ae | ||
|
|
21abb7f402 | ||
|
|
ac638f32c0 | ||
|
|
b5dbf155b1 | ||
|
|
8f7f9ac17e | ||
|
|
7901925ad3 | ||
|
|
8130656780 | ||
|
|
6f4a1dc6bf | ||
|
|
e968b0ecd7 | ||
|
|
e5ef35857f | ||
|
|
21509db121 | ||
|
|
727c0d6cfd | ||
|
|
32bc596062 | ||
|
|
9380e2dfc6 | ||
|
|
e1011f1387 | ||
|
|
85b9a6c601 | ||
|
|
d7bdd8e2a7 | ||
|
|
3c4c9dc1d2 | ||
|
|
80df8ffb85 | ||
|
|
471731771c | ||
|
|
78fa698fe6 | ||
|
|
482890b9ed | ||
|
|
af97e7a793 | ||
|
|
e67069550b | ||
|
|
f62e678df8 | ||
|
|
c28f5767bf | ||
|
|
5ef178fdca | ||
|
|
f3d2fd22ef | ||
|
|
aadb8d9d21 | ||
|
|
e26f76a1c4 | ||
|
|
caa3d7594f | ||
|
|
ce8969d82b | ||
|
|
7e0dd61e61 | ||
|
|
258b5042fe | ||
|
|
c3c18027c6 | ||
|
|
41f2195899 | ||
|
|
1a963342c7 | ||
|
|
80decd83c1 | ||
|
|
ed843e643f | ||
|
|
fd6ba43b97 | ||
|
|
46980c9664 | ||
|
|
817badf9ca | ||
|
|
2cf764e998 | ||
|
|
406293682c | ||
|
|
35872e86d2 | ||
|
|
b62cfc430a | ||
|
|
e9505e5432 | ||
|
|
e42c4396cf | ||
|
|
15fc6cd966 | ||
|
|
1fe0983f2d | ||
|
|
46f3feae96 | ||
|
|
4fa6cbec27 | ||
|
|
ee3bd4dbda | ||
|
|
a03cb866b4 | ||
|
|
745fb31bd4 | ||
|
|
07e783c7be | ||
|
|
3349e86c0a | ||
|
|
0c11fd978b | ||
|
|
9d22ec0ba2 | ||
|
|
cd633a7252 | ||
|
|
f97d0ac994 | ||
|
|
e0287a4b33 | ||
|
|
19b31ac9a6 | ||
|
|
a49ed2e145 | ||
|
|
96712e10a7 | ||
|
|
be663c84c1 | ||
|
|
10497acc95 | ||
|
|
13e1355546 | ||
|
|
843afe7c53 | ||
|
|
45b9aa0d83 | ||
|
|
4c08410011 | ||
|
|
ba34943133 | ||
|
|
fa1303d632 | ||
|
|
de85610be0 | ||
|
|
2648d475d7 | ||
|
|
7455e027e9 | ||
|
|
fe009c134e | ||
|
|
c47f9303b0 | ||
|
|
5db80cf2d8 | ||
|
|
44aa809cb0 | ||
|
|
1fe073098c | ||
|
|
a47ce618bd | ||
|
|
ec04c677c0 | ||
|
|
7ba8f03936 | ||
|
|
7d9c3f9897 | ||
|
|
d02f1be46a | ||
|
|
5254f6de06 | ||
|
|
ce5c80d0fe | ||
|
|
6a0fbacc28 | ||
|
|
c27dc1ca31 | ||
|
|
fea2e73bc1 | ||
|
|
1bd1b387b2 | ||
|
|
79836e7bfd | ||
|
|
b2b49cb3d5 | ||
|
|
74c399483c | ||
|
|
1452faf510 | ||
|
|
1e6cdb7d86 | ||
|
|
b9adbe2002 | ||
|
|
6b95219e3a | ||
|
|
45f0721530 | ||
|
|
3672f29a4e | ||
|
|
4f73a26ea5 | ||
|
|
7a62dddeac | ||
|
|
4dece0c359 | ||
|
|
7f587d0321 | ||
|
|
71e9258ad9 | ||
|
|
745931415c | ||
|
|
a4a282cd49 | ||
|
|
6d69fc137f | ||
|
|
df8f40905b | ||
|
|
723c775dbb | ||
|
|
cb66952a0d | ||
|
|
7349b274bd | ||
|
|
5b32264033 | ||
|
|
ebc552d2e0 | ||
|
|
d5fc52a0f5 | ||
|
|
18765cd4f9 | ||
|
|
955ad12489 | ||
|
|
5d4b4ffc3c | ||
|
|
14ac41febc | ||
|
|
31e6bdbc82 | ||
|
|
1d3e77f373 | ||
|
|
0cce456ee5 | ||
|
|
c8e912896e | ||
|
|
add62af7c6 | ||
|
|
3af0f526b8 | ||
|
|
bf46bff678 | ||
|
|
b7e5122226 | ||
|
|
e985c6e58f | ||
|
|
9779eb6dba | ||
|
|
c07aa2cfed | ||
|
|
63b3c82587 | ||
|
|
06502b9048 | ||
|
|
0a84215036 | ||
|
|
b743b85dad | ||
|
|
5100bdeba7 | ||
|
|
c39cde79d2 | ||
|
|
05bfa022f2 | ||
|
|
375617c5c8 | ||
|
|
9e1c86901b | ||
|
|
bff527622d | ||
|
|
b3fb3bf084 | ||
|
|
bbe194c80d | ||
|
|
d16c1293e9 | ||
|
|
94c0403104 | ||
|
|
787f8c08ec | ||
|
|
c24f2eee34 | ||
|
|
048cb61dd0 | ||
|
|
7132b782d4 | ||
|
|
02c6af2a69 | ||
|
|
bdfaef4879 | ||
|
|
e775de3c63 | ||
|
|
c8b0adb382 | ||
|
|
03d5d1f0f9 | ||
|
|
22bd506129 | ||
|
|
88a7767492 | ||
|
|
dd48cad89a | ||
|
|
b85c2b2313 | ||
|
|
82394debb7 | ||
|
|
21a0fe1b9b | ||
|
|
449be38e03 | ||
|
|
3ef7f895c8 | ||
|
|
226486eb9a | ||
|
|
454a03a766 | ||
|
|
d07ede461a | ||
|
|
3ff3445e9d | ||
|
|
eb34b8a173 | ||
|
|
a50e4e604e | ||
|
|
62d4be873d | ||
|
|
7c1d6e35a5 | ||
|
|
068db1f972 | ||
|
|
7e2b4268d6 | ||
|
|
0fba9e7570 | ||
|
|
26f9bbc02b | ||
|
|
ca5cb41b43 | ||
|
|
3c1e2bba5b | ||
|
|
dd6c76ea24 | ||
|
|
7ec0dc3834 | ||
|
|
9171b217ba | ||
|
|
449f46c207 | ||
|
|
14c8b674ea | ||
|
|
952e06aa46 | ||
|
|
38fb23f120 | ||
|
|
9258bcc360 | ||
|
|
b9aa7421d6 | ||
|
|
a6739c49df | ||
|
|
271cfdb3d3 | ||
|
|
bad3159b62 | ||
|
|
8186cd0349 | ||
|
|
68043a17c2 | ||
|
|
970b1e21d0 | ||
|
|
170c618483 | ||
|
|
65f215115f | ||
|
|
a1abd12f35 | ||
|
|
1cd51f95c7 | ||
|
|
976d3c7b5f | ||
|
|
7a77a2edf1 | ||
|
|
4d5d669cd5 | ||
|
|
9d021579e7 | ||
|
|
11dca08e93 | ||
|
|
2207643312 | ||
|
|
09524b58f3 | ||
|
|
a2eb1c22b0 | ||
|
|
7f4cda23ac | ||
|
|
8fa3026614 | ||
|
|
d0f3fa7d7e | ||
|
|
db760d0bac | ||
|
|
8d83adde07 | ||
|
|
da4e92bf01 | ||
|
|
9da135dd64 | ||
|
|
1e0ebc6c6d | ||
|
|
b4ba492701 | ||
|
|
231e44e742 | ||
|
|
0001237253 | ||
|
|
b27238b654 | ||
|
|
e6983baa73 | ||
|
|
0f3a292ebd | ||
|
|
c71e8db058 | ||
|
|
5336362e64 | ||
|
|
21671ca374 | ||
|
|
b0fbd85592 | ||
|
|
a5e1f7d703 | ||
|
|
3f4c5daa15 | ||
|
|
fe22032fb3 | ||
|
|
aa084a29c6 | ||
|
|
5e7c0b025c | ||
|
|
efb710d0e5 | ||
|
|
38377c37b5 | ||
|
|
21b32b467e | ||
|
|
ac2522092d | ||
|
|
6e334e64a1 | ||
|
|
1fbaf26106 | ||
|
|
8c75da27fc | ||
|
|
306bacc669 | ||
|
|
9699bb0a20 | ||
|
|
fe0cfec4ad | ||
|
|
4bbac72868 | ||
|
|
98cf71cd73 | ||
|
|
853e3e29a0 | ||
|
|
1a38d2a3b4 | ||
|
|
7d7d159824 | ||
|
|
ac574d875c | ||
|
|
8d7894c68e | ||
|
|
92d3f64e95 | ||
|
|
93618a3518 | ||
|
|
2409661a0d | ||
|
|
b9611461e5 | ||
|
|
262fa8a01e | ||
|
|
9eaa56df93 | ||
|
|
14683371ee | ||
|
|
1c259100b0 | ||
|
|
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 |
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
|
||||
6
.github/workflows/installer.yml
vendored
6
.github/workflows/installer.yml
vendored
@@ -32,7 +32,6 @@ jobs:
|
||||
- "ubuntu:18.04"
|
||||
- "ubuntu:20.04"
|
||||
- "ubuntu:22.04"
|
||||
- "ubuntu:22.10"
|
||||
- "ubuntu:23.04"
|
||||
- "elementary/docker:stable"
|
||||
- "elementary/docker:unstable"
|
||||
@@ -91,7 +90,10 @@ jobs:
|
||||
|| contains(matrix.image, 'parrotsec')
|
||||
|| contains(matrix.image, 'kalilinux')
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
# We cannot use v4, as it requires a newer glibc version than some of the
|
||||
# tested images provide. See
|
||||
# https://github.com/actions/checkout/issues/1487
|
||||
uses: actions/checkout@v3
|
||||
- name: run installer
|
||||
run: scripts/installer.sh
|
||||
# Package installation can fail in docker because systemd is not running
|
||||
|
||||
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
|
||||
|
||||
46
.github/workflows/test.yml
vendored
46
.github/workflows/test.yml
vendored
@@ -254,9 +254,6 @@ jobs:
|
||||
goarch: amd64
|
||||
- goos: openbsd
|
||||
goarch: amd64
|
||||
# Plan9 (disabled until 3p dependencies are fixed)
|
||||
# - goos: plan9
|
||||
# goarch: amd64
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
@@ -305,6 +302,47 @@ jobs:
|
||||
GOOS: ios
|
||||
GOARCH: arm64
|
||||
|
||||
crossmin: # cross-compile for platforms where we only check cmd/tailscale{,d}
|
||||
strategy:
|
||||
fail-fast: false # don't abort the entire matrix if one element fails
|
||||
matrix:
|
||||
include:
|
||||
# Plan9
|
||||
- goos: plan9
|
||||
goarch: amd64
|
||||
# AIX
|
||||
- goos: aix
|
||||
goarch: ppc64
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
# Note: unlike the other setups, this is only grabbing the mod download
|
||||
# cache, rather than the whole mod directory, as the download cache
|
||||
# contains zips that can be unpacked in parallel faster than they can be
|
||||
# fetched and extracted by tar
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod/cache
|
||||
~\AppData\Local\go-build
|
||||
# The -2- here should be incremented when the scheme of data to be
|
||||
# cached changes (e.g. path above changes).
|
||||
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-${{ hashFiles('**/go.sum') }}
|
||||
${{ github.job }}-${{ runner.os }}-${{ matrix.goos }}-${{ matrix.goarch }}-go-2-
|
||||
- name: build core
|
||||
run: ./tool/go build ./cmd/tailscale ./cmd/tailscaled
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: "0"
|
||||
|
||||
android:
|
||||
# similar to cross above, but android fails to build a few pieces of the
|
||||
# repo. We should fix those pieces, they're small, but as a stepping stone,
|
||||
@@ -318,7 +356,7 @@ jobs:
|
||||
# some Android breakages early.
|
||||
# TODO(bradfitz): better; see https://github.com/tailscale/tailscale/issues/4482
|
||||
- name: build some
|
||||
run: ./tool/go install ./net/netns ./ipn/ipnlocal ./wgengine/magicsock/ ./wgengine/ ./wgengine/router/ ./wgengine/netstack ./util/dnsname/ ./ipn/ ./net/interfaces ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version
|
||||
run: ./tool/go install ./net/netns ./ipn/ipnlocal ./wgengine/magicsock/ ./wgengine/ ./wgengine/router/ ./wgengine/netstack ./util/dnsname/ ./ipn/ ./net/netmon ./wgengine/router/ ./tailcfg/ ./types/logger/ ./net/dns ./hostinfo ./version
|
||||
env:
|
||||
GOOS: android
|
||||
GOARCH: arm64
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@
|
||||
|
||||
cmd/tailscale/tailscale
|
||||
cmd/tailscaled/tailscaled
|
||||
ssh/tailssh/testcontainers/tailscaled
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
19
Makefile
19
Makefile
@@ -1,5 +1,5 @@
|
||||
IMAGE_REPO ?= tailscale/tailscale
|
||||
SYNO_ARCH ?= "amd64"
|
||||
SYNO_ARCH ?= "x86_64"
|
||||
SYNO_DSM ?= "7"
|
||||
TAGS ?= "latest"
|
||||
|
||||
@@ -100,6 +100,23 @@ 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
|
||||
|
||||
.PHONY: sshintegrationtest
|
||||
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
|
||||
@GOOS=linux GOARCH=amd64 go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
|
||||
GOOS=linux GOARCH=amd64 go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
|
||||
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers
|
||||
|
||||
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.68.0
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
@@ -23,8 +24,49 @@ import (
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/execqueue"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/slicesx"
|
||||
)
|
||||
|
||||
// rateLogger responds to calls to update by adding a count for the current period and
|
||||
// calling the callback if any previous period has finished since update was last called
|
||||
type rateLogger struct {
|
||||
interval time.Duration
|
||||
start time.Time
|
||||
periodStart time.Time
|
||||
periodCount int64
|
||||
now func() time.Time
|
||||
callback func(int64, time.Time, int64)
|
||||
}
|
||||
|
||||
func (rl *rateLogger) currentIntervalStart(now time.Time) time.Time {
|
||||
millisSince := now.Sub(rl.start).Milliseconds() % rl.interval.Milliseconds()
|
||||
return now.Add(-(time.Duration(millisSince)) * time.Millisecond)
|
||||
}
|
||||
|
||||
func (rl *rateLogger) update(numRoutes int64) {
|
||||
now := rl.now()
|
||||
periodEnd := rl.periodStart.Add(rl.interval)
|
||||
if periodEnd.Before(now) {
|
||||
if rl.periodCount != 0 {
|
||||
rl.callback(rl.periodCount, rl.periodStart, numRoutes)
|
||||
}
|
||||
rl.periodCount = 0
|
||||
rl.periodStart = rl.currentIntervalStart(now)
|
||||
}
|
||||
rl.periodCount++
|
||||
}
|
||||
|
||||
func newRateLogger(now func() time.Time, interval time.Duration, callback func(int64, time.Time, int64)) *rateLogger {
|
||||
nowTime := now()
|
||||
return &rateLogger{
|
||||
callback: callback,
|
||||
now: now,
|
||||
interval: interval,
|
||||
start: nowTime,
|
||||
periodStart: nowTime,
|
||||
}
|
||||
}
|
||||
|
||||
// RouteAdvertiser is an interface that allows the AppConnector to advertise
|
||||
// newly discovered routes that need to be served through the AppConnector.
|
||||
type RouteAdvertiser interface {
|
||||
@@ -36,6 +78,19 @@ type RouteAdvertiser interface {
|
||||
UnadvertiseRoute(...netip.Prefix) error
|
||||
}
|
||||
|
||||
// RouteInfo is a data structure used to persist the in memory state of an AppConnector
|
||||
// so that we can know, even after a restart, which routes came from ACLs and which were
|
||||
// learned from domains.
|
||||
type RouteInfo struct {
|
||||
// Control is the routes from the 'routes' section of an app connector acl.
|
||||
Control []netip.Prefix `json:",omitempty"`
|
||||
// Domains are the routes discovered by observing DNS lookups for configured domains.
|
||||
Domains map[string][]netip.Addr `json:",omitempty"`
|
||||
// Wildcards are the configured DNS lookup domains to observe. When a DNS query matches Wildcards,
|
||||
// its result is added to Domains.
|
||||
Wildcards []string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// AppConnector is an implementation of an AppConnector that performs
|
||||
// its function as a subsystem inside of a tailscale node. At the control plane
|
||||
// side App Connector routing is configured in terms of domains rather than IP
|
||||
@@ -49,6 +104,9 @@ type AppConnector struct {
|
||||
logf logger.Logf
|
||||
routeAdvertiser RouteAdvertiser
|
||||
|
||||
// storeRoutesFunc will be called to persist routes if it is not nil.
|
||||
storeRoutesFunc func(*RouteInfo) error
|
||||
|
||||
// mu guards the fields that follow
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -64,14 +122,67 @@ type AppConnector struct {
|
||||
|
||||
// queue provides ordering for update operations
|
||||
queue execqueue.ExecQueue
|
||||
|
||||
writeRateMinute *rateLogger
|
||||
writeRateDay *rateLogger
|
||||
}
|
||||
|
||||
// NewAppConnector creates a new AppConnector.
|
||||
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser) *AppConnector {
|
||||
return &AppConnector{
|
||||
func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser, routeInfo *RouteInfo, storeRoutesFunc func(*RouteInfo) error) *AppConnector {
|
||||
ac := &AppConnector{
|
||||
logf: logger.WithPrefix(logf, "appc: "),
|
||||
routeAdvertiser: routeAdvertiser,
|
||||
storeRoutesFunc: storeRoutesFunc,
|
||||
}
|
||||
if routeInfo != nil {
|
||||
ac.domains = routeInfo.Domains
|
||||
ac.wildcards = routeInfo.Wildcards
|
||||
ac.controlRoutes = routeInfo.Control
|
||||
}
|
||||
ac.writeRateMinute = newRateLogger(time.Now, time.Minute, func(c int64, s time.Time, l int64) {
|
||||
ac.logf("routeInfo write rate: %d in minute starting at %v (%d routes)", c, s, l)
|
||||
})
|
||||
ac.writeRateDay = newRateLogger(time.Now, 24*time.Hour, func(c int64, s time.Time, l int64) {
|
||||
ac.logf("routeInfo write rate: %d in 24 hours starting at %v (%d routes)", c, s, l)
|
||||
})
|
||||
return ac
|
||||
}
|
||||
|
||||
// ShouldStoreRoutes returns true if the appconnector was created with the controlknob on
|
||||
// and is storing its discovered routes persistently.
|
||||
func (e *AppConnector) ShouldStoreRoutes() bool {
|
||||
return e.storeRoutesFunc != nil
|
||||
}
|
||||
|
||||
// storeRoutesLocked takes the current state of the AppConnector and persists it
|
||||
func (e *AppConnector) storeRoutesLocked() error {
|
||||
if !e.ShouldStoreRoutes() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// log write rate and write size
|
||||
numRoutes := int64(len(e.controlRoutes))
|
||||
for _, rs := range e.domains {
|
||||
numRoutes += int64(len(rs))
|
||||
}
|
||||
e.writeRateMinute.update(numRoutes)
|
||||
e.writeRateDay.update(numRoutes)
|
||||
|
||||
return e.storeRoutesFunc(&RouteInfo{
|
||||
Control: e.controlRoutes,
|
||||
Domains: e.domains,
|
||||
Wildcards: e.wildcards,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearRoutes removes all route state from the AppConnector.
|
||||
func (e *AppConnector) ClearRoutes() error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.controlRoutes = nil
|
||||
e.domains = nil
|
||||
e.wildcards = nil
|
||||
return e.storeRoutesLocked()
|
||||
}
|
||||
|
||||
// UpdateDomainsAndRoutes starts an asynchronous update of the configuration
|
||||
@@ -125,10 +236,26 @@ func (e *AppConnector) updateDomains(domains []string) {
|
||||
for _, wc := range e.wildcards {
|
||||
if dnsname.HasSuffix(d, wc) {
|
||||
e.domains[d] = addrs
|
||||
delete(oldDomains, d)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Everything left in oldDomains is a domain we're no longer tracking
|
||||
// and if we are storing route info we can unadvertise the routes
|
||||
if e.ShouldStoreRoutes() {
|
||||
toRemove := []netip.Prefix{}
|
||||
for _, addrs := range oldDomains {
|
||||
for _, a := range addrs {
|
||||
toRemove = append(toRemove, netip.PrefixFrom(a, a.BitLen()))
|
||||
}
|
||||
}
|
||||
if err := e.routeAdvertiser.UnadvertiseRoute(toRemove...); err != nil {
|
||||
e.logf("failed to unadvertise routes on domain removal: %v: %v: %v", xmaps.Keys(oldDomains), toRemove, err)
|
||||
}
|
||||
}
|
||||
|
||||
e.logf("handling domains: %v and wildcards: %v", xmaps.Keys(e.domains), e.wildcards)
|
||||
}
|
||||
|
||||
@@ -152,6 +279,14 @@ func (e *AppConnector) updateRoutes(routes []netip.Prefix) {
|
||||
|
||||
var toRemove []netip.Prefix
|
||||
|
||||
// If we're storing routes and know e.controlRoutes is a good
|
||||
// representation of what should be in AdvertisedRoutes we can stop
|
||||
// advertising routes that used to be in e.controlRoutes but are not
|
||||
// in routes.
|
||||
if e.ShouldStoreRoutes() {
|
||||
toRemove = routesWithout(e.controlRoutes, routes)
|
||||
}
|
||||
|
||||
nextRoute:
|
||||
for _, r := range routes {
|
||||
for _, addr := range e.domains {
|
||||
@@ -170,6 +305,9 @@ nextRoute:
|
||||
}
|
||||
|
||||
e.controlRoutes = routes
|
||||
if err := e.storeRoutesLocked(); err != nil {
|
||||
e.logf("failed to store route info: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Domains returns the currently configured domain list.
|
||||
@@ -380,6 +518,9 @@ func (e *AppConnector) scheduleAdvertisement(domain string, routes ...netip.Pref
|
||||
e.logf("[v2] advertised route for %v: %v", domain, addr)
|
||||
}
|
||||
}
|
||||
if err := e.storeRoutesLocked(); err != nil {
|
||||
e.logf("failed to store route info: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -400,3 +541,15 @@ func (e *AppConnector) addDomainAddrLocked(domain string, addr netip.Addr) {
|
||||
func compareAddr(l, r netip.Addr) int {
|
||||
return l.Compare(r)
|
||||
}
|
||||
|
||||
// routesWithout returns a without b where a and b
|
||||
// are unsorted slices of netip.Prefix
|
||||
func routesWithout(a, b []netip.Prefix) []netip.Prefix {
|
||||
m := make(map[netip.Prefix]bool, len(b))
|
||||
for _, p := range b {
|
||||
m[p] = true
|
||||
}
|
||||
return slicesx.Filter(make([]netip.Prefix, 0, len(a)), a, func(p netip.Prefix) bool {
|
||||
return !m[p]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,202 +9,248 @@ import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/appc/appctest"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
func fakeStoreRoutes(*RouteInfo) error { return nil }
|
||||
|
||||
func TestUpdateDomains(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
a := NewAppConnector(t.Logf, nil)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, &appctest.RouteCollector{}, nil, nil)
|
||||
}
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
|
||||
a.Wait(ctx)
|
||||
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
a.Wait(ctx)
|
||||
if got, want := a.Domains().AsSlice(), []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
addr := netip.MustParseAddr("192.0.0.8")
|
||||
a.domains["example.com"] = append(a.domains["example.com"], addr)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
a.Wait(ctx)
|
||||
addr := netip.MustParseAddr("192.0.0.8")
|
||||
a.domains["example.com"] = append(a.domains["example.com"], addr)
|
||||
a.UpdateDomains([]string{"example.com"})
|
||||
a.Wait(ctx)
|
||||
|
||||
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
if got, want := a.domains["example.com"], []netip.Addr{addr}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// domains are explicitly downcased on set.
|
||||
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
|
||||
a.Wait(ctx)
|
||||
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
// domains are explicitly downcased on set.
|
||||
a.UpdateDomains([]string{"UP.EXAMPLE.COM"})
|
||||
a.Wait(ctx)
|
||||
if got, want := xmaps.Keys(a.domains), []string{"up.example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRoutes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
a := NewAppConnector(t.Logf, rc)
|
||||
a.updateDomains([]string{"*.example.com"})
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
a.updateDomains([]string{"*.example.com"})
|
||||
|
||||
// This route should be collapsed into the range
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1"))
|
||||
a.Wait(ctx)
|
||||
// This route should be collapsed into the range
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "192.0.2.1"))
|
||||
a.Wait(ctx)
|
||||
|
||||
if !slices.Equal(rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) {
|
||||
t.Fatalf("got %v, want %v", rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
|
||||
}
|
||||
if !slices.Equal(rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}) {
|
||||
t.Fatalf("got %v, want %v", rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
|
||||
}
|
||||
|
||||
// This route should not be collapsed or removed
|
||||
a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1"))
|
||||
a.Wait(ctx)
|
||||
// This route should not be collapsed or removed
|
||||
a.ObserveDNSResponse(dnsResponse("b.example.com.", "192.0.0.1"))
|
||||
a.Wait(ctx)
|
||||
|
||||
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")}
|
||||
a.updateRoutes(routes)
|
||||
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.0.0.1/32")}
|
||||
a.updateRoutes(routes)
|
||||
|
||||
slices.SortFunc(rc.Routes(), prefixCompare)
|
||||
rc.SetRoutes(slices.Compact(rc.Routes()))
|
||||
slices.SortFunc(routes, prefixCompare)
|
||||
slices.SortFunc(rc.Routes(), prefixCompare)
|
||||
rc.SetRoutes(slices.Compact(rc.Routes()))
|
||||
slices.SortFunc(routes, prefixCompare)
|
||||
|
||||
// Ensure that the non-matching /32 is preserved, even though it's in the domains table.
|
||||
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
|
||||
t.Errorf("added routes: got %v, want %v", rc.Routes(), routes)
|
||||
}
|
||||
// Ensure that the non-matching /32 is preserved, even though it's in the domains table.
|
||||
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
|
||||
t.Errorf("added routes: got %v, want %v", rc.Routes(), routes)
|
||||
}
|
||||
|
||||
// Ensure that the contained /32 is removed, replaced by the /24.
|
||||
wantRemoved := []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}
|
||||
if !slices.EqualFunc(rc.RemovedRoutes(), wantRemoved, prefixEqual) {
|
||||
t.Fatalf("unexpected removed routes: %v", rc.RemovedRoutes())
|
||||
// Ensure that the contained /32 is removed, replaced by the /24.
|
||||
wantRemoved := []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")}
|
||||
if !slices.EqualFunc(rc.RemovedRoutes(), wantRemoved, prefixEqual) {
|
||||
t.Fatalf("unexpected removed routes: %v", rc.RemovedRoutes())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRoutesUnadvertisesContainedRoutes(t *testing.T) {
|
||||
rc := &appctest.RouteCollector{}
|
||||
a := NewAppConnector(t.Logf, rc)
|
||||
mak.Set(&a.domains, "example.com", []netip.Addr{netip.MustParseAddr("192.0.2.1")})
|
||||
rc.SetRoutes([]netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
|
||||
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}
|
||||
a.updateRoutes(routes)
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
mak.Set(&a.domains, "example.com", []netip.Addr{netip.MustParseAddr("192.0.2.1")})
|
||||
rc.SetRoutes([]netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")})
|
||||
routes := []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}
|
||||
a.updateRoutes(routes)
|
||||
|
||||
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
|
||||
t.Fatalf("got %v, want %v", rc.Routes(), routes)
|
||||
if !slices.EqualFunc(routes, rc.Routes(), prefixEqual) {
|
||||
t.Fatalf("got %v, want %v", rc.Routes(), routes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainRoutes(t *testing.T) {
|
||||
rc := &appctest.RouteCollector{}
|
||||
a := NewAppConnector(t.Logf, rc)
|
||||
a.updateDomains([]string{"example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
a.Wait(context.Background())
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
a.updateDomains([]string{"example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
a.Wait(context.Background())
|
||||
|
||||
want := map[string][]netip.Addr{
|
||||
"example.com": {netip.MustParseAddr("192.0.0.8")},
|
||||
}
|
||||
want := map[string][]netip.Addr{
|
||||
"example.com": {netip.MustParseAddr("192.0.0.8")},
|
||||
}
|
||||
|
||||
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("DomainRoutes: got %v, want %v", got, want)
|
||||
if got := a.DomainRoutes(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("DomainRoutes: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveDNSResponse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
a := NewAppConnector(t.Logf, rc)
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
|
||||
// a has no domains configured, so it should not advertise any routes
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if got, want := rc.Routes(), ([]netip.Prefix)(nil); !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
// a has no domains configured, so it should not advertise any routes
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
if got, want := rc.Routes(), ([]netip.Prefix)(nil); !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||
wantRoutes := []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}
|
||||
|
||||
a.updateDomains([]string{"example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
a.updateDomains([]string{"example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.0.8"))
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// a CNAME record chain should result in a route being added if the chain
|
||||
// matches a routed domain.
|
||||
a.updateDomains([]string{"www.example.com", "example.com"})
|
||||
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com."))
|
||||
a.Wait(ctx)
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.9/32"))
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
// a CNAME record chain should result in a route being added if the chain
|
||||
// matches a routed domain.
|
||||
a.updateDomains([]string{"www.example.com", "example.com"})
|
||||
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.9", "www.example.com.", "chain.example.com.", "example.com."))
|
||||
a.Wait(ctx)
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.9/32"))
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// a CNAME record chain should result in a route being added if the chain
|
||||
// even if only found in the middle of the chain
|
||||
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org."))
|
||||
a.Wait(ctx)
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.10/32"))
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
// a CNAME record chain should result in a route being added if the chain
|
||||
// even if only found in the middle of the chain
|
||||
a.ObserveDNSResponse(dnsCNAMEResponse("192.0.0.10", "outside.example.org.", "www.example.com.", "example.org."))
|
||||
a.Wait(ctx)
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("192.0.0.10/32"))
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
|
||||
wantRoutes = append(wantRoutes, netip.MustParsePrefix("2001:db8::1/128"))
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), wantRoutes; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// don't re-advertise routes that have already been advertised
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
a.Wait(ctx)
|
||||
if !slices.Equal(rc.Routes(), wantRoutes) {
|
||||
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
|
||||
}
|
||||
// don't re-advertise routes that have already been advertised
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "2001:db8::1"))
|
||||
a.Wait(ctx)
|
||||
if !slices.Equal(rc.Routes(), wantRoutes) {
|
||||
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
|
||||
}
|
||||
|
||||
// don't advertise addresses that are already in a control provided route
|
||||
pfx := netip.MustParsePrefix("192.0.2.0/24")
|
||||
a.updateRoutes([]netip.Prefix{pfx})
|
||||
wantRoutes = append(wantRoutes, pfx)
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1"))
|
||||
a.Wait(ctx)
|
||||
if !slices.Equal(rc.Routes(), wantRoutes) {
|
||||
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
|
||||
}
|
||||
if !slices.Contains(a.domains["example.com"], netip.MustParseAddr("192.0.2.1")) {
|
||||
t.Errorf("missing %v from %v", "192.0.2.1", a.domains["exmaple.com"])
|
||||
// don't advertise addresses that are already in a control provided route
|
||||
pfx := netip.MustParsePrefix("192.0.2.0/24")
|
||||
a.updateRoutes([]netip.Prefix{pfx})
|
||||
wantRoutes = append(wantRoutes, pfx)
|
||||
a.ObserveDNSResponse(dnsResponse("example.com.", "192.0.2.1"))
|
||||
a.Wait(ctx)
|
||||
if !slices.Equal(rc.Routes(), wantRoutes) {
|
||||
t.Errorf("rc.Routes(): got %v; want %v", rc.Routes(), wantRoutes)
|
||||
}
|
||||
if !slices.Contains(a.domains["example.com"], netip.MustParseAddr("192.0.2.1")) {
|
||||
t.Errorf("missing %v from %v", "192.0.2.1", a.domains["exmaple.com"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildcardDomains(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
a := NewAppConnector(t.Logf, rc)
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
|
||||
a.updateDomains([]string{"*.example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
|
||||
t.Errorf("routes: got %v; want %v", got, want)
|
||||
}
|
||||
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("wildcards: got %v; want %v", got, want)
|
||||
}
|
||||
a.updateDomains([]string{"*.example.com"})
|
||||
a.ObserveDNSResponse(dnsResponse("foo.example.com.", "192.0.0.8"))
|
||||
a.Wait(ctx)
|
||||
if got, want := rc.Routes(), []netip.Prefix{netip.MustParsePrefix("192.0.0.8/32")}; !slices.Equal(got, want) {
|
||||
t.Errorf("routes: got %v; want %v", got, want)
|
||||
}
|
||||
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("wildcards: got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
a.updateDomains([]string{"*.example.com", "example.com"})
|
||||
if _, ok := a.domains["foo.example.com"]; !ok {
|
||||
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard")
|
||||
}
|
||||
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("wildcards: got %v; want %v", got, want)
|
||||
}
|
||||
a.updateDomains([]string{"*.example.com", "example.com"})
|
||||
if _, ok := a.domains["foo.example.com"]; !ok {
|
||||
t.Errorf("expected foo.example.com to be preserved in domains due to wildcard")
|
||||
}
|
||||
if got, want := a.wildcards, []string{"example.com"}; !slices.Equal(got, want) {
|
||||
t.Errorf("wildcards: got %v; want %v", got, want)
|
||||
}
|
||||
|
||||
// There was an early regression where the wildcard domain was added repeatedly, this guards against that.
|
||||
a.updateDomains([]string{"*.example.com", "example.com"})
|
||||
if len(a.wildcards) != 1 {
|
||||
t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
|
||||
// There was an early regression where the wildcard domain was added repeatedly, this guards against that.
|
||||
a.updateDomains([]string{"*.example.com", "example.com"})
|
||||
if len(a.wildcards) != 1 {
|
||||
t.Errorf("expected only one wildcard domain, got %v", a.wildcards)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,3 +356,216 @@ func prefixCompare(a, b netip.Prefix) int {
|
||||
}
|
||||
return a.Addr().Compare(b.Addr())
|
||||
}
|
||||
|
||||
func prefixes(in ...string) []netip.Prefix {
|
||||
toRet := make([]netip.Prefix, len(in))
|
||||
for i, s := range in {
|
||||
toRet[i] = netip.MustParsePrefix(s)
|
||||
}
|
||||
return toRet
|
||||
}
|
||||
|
||||
func TestUpdateRouteRouteRemoval(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
|
||||
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
|
||||
if !slices.Equal(routes, rc.Routes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
|
||||
}
|
||||
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
|
||||
}
|
||||
}
|
||||
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
// nothing has yet been advertised
|
||||
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.2/32"))
|
||||
a.Wait(ctx)
|
||||
// the routes passed to UpdateDomainsAndRoutes have been advertised
|
||||
assertRoutes("simple update", prefixes("1.2.3.1/32", "1.2.3.2/32"), []netip.Prefix{})
|
||||
|
||||
// one route the same, one different
|
||||
a.UpdateDomainsAndRoutes([]string{}, prefixes("1.2.3.1/32", "1.2.3.3/32"))
|
||||
a.Wait(ctx)
|
||||
// old behavior: routes are not removed, resulting routes are both old and new
|
||||
// (we have dupe 1.2.3.1 routes because the test RouteAdvertiser doesn't have the deduplication
|
||||
// the real one does)
|
||||
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.1/32", "1.2.3.3/32")
|
||||
wantRemovedRoutes := []netip.Prefix{}
|
||||
if shouldStore {
|
||||
// new behavior: routes are removed, resulting routes are new only
|
||||
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.1/32", "1.2.3.3/32")
|
||||
wantRemovedRoutes = prefixes("1.2.3.2/32")
|
||||
}
|
||||
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDomainRouteRemoval(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
|
||||
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
|
||||
if !slices.Equal(routes, rc.Routes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
|
||||
}
|
||||
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
|
||||
}
|
||||
}
|
||||
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com", "b.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// adding domains doesn't immediately cause any routes to be advertised
|
||||
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
|
||||
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.3"))
|
||||
a.ObserveDNSResponse(dnsResponse("b.example.com.", "1.2.3.4"))
|
||||
a.Wait(ctx)
|
||||
// observing dns responses causes routes to be advertised
|
||||
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// old behavior, routes are not removed
|
||||
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32")
|
||||
wantRemovedRoutes := []netip.Prefix{}
|
||||
if shouldStore {
|
||||
// new behavior, routes are removed for b.example.com
|
||||
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32")
|
||||
wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32")
|
||||
}
|
||||
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWildcardRouteRemoval(t *testing.T) {
|
||||
for _, shouldStore := range []bool{false, true} {
|
||||
ctx := context.Background()
|
||||
rc := &appctest.RouteCollector{}
|
||||
|
||||
assertRoutes := func(prefix string, routes, removedRoutes []netip.Prefix) {
|
||||
if !slices.Equal(routes, rc.Routes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) routes want %v, got %v", prefix, shouldStore, routes, rc.Routes())
|
||||
}
|
||||
if !slices.Equal(removedRoutes, rc.RemovedRoutes()) {
|
||||
t.Fatalf("%s: (shouldStore=%t) removedRoutes want %v, got %v", prefix, shouldStore, removedRoutes, rc.RemovedRoutes())
|
||||
}
|
||||
}
|
||||
|
||||
var a *AppConnector
|
||||
if shouldStore {
|
||||
a = NewAppConnector(t.Logf, rc, &RouteInfo{}, fakeStoreRoutes)
|
||||
} else {
|
||||
a = NewAppConnector(t.Logf, rc, nil, nil)
|
||||
}
|
||||
assertRoutes("appc init", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com", "*.b.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// adding domains doesn't immediately cause any routes to be advertised
|
||||
assertRoutes("update domains", []netip.Prefix{}, []netip.Prefix{})
|
||||
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.1"))
|
||||
a.ObserveDNSResponse(dnsResponse("a.example.com.", "1.2.3.2"))
|
||||
a.ObserveDNSResponse(dnsResponse("1.b.example.com.", "1.2.3.3"))
|
||||
a.ObserveDNSResponse(dnsResponse("2.b.example.com.", "1.2.3.4"))
|
||||
a.Wait(ctx)
|
||||
// observing dns responses causes routes to be advertised
|
||||
assertRoutes("observed dns", prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32"), []netip.Prefix{})
|
||||
|
||||
a.UpdateDomainsAndRoutes([]string{"a.example.com"}, []netip.Prefix{})
|
||||
a.Wait(ctx)
|
||||
// old behavior, routes are not removed
|
||||
wantRoutes := prefixes("1.2.3.1/32", "1.2.3.2/32", "1.2.3.3/32", "1.2.3.4/32")
|
||||
wantRemovedRoutes := []netip.Prefix{}
|
||||
if shouldStore {
|
||||
// new behavior, routes are removed for *.b.example.com
|
||||
wantRoutes = prefixes("1.2.3.1/32", "1.2.3.2/32")
|
||||
wantRemovedRoutes = prefixes("1.2.3.3/32", "1.2.3.4/32")
|
||||
}
|
||||
assertRoutes("removal", wantRoutes, wantRemovedRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutesWithout(t *testing.T) {
|
||||
assert := func(msg string, got, want []netip.Prefix) {
|
||||
if !slices.Equal(want, got) {
|
||||
t.Errorf("%s: want %v, got %v", msg, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
assert("empty routes", routesWithout([]netip.Prefix{}, []netip.Prefix{}), []netip.Prefix{})
|
||||
assert("a empty", routesWithout([]netip.Prefix{}, prefixes("1.1.1.1/32", "1.1.1.2/32")), []netip.Prefix{})
|
||||
assert("b empty", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), []netip.Prefix{}), prefixes("1.1.1.1/32", "1.1.1.2/32"))
|
||||
assert("no overlap", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.3/32", "1.1.1.4/32")), prefixes("1.1.1.1/32", "1.1.1.2/32"))
|
||||
assert("a has fewer", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32"), prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32")), []netip.Prefix{})
|
||||
assert("a has more", routesWithout(prefixes("1.1.1.1/32", "1.1.1.2/32", "1.1.1.3/32", "1.1.1.4/32"), prefixes("1.1.1.1/32", "1.1.1.3/32")), prefixes("1.1.1.2/32", "1.1.1.4/32"))
|
||||
}
|
||||
|
||||
func TestRateLogger(t *testing.T) {
|
||||
clock := tstest.Clock{}
|
||||
wasCalled := false
|
||||
rl := newRateLogger(func() time.Time { return clock.Now() }, 1*time.Second, func(count int64, _ time.Time, _ int64) {
|
||||
if count != 3 {
|
||||
t.Fatalf("count for prev period: got %d, want 3", count)
|
||||
}
|
||||
wasCalled = true
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
clock.Advance(1 * time.Millisecond)
|
||||
rl.update(0)
|
||||
if wasCalled {
|
||||
t.Fatalf("wasCalled: got true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
clock.Advance(1 * time.Second)
|
||||
rl.update(0)
|
||||
if !wasCalled {
|
||||
t.Fatalf("wasCalled: got false, want true")
|
||||
}
|
||||
|
||||
wasCalled = false
|
||||
rl = newRateLogger(func() time.Time { return clock.Now() }, 1*time.Hour, func(count int64, _ time.Time, _ int64) {
|
||||
if count != 3 {
|
||||
t.Fatalf("count for prev period: got %d, want 3", count)
|
||||
}
|
||||
wasCalled = true
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
clock.Advance(1 * time.Minute)
|
||||
rl.update(0)
|
||||
if wasCalled {
|
||||
t.Fatalf("wasCalled: got true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
clock.Advance(1 * time.Hour)
|
||||
rl.update(0)
|
||||
if !wasCalled {
|
||||
t.Fatalf("wasCalled: got false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ while [ "$#" -gt 1 ]; do
|
||||
--extra-small)
|
||||
shift
|
||||
ldflags="$ldflags -w -s"
|
||||
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube"
|
||||
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube,ts_omit_completion"
|
||||
;;
|
||||
--box)
|
||||
shift
|
||||
|
||||
@@ -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}"
|
||||
@@ -49,6 +49,7 @@ case "$TARGET" in
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--gotags="ts_kube" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
@@ -70,8 +71,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
|
||||
|
||||
@@ -49,3 +49,11 @@ type ReloadConfigResponse struct {
|
||||
Reloaded bool // whether the config was reloaded
|
||||
Err string // any error message
|
||||
}
|
||||
|
||||
// ExitNodeSuggestionResponse is the response to a LocalAPI suggest-exit-node GET request.
|
||||
// It returns the StableNodeID, name, and location of a suggested exit node for the client making the request.
|
||||
type ExitNodeSuggestionResponse struct {
|
||||
ID tailcfg.StableNodeID
|
||||
Name string
|
||||
Location tailcfg.LocationView `json:",omitempty"`
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
"go4.org/mem"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/drive"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
@@ -35,7 +36,6 @@ import (
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tailfs"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/tkatype"
|
||||
@@ -699,6 +699,27 @@ func (lc *LocalClient) CheckUDPGROForwarding(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUDPGROForwarding enables UDP GRO forwarding for the main interface of this
|
||||
// node. This can be done to improve performance of tailnet nodes acting as exit
|
||||
// nodes or subnet routers.
|
||||
// See https://tailscale.com/kb/1320/performance-best-practices#linux-optimizations-for-subnet-routers-and-exit-nodes
|
||||
func (lc *LocalClient) SetUDPGROForwarding(ctx context.Context) error {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/set-udp-gro-forwarding")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jres struct {
|
||||
Warning string
|
||||
}
|
||||
if err := json.Unmarshal(body, &jres); err != nil {
|
||||
return fmt.Errorf("invalid JSON from set-udp-gro-forwarding: %w", err)
|
||||
}
|
||||
if jres.Warning != "" {
|
||||
return errors.New(jres.Warning)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPrefs validates the provided preferences, without making any changes.
|
||||
//
|
||||
// The CLI uses this before a Start call to fail fast if the preferences won't
|
||||
@@ -778,6 +799,17 @@ func (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error {
|
||||
//
|
||||
// The ctx is only used for the duration of the call, not the lifetime of the net.Conn.
|
||||
func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
|
||||
return lc.UserDial(ctx, "tcp", host, port)
|
||||
}
|
||||
|
||||
// UserDial connects to the host's port via Tailscale for the given network.
|
||||
//
|
||||
// The host may be a base DNS name (resolved from the netmap inside tailscaled),
|
||||
// a FQDN, or an IP address.
|
||||
//
|
||||
// The ctx is only used for the duration of the call, not the lifetime of the
|
||||
// net.Conn.
|
||||
func (lc *LocalClient) UserDial(ctx context.Context, network, host string, port uint16) (net.Conn, error) {
|
||||
connCh := make(chan net.Conn, 1)
|
||||
trace := httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
@@ -790,10 +822,11 @@ func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (n
|
||||
return nil, err
|
||||
}
|
||||
req.Header = http.Header{
|
||||
"Upgrade": []string{"ts-dial"},
|
||||
"Connection": []string{"upgrade"},
|
||||
"Dial-Host": []string{host},
|
||||
"Dial-Port": []string{fmt.Sprint(port)},
|
||||
"Upgrade": []string{"ts-dial"},
|
||||
"Connection": []string{"upgrade"},
|
||||
"Dial-Host": []string{host},
|
||||
"Dial-Port": []string{fmt.Sprint(port)},
|
||||
"Dial-Network": []string{network},
|
||||
}
|
||||
res, err := lc.DoLocalRequest(req)
|
||||
if err != nil {
|
||||
@@ -1418,44 +1451,62 @@ func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion,
|
||||
return &cv, nil
|
||||
}
|
||||
|
||||
// TailFSSetFileServerAddr instructs TailFS to use the server at addr to access
|
||||
// SetUseExitNode toggles the use of an exit node on or off.
|
||||
// To turn it on, there must have been a previously used exit node.
|
||||
// The most previously used one is reused.
|
||||
// This is a convenience method for GUIs. To select an actual one, update the prefs.
|
||||
func (lc *LocalClient) SetUseExitNode(ctx context.Context, on bool) error {
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/set-use-exit-node-enabled?enabled="+strconv.FormatBool(on), http.StatusOK, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DriveSetServerAddr instructs Taildrive to use the server at addr to access
|
||||
// the filesystem. This is used on platforms like Windows and MacOS to let
|
||||
// TailFS know to use the file server running in the GUI app.
|
||||
func (lc *LocalClient) TailFSSetFileServerAddr(ctx context.Context, addr string) error {
|
||||
_, err := lc.send(ctx, "PUT", "/localapi/v0/tailfs/fileserver-address", http.StatusCreated, strings.NewReader(addr))
|
||||
// Taildrive know to use the file server running in the GUI app.
|
||||
func (lc *LocalClient) DriveSetServerAddr(ctx context.Context, addr string) error {
|
||||
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/fileserver-address", http.StatusCreated, strings.NewReader(addr))
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_, err := lc.send(ctx, "PUT", "/localapi/v0/tailfs/shares", http.StatusCreated, jsonBody(share))
|
||||
// DriveShareSet adds or updates the given share in the list of shares that
|
||||
// Taildrive will serve to remote nodes. If a share with the same name already
|
||||
// exists, the existing share is replaced/updated.
|
||||
func (lc *LocalClient) DriveShareSet(ctx context.Context, share *drive.Share) error {
|
||||
_, err := lc.send(ctx, "PUT", "/localapi/v0/drive/shares", http.StatusCreated, jsonBody(share))
|
||||
return err
|
||||
}
|
||||
|
||||
// TailFSShareRemove removes the share with the given name from the list of
|
||||
// shares that TailFS will serve to remote nodes.
|
||||
func (lc *LocalClient) TailFSShareRemove(ctx context.Context, name string) error {
|
||||
// DriveShareRemove removes the share with the given name from the list of
|
||||
// shares that Taildrive will serve to remote nodes.
|
||||
func (lc *LocalClient) DriveShareRemove(ctx context.Context, name string) error {
|
||||
_, err := lc.send(
|
||||
ctx,
|
||||
"DELETE",
|
||||
"/localapi/v0/tailfs/shares",
|
||||
"/localapi/v0/drive/shares",
|
||||
http.StatusNoContent,
|
||||
jsonBody(&tailfs.Share{
|
||||
Name: name,
|
||||
}))
|
||||
strings.NewReader(name))
|
||||
return err
|
||||
}
|
||||
|
||||
// TailFSShareList returns the list of shares that TailFS is currently serving
|
||||
// DriveShareRename renames the share from old to new name.
|
||||
func (lc *LocalClient) DriveShareRename(ctx context.Context, oldName, newName string) error {
|
||||
_, err := lc.send(
|
||||
ctx,
|
||||
"POST",
|
||||
"/localapi/v0/drive/shares",
|
||||
http.StatusNoContent,
|
||||
jsonBody([2]string{oldName, newName}))
|
||||
return err
|
||||
}
|
||||
|
||||
// DriveShareList returns the list of shares that drive is currently serving
|
||||
// to remote nodes.
|
||||
func (lc *LocalClient) TailFSShareList(ctx context.Context) (map[string]*tailfs.Share, error) {
|
||||
result, err := lc.get200(ctx, "/localapi/v0/tailfs/shares")
|
||||
func (lc *LocalClient) DriveShareList(ctx context.Context) ([]*drive.Share, error) {
|
||||
result, err := lc.get200(ctx, "/localapi/v0/drive/shares")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var shares map[string]*tailfs.Share
|
||||
var shares []*drive.Share
|
||||
err = json.Unmarshal(result, &shares)
|
||||
return shares, err
|
||||
}
|
||||
@@ -1496,3 +1547,12 @@ func (w *IPNBusWatcher) Next() (ipn.Notify, error) {
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SuggestExitNode requests an exit node suggestion and returns the exit node's details.
|
||||
func (lc *LocalClient) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/suggest-exit-node")
|
||||
if err != nil {
|
||||
return apitype.ExitNodeSuggestionResponse{}, err
|
||||
}
|
||||
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
|
||||
}
|
||||
|
||||
@@ -34,9 +34,10 @@ func TestDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
BadDeps: map[string]string{
|
||||
// Make sure we don't again accidentally bring in a dependency on
|
||||
// TailFS or its transitive dependencies
|
||||
"tailscale.com/tailfs/tailfsimpl": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
// drive or its transitive dependencies
|
||||
"testing": "do not use testing package in production code",
|
||||
"tailscale.com/drive/driveimpl": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
"github.com/studio-b12/gowebdav": "https://github.com/tailscale/tailscale/pull/10631",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) err
|
||||
|
||||
func (s *Server) newSessionID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4",
|
||||
"vite": "^5.1.7",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
"vitest": "^1.3.1"
|
||||
|
||||
@@ -1150,7 +1150,15 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tails
|
||||
if !isRunning {
|
||||
ipnOptions := ipn.Options{AuthKey: opt.AuthKey}
|
||||
if opt.ControlURL != "" {
|
||||
ipnOptions.UpdatePrefs = &ipn.Prefs{ControlURL: opt.ControlURL}
|
||||
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
ControlURL: opt.ControlURL,
|
||||
},
|
||||
ControlURLSet: true,
|
||||
})
|
||||
if err != nil {
|
||||
s.logf("edit prefs: %v", err)
|
||||
}
|
||||
}
|
||||
if err := s.lc.Start(ctx, ipnOptions); err != nil {
|
||||
s.logf("start: %v", err)
|
||||
|
||||
@@ -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.13", "@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,26 +63,6 @@
|
||||
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":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.4.tgz#4a41377d8566ec18f807f42962a7f3551de83d1c"
|
||||
@@ -149,17 +87,6 @@
|
||||
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":
|
||||
version "7.22.15"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz#0698fc44551a26cf29f18d4662d5bf545a6cfc52"
|
||||
@@ -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"
|
||||
@@ -245,24 +167,6 @@
|
||||
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":
|
||||
version "7.23.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz#d7d12c3c5d30af5b3c0fcab2a6d5217773e2d0f1"
|
||||
@@ -325,11 +229,6 @@
|
||||
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":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83"
|
||||
@@ -340,21 +239,11 @@
|
||||
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":
|
||||
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,15 +253,6 @@
|
||||
"@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":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.23.4.tgz#7d2cfb969aa43222032193accd7329851facf3c1"
|
||||
@@ -382,24 +262,6 @@
|
||||
"@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":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b"
|
||||
@@ -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.15", "@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,20 +1086,13 @@
|
||||
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":
|
||||
version "7.22.15"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
|
||||
@@ -1257,31 +1102,6 @@
|
||||
"@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":
|
||||
version "7.23.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.4.tgz#c2790f7edf106d059a0098770fe70801417f3f85"
|
||||
@@ -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.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==
|
||||
@@ -2063,16 +1865,11 @@
|
||||
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.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"
|
||||
@@ -2121,12 +1918,7 @@
|
||||
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.5.0":
|
||||
"@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==
|
||||
@@ -2338,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"
|
||||
@@ -2648,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==
|
||||
@@ -2697,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.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==
|
||||
@@ -2820,11 +2587,6 @@ confusing-browser-globals@^1.0.11:
|
||||
resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81"
|
||||
integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==
|
||||
|
||||
convert-source-map@^1.7.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
|
||||
integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
|
||||
|
||||
convert-source-map@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
|
||||
@@ -3005,11 +2767,6 @@ 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:
|
||||
version "1.4.596"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.596.tgz#6752d1aa795d942d49dfc5d3764d6ea283fab1d7"
|
||||
@@ -3441,18 +3198,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==
|
||||
@@ -3542,22 +3288,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==
|
||||
@@ -3582,7 +3318,7 @@ gensync@^1.0.0-beta.2:
|
||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
|
||||
|
||||
get-func-name@^2.0.0, get-func-name@^2.0.1, get-func-name@^2.0.2:
|
||||
get-func-name@^2.0.1, get-func-name@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41"
|
||||
integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==
|
||||
@@ -3745,13 +3481,6 @@ has-tostringtag@^1.0.0:
|
||||
dependencies:
|
||||
has-symbols "^1.0.2"
|
||||
|
||||
has@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
|
||||
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
|
||||
dependencies:
|
||||
function-bind "^1.1.1"
|
||||
|
||||
hasown@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c"
|
||||
@@ -3794,12 +3523,7 @@ 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.4:
|
||||
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==
|
||||
@@ -3894,14 +3618,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==
|
||||
@@ -4240,14 +3957,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==
|
||||
@@ -4336,17 +4046,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==
|
||||
@@ -4375,11 +4075,6 @@ 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:
|
||||
version "3.3.7"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
|
||||
@@ -4601,12 +4296,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==
|
||||
@@ -4689,16 +4379,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==
|
||||
@@ -4912,16 +4593,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==
|
||||
@@ -5028,14 +4700,7 @@ 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==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
semver@^7.5.4:
|
||||
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==
|
||||
@@ -5444,11 +5109,6 @@ typescript@^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:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.4.0.tgz#39845b31be81b4f319ab1d99fd20c56cac528d32"
|
||||
@@ -5497,14 +5157,6 @@ 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:
|
||||
version "1.0.13"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4"
|
||||
@@ -5583,10 +5235,10 @@ vite-tsconfig-paths@^3.5.0:
|
||||
recrawl-sync "^2.0.3"
|
||||
tsconfig-paths "^4.0.0"
|
||||
|
||||
vite@^5.0.0, vite@^5.1.4:
|
||||
version "5.1.4"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.4.tgz#14e9d3e7a6e488f36284ef13cebe149f060bcfb6"
|
||||
integrity sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==
|
||||
vite@^5.0.0, vite@^5.1.7:
|
||||
version "5.1.7"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.7.tgz#9f685a2c4c70707fef6d37341b0e809c366da619"
|
||||
integrity sha512-sgnEEFTZYMui/sTlH1/XEnVNHMujOahPLGMxn1+5sIT45Xjng1Ec1K78jRP15dSmVgg5WBin9yO81j3o9OxofA==
|
||||
dependencies:
|
||||
esbuild "^0.19.3"
|
||||
postcss "^8.4.35"
|
||||
|
||||
@@ -436,7 +436,7 @@ func (up *Updater) updateDebLike() error {
|
||||
return fmt.Errorf("apt-get update failed: %w; output:\n%s", err, out)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
for range 2 {
|
||||
out, err := exec.Command("apt-get", "install", "--yes", "--allow-downgrades", "tailscale="+ver).CombinedOutput()
|
||||
if err != nil {
|
||||
if !bytes.Contains(out, []byte(`dpkg was interrupted`)) {
|
||||
@@ -651,6 +651,9 @@ func (up *Updater) updateAlpineLike() (err error) {
|
||||
return fmt.Errorf(`failed to parse latest version from "apk info tailscale": %w`, err)
|
||||
}
|
||||
if !up.confirm(ver) {
|
||||
if err := checkOutdatedAlpineRepo(up.Logf, ver, up.Track); err != nil {
|
||||
up.Logf("failed to check whether Alpine release is outdated: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -688,6 +691,37 @@ func parseAlpinePackageVersion(out []byte) (string, error) {
|
||||
return "", errors.New("tailscale version not found in output")
|
||||
}
|
||||
|
||||
var apkRepoVersionRE = regexp.MustCompile(`v[0-9]+\.[0-9]+`)
|
||||
|
||||
func checkOutdatedAlpineRepo(logf logger.Logf, apkVer, track string) error {
|
||||
latest, err := LatestTailscaleVersion(track)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if latest == apkVer {
|
||||
// Actually on latest release.
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open("/etc/apk/repositories")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
// Read the first repo line. Typically, there are multiple repos that all
|
||||
// contain the same version in the path, like:
|
||||
// https://dl-cdn.alpinelinux.org/alpine/v3.20/main
|
||||
// https://dl-cdn.alpinelinux.org/alpine/v3.20/community
|
||||
s := bufio.NewScanner(f)
|
||||
if !s.Scan() {
|
||||
return s.Err()
|
||||
}
|
||||
alpineVer := apkRepoVersionRE.FindString(s.Text())
|
||||
if alpineVer != "" {
|
||||
logf("The latest Tailscale release for Linux is %q, but your apk repository only provides %q.\nYour Alpine version is %q, you may need to upgrade the system to get the latest Tailscale version: https://wiki.alpinelinux.org/wiki/Upgrading_Alpine", latest, apkVer, alpineVer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (up *Updater) updateMacSys() error {
|
||||
return errors.New("NOTREACHED: On MacSys builds, `tailscale update` is handled in Swift to launch the GUI updater")
|
||||
}
|
||||
@@ -836,7 +870,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
|
||||
@@ -1017,6 +1051,20 @@ func (up *Updater) updateLinuxBinary() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func restartSystemdUnit(ctx context.Context) error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
// Likely not a systemd-managed distro.
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("systemctl daemon-reload failed: %w\noutput: %s", err, out)
|
||||
}
|
||||
if out, err := exec.Command("systemctl", "restart", "tailscaled.service").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("systemctl restart failed: %w\noutput: %s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (up *Updater) downloadLinuxTarball(ver string) (string, error) {
|
||||
dlDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
@@ -1295,10 +1343,23 @@ func LatestTailscaleVersion(track string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if latest.Version == "" {
|
||||
return "", fmt.Errorf("no latest version found for %q track", track)
|
||||
ver := latest.Version
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
ver = latest.MSIsVersion
|
||||
case "darwin":
|
||||
ver = latest.MacZipsVersion
|
||||
case "linux":
|
||||
ver = latest.TarballsVersion
|
||||
if distro.Get() == distro.Synology {
|
||||
ver = latest.SPKsVersion
|
||||
}
|
||||
}
|
||||
return latest.Version, nil
|
||||
|
||||
if ver == "" {
|
||||
return "", fmt.Errorf("no latest version found for OS %q on %q track", runtime.GOOS, track)
|
||||
}
|
||||
return ver, nil
|
||||
}
|
||||
|
||||
type trackPackages struct {
|
||||
|
||||
@@ -663,7 +663,7 @@ func genTarball(t *testing.T, path string, files map[string]string) {
|
||||
|
||||
func TestWriteFileOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test")
|
||||
for i := 0; i < 2; i++ {
|
||||
for i := range 2 {
|
||||
content := fmt.Sprintf("content %d", i)
|
||||
if err := writeFile(strings.NewReader(content), path, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -445,7 +445,7 @@ type testServer struct {
|
||||
|
||||
func newTestServer(t *testing.T) *testServer {
|
||||
var roots []rootKeyPair
|
||||
for i := 0; i < 3; i++ {
|
||||
for range 3 {
|
||||
roots = append(roots, newRootKeyPair(t))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/coreos/go-systemd/v22/dbus"
|
||||
)
|
||||
|
||||
func restartSystemdUnit(ctx context.Context) error {
|
||||
c, err := dbus.NewWithContext(ctx)
|
||||
if err != nil {
|
||||
// Likely not a systemd-managed distro.
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.ReloadContext(ctx); err != nil {
|
||||
return fmt.Errorf("failed to reload tailsacled.service: %w", err)
|
||||
}
|
||||
ch := make(chan string, 1)
|
||||
if _, err := c.RestartUnitContext(ctx, "tailscaled.service", "replace", ch); err != nil {
|
||||
return fmt.Errorf("failed to restart tailsacled.service: %w", err)
|
||||
}
|
||||
select {
|
||||
case res := <-ch:
|
||||
if res != "done" {
|
||||
return fmt.Errorf("systemd service restart failed with result %q", res)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !linux
|
||||
|
||||
package clientupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func restartSystemdUnit(ctx context.Context) error {
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("}")
|
||||
writef("dst := new(%s)", name)
|
||||
writef("*dst = *src")
|
||||
for i := 0; i < t.NumFields(); i++ {
|
||||
for i := range t.NumFields() {
|
||||
fname := t.Field(i).Name()
|
||||
ft := t.Field(i).Type()
|
||||
if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) {
|
||||
|
||||
@@ -8,6 +8,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -18,20 +19,6 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// findKeyInKubeSecret inspects the kube secret secretName for a data
|
||||
// field called "authkey", and returns its value if present.
|
||||
func findKeyInKubeSecret(ctx context.Context, secretName string) (string, error) {
|
||||
s, err := kc.GetSecret(ctx, secretName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ak, ok := s.Data["authkey"]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return string(ak), nil
|
||||
}
|
||||
|
||||
// storeDeviceInfo writes deviceID into the "device_id" data field of the kube
|
||||
// secret secretName.
|
||||
func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID, fqdn string, addresses []netip.Prefix) error {
|
||||
@@ -88,9 +75,59 @@ func deleteAuthKey(ctx context.Context, secretName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var kc *kube.Client
|
||||
var kc kube.Client
|
||||
|
||||
func initKube(root string) {
|
||||
// setupKube is responsible for doing any necessary configuration and checks to
|
||||
// ensure that tailscale state storage and authentication mechanism will work on
|
||||
// Kubernetes.
|
||||
func (cfg *settings) setupKube(ctx context.Context) error {
|
||||
if cfg.KubeSecret == "" {
|
||||
return nil
|
||||
}
|
||||
canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
|
||||
}
|
||||
cfg.KubernetesCanPatch = canPatch
|
||||
|
||||
s, err := kc.GetSecret(ctx, cfg.KubeSecret)
|
||||
if err != nil && kube.IsNotFoundErr(err) && !canCreate {
|
||||
return fmt.Errorf("Tailscale state Secret %s does not exist and we don't have permissions to create it. "+
|
||||
"If you intend to store tailscale state elsewhere than a Kubernetes Secret, "+
|
||||
"you can explicitly set TS_KUBE_SECRET env var to an empty string. "+
|
||||
"Else ensure that RBAC is set up that allows the service account associated with this installation to create Secrets.", cfg.KubeSecret)
|
||||
} else if err != nil && !kube.IsNotFoundErr(err) {
|
||||
return fmt.Errorf("Getting Tailscale state Secret %s: %v", cfg.KubeSecret, err)
|
||||
}
|
||||
|
||||
if cfg.AuthKey == "" && !isOneStepConfig(cfg) {
|
||||
if s == nil {
|
||||
log.Print("TS_AUTHKEY not provided and kube secret does not exist, login will be interactive if needed.")
|
||||
return nil
|
||||
}
|
||||
keyBytes, _ := s.Data["authkey"]
|
||||
key := string(keyBytes)
|
||||
|
||||
if key != "" {
|
||||
// This behavior of pulling authkeys from kube secrets was added
|
||||
// at the same time as the patch permission, so we can enforce
|
||||
// that we must be able to patch out the authkey after
|
||||
// authenticating if you want to use this feature. This avoids
|
||||
// us having to deal with the case where we might leave behind
|
||||
// an unnecessary reusable authkey in a secret, like a rake in
|
||||
// the grass.
|
||||
if !cfg.KubernetesCanPatch {
|
||||
return errors.New("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the secret to manage the authkey.")
|
||||
}
|
||||
cfg.AuthKey = key
|
||||
} else {
|
||||
log.Print("No authkey found in kube secret and TS_AUTHKEY not provided, login will be interactive if needed.")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initKubeClient(root string) {
|
||||
if root != "/" {
|
||||
// If we are running in a test, we need to set the root path to the fake
|
||||
// service account directory.
|
||||
@@ -101,9 +138,9 @@ func initKube(root string) {
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating kube client: %v", err)
|
||||
}
|
||||
if root != "/" {
|
||||
// If we are running in a test, we need to set the URL to the
|
||||
// httptest server.
|
||||
if (root != "/") || os.Getenv("TS_KUBERNETES_READ_API_SERVER_ADDRESS_FROM_ENV") == "true" {
|
||||
// Derive the API server address from the environment variables
|
||||
// Used to set http server in tests, or optionally enabled by flag
|
||||
kc.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
|
||||
}
|
||||
}
|
||||
|
||||
206
cmd/containerboot/kube_test.go
Normal file
206
cmd/containerboot/kube_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/kube"
|
||||
)
|
||||
|
||||
func TestSetupKube(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *settings
|
||||
wantErr bool
|
||||
wantCfg *settings
|
||||
kc kube.Client
|
||||
}{
|
||||
{
|
||||
name: "TS_AUTHKEY set, state Secret exists",
|
||||
cfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return nil, nil
|
||||
},
|
||||
},
|
||||
wantCfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY set, state Secret does not exist, we have permissions to create it",
|
||||
cfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, true, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return nil, &kube.Status{Code: 404}
|
||||
},
|
||||
},
|
||||
wantCfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY set, state Secret does not exist, we do not have permissions to create it",
|
||||
cfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return nil, &kube.Status{Code: 404}
|
||||
},
|
||||
},
|
||||
wantCfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY set, we encounter a non-404 error when trying to retrieve the state Secret",
|
||||
cfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return nil, &kube.Status{Code: 403}
|
||||
},
|
||||
},
|
||||
wantCfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY set, we encounter a non-404 error when trying to check Secret permissions",
|
||||
cfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantCfg: &settings{
|
||||
AuthKey: "foo",
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, errors.New("broken")
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// Interactive login using URL in Pod logs
|
||||
name: "TS_AUTHKEY not set, state Secret does not exist, we have permissions to create it",
|
||||
cfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantCfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, true, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return nil, &kube.Status{Code: 404}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Interactive login using URL in Pod logs
|
||||
name: "TS_AUTHKEY not set, state Secret exists, but does not contain auth key",
|
||||
cfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantCfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return &kube.Secret{}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY not set, state Secret contains auth key, we do not have RBAC to patch it",
|
||||
cfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return false, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return &kube.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
|
||||
},
|
||||
},
|
||||
wantCfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "TS_AUTHKEY not set, state Secret contains auth key, we have RBAC to patch it",
|
||||
cfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
},
|
||||
kc: &kube.FakeClient{
|
||||
CheckSecretPermissionsImpl: func(context.Context, string) (bool, bool, error) {
|
||||
return true, false, nil
|
||||
},
|
||||
GetSecretImpl: func(context.Context, string) (*kube.Secret, error) {
|
||||
return &kube.Secret{Data: map[string][]byte{"authkey": []byte("foo")}}, nil
|
||||
},
|
||||
},
|
||||
wantCfg: &settings{
|
||||
KubeSecret: "foo",
|
||||
AuthKey: "foo",
|
||||
KubernetesCanPatch: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
kc = tt.kc
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.cfg.setupKube(context.Background()); (err != nil) != tt.wantErr {
|
||||
t.Errorf("settings.setupKube() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if diff := cmp.Diff(*tt.cfg, *tt.wantCfg); diff != "" {
|
||||
t.Errorf("unexpected contents of settings after running settings.setupKube()\n(-got +want):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,11 @@
|
||||
// previously advertised routes. To accept routes, use TS_EXTRA_ARGS to pass
|
||||
// in --accept-routes.
|
||||
// - TS_DEST_IP: proxy all incoming Tailscale traffic to the given
|
||||
// destination.
|
||||
// destination defined by an IP address.
|
||||
// - TS_EXPERIMENTAL_DEST_DNS_NAME: proxy all incoming Tailscale traffic to the given
|
||||
// destination defined by a DNS name. The DNS name will be periodically resolved and firewall rules updated accordingly.
|
||||
// This is currently intended to be used by the Kubernetes operator (ExternalName Services).
|
||||
// This is an experimental env var and will likely change in the future.
|
||||
// - TS_TAILNET_TARGET_IP: proxy all incoming non-Tailscale traffic to the given
|
||||
// destination defined by an IP.
|
||||
// - TS_TAILNET_TARGET_FQDN: proxy all incoming non-Tailscale traffic to the given
|
||||
@@ -48,13 +52,20 @@
|
||||
// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN.
|
||||
// It cannot be used in conjunction with TS_DEST_IP. The file is watched for changes,
|
||||
// and will be re-applied when it changes.
|
||||
// - EXPERIMENTAL_TS_CONFIGFILE_PATH: if specified, a path to tailscaled
|
||||
// config. If this is set, TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY,
|
||||
// - TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR: if specified, a path to a
|
||||
// directory that containers tailscaled config in file. The config file needs to be
|
||||
// named cap-<current-tailscaled-cap>.hujson. If this is set, TS_HOSTNAME,
|
||||
// TS_EXTRA_ARGS, TS_AUTHKEY,
|
||||
// TS_ROUTES, TS_ACCEPT_DNS env vars must not be set. If this is set,
|
||||
// containerboot only runs `tailscaled --config <path-to-this-configfile>`
|
||||
// and not `tailscale up` or `tailscale set`.
|
||||
// The config file contents are currently read once on container start.
|
||||
// NB: This env var is currently experimental and the logic will likely change!
|
||||
// TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS: set to true to
|
||||
// autoconfigure the default network interface for optimal performance for
|
||||
// Tailscale subnet router/exit node.
|
||||
// https://tailscale.com/kb/1320/performance-best-practices#linux-optimizations-for-subnet-routers-and-exit-nodes
|
||||
// NB: This env var is currently experimental and the logic will likely change!
|
||||
// - EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS: if set to true
|
||||
// and if this containerboot instance is an L7 ingress proxy (created by
|
||||
// the Kubernetes operator), set up rules to allow proxying cluster traffic,
|
||||
@@ -82,12 +93,16 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -100,6 +115,7 @@ import (
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/conffile"
|
||||
kubeutils "tailscale.com/k8s-operator"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/ptr"
|
||||
@@ -122,7 +138,8 @@ func main() {
|
||||
Hostname: defaultEnv("TS_HOSTNAME", ""),
|
||||
Routes: defaultEnvStringPointer("TS_ROUTES"),
|
||||
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
|
||||
ProxyTo: defaultEnv("TS_DEST_IP", ""),
|
||||
ProxyTargetIP: defaultEnv("TS_DEST_IP", ""),
|
||||
ProxyTargetDNSName: defaultEnv("TS_EXPERIMENTAL_DEST_DNS_NAME", ""),
|
||||
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
|
||||
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
|
||||
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
|
||||
@@ -137,9 +154,10 @@ func main() {
|
||||
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
TailscaledConfigFilePath: defaultEnv("EXPERIMENTAL_TS_CONFIGFILE_PATH", ""),
|
||||
TailscaledConfigFilePath: tailscaledConfigFilePath(),
|
||||
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
|
||||
PodIP: defaultEnv("POD_IP", ""),
|
||||
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
@@ -150,8 +168,8 @@ func main() {
|
||||
if err := ensureTunFile(cfg.Root); err != nil {
|
||||
log.Fatalf("Unable to create tuntap device file: %v", err)
|
||||
}
|
||||
if cfg.ProxyTo != "" || cfg.Routes != nil || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" {
|
||||
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTo, cfg.TailnetTargetIP, cfg.TailnetTargetFQDN, cfg.Routes); err != nil {
|
||||
if cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.Routes != nil || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" {
|
||||
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTargetIP, cfg.TailnetTargetIP, cfg.TailnetTargetFQDN, cfg.Routes); err != nil {
|
||||
log.Printf("Failed to enable IP forwarding: %v", err)
|
||||
log.Printf("To run tailscale as a proxy or router container, IP forwarding must be enabled.")
|
||||
if cfg.InKubernetes {
|
||||
@@ -163,44 +181,16 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.InKubernetes {
|
||||
initKube(cfg.Root)
|
||||
}
|
||||
|
||||
// Context is used for all setup stuff until we're in steady
|
||||
// state, so that if something is hanging we eventually time out
|
||||
// and crashloop the container.
|
||||
bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if cfg.InKubernetes && cfg.KubeSecret != "" {
|
||||
canPatch, err := kc.CheckSecretPermissions(bootCtx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("Some Kubernetes permissions are missing, please check your RBAC configuration: %v", err)
|
||||
}
|
||||
cfg.KubernetesCanPatch = canPatch
|
||||
|
||||
if cfg.AuthKey == "" && !isOneStepConfig(cfg) {
|
||||
key, err := findKeyInKubeSecret(bootCtx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("Getting authkey from kube secret: %v", err)
|
||||
}
|
||||
if key != "" {
|
||||
// This behavior of pulling authkeys from kube secrets was added
|
||||
// at the same time as the patch permission, so we can enforce
|
||||
// that we must be able to patch out the authkey after
|
||||
// authenticating if you want to use this feature. This avoids
|
||||
// us having to deal with the case where we might leave behind
|
||||
// an unnecessary reusable authkey in a secret, like a rake in
|
||||
// the grass.
|
||||
if !cfg.KubernetesCanPatch {
|
||||
log.Fatalf("authkey found in TS_KUBE_SECRET, but the pod doesn't have patch permissions on the secret to manage the authkey.")
|
||||
}
|
||||
log.Print("Using authkey found in kube secret")
|
||||
cfg.AuthKey = key
|
||||
} else {
|
||||
log.Print("No authkey found in kube secret and TS_AUTHKEY not provided, login will be interactive if needed.")
|
||||
}
|
||||
if cfg.InKubernetes {
|
||||
initKubeClient(cfg.Root)
|
||||
if err := cfg.setupKube(bootCtx); err != nil {
|
||||
log.Fatalf("error setting up for running on Kubernetes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +205,12 @@ func main() {
|
||||
}
|
||||
defer killTailscaled()
|
||||
|
||||
if cfg.EnableForwardingOptimizations {
|
||||
if err := client.SetUDPGROForwarding(bootCtx); err != nil {
|
||||
log.Printf("[unexpected] error enabling UDP GRO forwarding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err := client.WatchIPNBus(bootCtx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to watch tailscaled for updates: %v", err)
|
||||
@@ -341,7 +337,7 @@ authLoop:
|
||||
}
|
||||
|
||||
var (
|
||||
wantProxy = cfg.ProxyTo != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress
|
||||
wantProxy = cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress
|
||||
wantDeviceInfo = cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch
|
||||
startupTasksDone = false
|
||||
currentIPs deephash.Sum // tailscale IPs assigned to device
|
||||
@@ -349,6 +345,9 @@ authLoop:
|
||||
|
||||
currentEgressIPs deephash.Sum
|
||||
|
||||
addrs []netip.Prefix
|
||||
backendAddrs []net.IP
|
||||
|
||||
certDomain = new(atomic.Pointer[string])
|
||||
certDomainChanged = make(chan bool, 1)
|
||||
)
|
||||
@@ -362,6 +361,44 @@ authLoop:
|
||||
log.Fatalf("error creating new netfilter runner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Setup for proxies that are configured to proxy to a target specified
|
||||
// by a DNS name (TS_EXPERIMENTAL_DEST_DNS_NAME).
|
||||
const defaultCheckPeriod = time.Minute * 10 // how often to check what IPs the DNS name resolves to
|
||||
var (
|
||||
tc = make(chan string, 1)
|
||||
failedResolveAttempts int
|
||||
t *time.Timer = time.AfterFunc(defaultCheckPeriod, func() {
|
||||
if cfg.ProxyTargetDNSName != "" {
|
||||
tc <- "recheck"
|
||||
}
|
||||
})
|
||||
)
|
||||
defer t.Stop()
|
||||
// resetTimer resets timer for when to next attempt to resolve the DNS
|
||||
// name for the proxy configured with TS_EXPERIMENTAL_DEST_DNS_NAME. The
|
||||
// timer gets reset to 10 minutes from now unless the last resolution
|
||||
// attempt failed. If one or more consecutive previous resolution
|
||||
// attempts failed, the next resolution attempt will happen after the smallest
|
||||
// of (10 minutes, 2 ^ number-of-consecutive-failed-resolution-attempts
|
||||
// seconds) i.e 2s, 4s, 8s ... 10 minutes.
|
||||
resetTimer := func(lastResolveFailed bool) {
|
||||
if !lastResolveFailed {
|
||||
log.Printf("reconfigureTimer: next DNS resolution attempt in %s", defaultCheckPeriod)
|
||||
t.Reset(defaultCheckPeriod)
|
||||
failedResolveAttempts = 0
|
||||
return
|
||||
}
|
||||
minDelay := 2 // 2 seconds
|
||||
nextTick := time.Second * time.Duration(math.Pow(float64(minDelay), float64(failedResolveAttempts)))
|
||||
if nextTick > defaultCheckPeriod {
|
||||
nextTick = defaultCheckPeriod // cap at 10 minutes
|
||||
}
|
||||
log.Printf("reconfigureTimer: last DNS resolution attempt failed, next DNS resolution attempt in %v", nextTick)
|
||||
t.Reset(nextTick)
|
||||
failedResolveAttempts++
|
||||
}
|
||||
|
||||
notifyChan := make(chan ipn.Notify)
|
||||
errChan := make(chan error)
|
||||
go func() {
|
||||
@@ -399,7 +436,7 @@ runLoop:
|
||||
log.Fatalf("tailscaled left running state (now in state %q), exiting", *n.State)
|
||||
}
|
||||
if n.NetMap != nil {
|
||||
addrs := n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
addrs = n.NetMap.SelfNode.Addresses().AsSlice()
|
||||
newCurrentIPs := deephash.Hash(&addrs)
|
||||
ipsHaveChanged := newCurrentIPs != currentIPs
|
||||
|
||||
@@ -425,7 +462,7 @@ runLoop:
|
||||
egressAddrs = node.Addresses().AsSlice()
|
||||
newCurentEgressIPs = deephash.Hash(&egressAddrs)
|
||||
egressIPsHaveChanged = newCurentEgressIPs != currentEgressIPs
|
||||
if egressIPsHaveChanged && len(egressAddrs) > 0 {
|
||||
if egressIPsHaveChanged && len(egressAddrs) != 0 {
|
||||
for _, egressAddr := range egressAddrs {
|
||||
ea := egressAddr.Addr()
|
||||
// TODO (irbekrm): make it work for IPv6 too.
|
||||
@@ -441,13 +478,32 @@ runLoop:
|
||||
}
|
||||
currentEgressIPs = newCurentEgressIPs
|
||||
}
|
||||
if cfg.ProxyTo != "" && len(addrs) > 0 && ipsHaveChanged {
|
||||
if cfg.ProxyTargetIP != "" && len(addrs) != 0 && ipsHaveChanged {
|
||||
log.Printf("Installing proxy rules")
|
||||
if err := installIngressForwardingRule(ctx, cfg.ProxyTo, addrs, nfr); err != nil {
|
||||
if err := installIngressForwardingRule(ctx, cfg.ProxyTargetIP, addrs, nfr); err != nil {
|
||||
log.Fatalf("installing ingress proxy rules: %v", err)
|
||||
}
|
||||
}
|
||||
if cfg.ServeConfigPath != "" && len(n.NetMap.DNS.CertDomains) > 0 {
|
||||
if cfg.ProxyTargetDNSName != "" && len(addrs) != 0 && ipsHaveChanged {
|
||||
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
|
||||
if err != nil {
|
||||
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
|
||||
resetTimer(true)
|
||||
continue
|
||||
}
|
||||
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
|
||||
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
|
||||
}))
|
||||
if backendsHaveChanged {
|
||||
log.Printf("installing ingress proxy rules for backends %v", newBackendAddrs)
|
||||
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
|
||||
log.Fatalf("error installing ingress proxy rules: %v", err)
|
||||
}
|
||||
}
|
||||
resetTimer(false)
|
||||
backendAddrs = newBackendAddrs
|
||||
}
|
||||
if cfg.ServeConfigPath != "" && len(n.NetMap.DNS.CertDomains) != 0 {
|
||||
cd := n.NetMap.DNS.CertDomains[0]
|
||||
prev := certDomain.Swap(ptr.To(cd))
|
||||
if prev == nil || *prev != cd {
|
||||
@@ -457,7 +513,7 @@ runLoop:
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) > 0 {
|
||||
if cfg.TailnetTargetIP != "" && ipsHaveChanged && len(addrs) != 0 {
|
||||
log.Printf("Installing forwarding rules for destination %v", cfg.TailnetTargetIP)
|
||||
if err := installEgressForwardingRule(ctx, cfg.TailnetTargetIP, addrs, nfr); err != nil {
|
||||
log.Fatalf("installing egress proxy rules: %v", err)
|
||||
@@ -469,7 +525,7 @@ runLoop:
|
||||
// enabled, set up proxy rule each time the
|
||||
// tailnet IPs of this node change (including
|
||||
// the first time they become available).
|
||||
if cfg.AllowProxyingClusterTrafficViaIngress && cfg.ServeConfigPath != "" && ipsHaveChanged && len(addrs) > 0 {
|
||||
if cfg.AllowProxyingClusterTrafficViaIngress && cfg.ServeConfigPath != "" && ipsHaveChanged && len(addrs) != 0 {
|
||||
log.Printf("installing rules to forward traffic for %s to node's tailnet IP", cfg.PodIP)
|
||||
if err := installTSForwardingRuleForDestination(ctx, cfg.PodIP, addrs, nfr); err != nil {
|
||||
log.Fatalf("installing rules to forward traffic to node's tailnet IP: %v", err)
|
||||
@@ -491,32 +547,50 @@ runLoop:
|
||||
log.Println("Startup complete, waiting for shutdown signal")
|
||||
startupTasksDone = true
|
||||
|
||||
// Reap all processes, since we are PID1 and need to collect zombies. We can
|
||||
// only start doing this once we've stopped shelling out to things
|
||||
// `tailscale up`, otherwise this goroutine can reap the CLI subprocesses
|
||||
// and wedge bringup.
|
||||
// Wait on tailscaled process. It won't
|
||||
// be cleaned up by default when the
|
||||
// container exits as it is not PID1.
|
||||
// TODO (irbekrm): perhaps we can
|
||||
// replace the reaper by a running
|
||||
// cmd.Wait in a goroutine immediately
|
||||
// after starting tailscaled?
|
||||
reaper := func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
var status unix.WaitStatus
|
||||
pid, err := unix.Wait4(-1, &status, 0, nil)
|
||||
_, err := unix.Wait4(daemonProcess.Pid, &status, 0, nil)
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("Waiting for exited processes: %v", err)
|
||||
}
|
||||
if pid == daemonProcess.Pid {
|
||||
log.Printf("Tailscaled exited")
|
||||
os.Exit(0)
|
||||
log.Fatalf("Waiting for tailscaled to exit: %v", err)
|
||||
}
|
||||
log.Print("tailscaled exited")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
}
|
||||
wg.Add(1)
|
||||
go reaper()
|
||||
}
|
||||
}
|
||||
case <-tc:
|
||||
newBackendAddrs, err := resolveDNS(ctx, cfg.ProxyTargetDNSName)
|
||||
if err != nil {
|
||||
log.Printf("[unexpected] error resolving DNS name %s: %v", cfg.ProxyTargetDNSName, err)
|
||||
resetTimer(true)
|
||||
continue
|
||||
}
|
||||
backendsHaveChanged := !(slices.EqualFunc(backendAddrs, newBackendAddrs, func(ip1 net.IP, ip2 net.IP) bool {
|
||||
return slices.ContainsFunc(newBackendAddrs, func(ip net.IP) bool { return ip.Equal(ip1) })
|
||||
}))
|
||||
if backendsHaveChanged && len(addrs) != 0 {
|
||||
log.Printf("Backend address change detected, installing proxy rules for backends %v", newBackendAddrs)
|
||||
if err := installIngressForwardingRuleForDNSTarget(ctx, newBackendAddrs, addrs, nfr); err != nil {
|
||||
log.Fatalf("installing ingress proxy rules for DNS target %s: %v", cfg.ProxyTargetDNSName, err)
|
||||
}
|
||||
}
|
||||
backendAddrs = newBackendAddrs
|
||||
resetTimer(false)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -757,12 +831,12 @@ func ensureTunFile(root string) error {
|
||||
}
|
||||
|
||||
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
|
||||
func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, tailnetTargetFQDN string, routes *string) error {
|
||||
func ensureIPForwarding(root, clusterProxyTargetIP, tailnetTargetIP, tailnetTargetFQDN string, routes *string) error {
|
||||
var (
|
||||
v4Forwarding, v6Forwarding bool
|
||||
)
|
||||
if clusterProxyTarget != "" {
|
||||
proxyIP, err := netip.ParseAddr(clusterProxyTarget)
|
||||
if clusterProxyTargetIP != "" {
|
||||
proxyIP, err := netip.ParseAddr(clusterProxyTargetIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cluster destination IP: %v", err)
|
||||
}
|
||||
@@ -772,8 +846,8 @@ func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, tailnetTarget
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
if tailnetTargetiP != "" {
|
||||
proxyIP, err := netip.ParseAddr(tailnetTargetiP)
|
||||
if tailnetTargetIP != "" {
|
||||
proxyIP, err := netip.ParseAddr(tailnetTargetIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid tailnet destination IP: %v", err)
|
||||
}
|
||||
@@ -801,7 +875,10 @@ func ensureIPForwarding(root, clusterProxyTarget, tailnetTargetiP, tailnetTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
return enableIPForwarding(v4Forwarding, v6Forwarding, root)
|
||||
}
|
||||
|
||||
func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error {
|
||||
var paths []string
|
||||
if v4Forwarding {
|
||||
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv4/ip_forward"))
|
||||
@@ -896,16 +973,23 @@ func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []ne
|
||||
return err
|
||||
}
|
||||
var local netip.Addr
|
||||
proxyHasIPv4Address := false
|
||||
for _, pfx := range tsIPs {
|
||||
if !pfx.IsSingleIP() {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() {
|
||||
proxyHasIPv4Address = true
|
||||
}
|
||||
if pfx.Addr().Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = pfx.Addr()
|
||||
break
|
||||
}
|
||||
if proxyHasIPv4Address && dst.Is6() {
|
||||
log.Printf("Warning: proxy backend ClusterIP is an IPv6 address and the proxy has a IPv4 tailnet address. You might need to disable IPv4 address allocation for the proxy for forwarding to work. See https://github.com/tailscale/tailscale/issues/12156")
|
||||
}
|
||||
if !local.IsValid() {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
|
||||
}
|
||||
@@ -918,15 +1002,89 @@ func installIngressForwardingRule(ctx context.Context, dstStr string, tsIPs []ne
|
||||
return nil
|
||||
}
|
||||
|
||||
func installIngressForwardingRuleForDNSTarget(ctx context.Context, backendAddrs []net.IP, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
var (
|
||||
tsv4 netip.Addr
|
||||
tsv6 netip.Addr
|
||||
v4Backends []netip.Addr
|
||||
v6Backends []netip.Addr
|
||||
)
|
||||
for _, pfx := range tsIPs {
|
||||
if pfx.IsSingleIP() && pfx.Addr().Is4() {
|
||||
tsv4 = pfx.Addr()
|
||||
continue
|
||||
}
|
||||
if pfx.IsSingleIP() && pfx.Addr().Is6() {
|
||||
tsv6 = pfx.Addr()
|
||||
continue
|
||||
}
|
||||
}
|
||||
// TODO: log if more than one backend address is found and firewall is
|
||||
// in nftables mode that only the first IP will be used.
|
||||
for _, ip := range backendAddrs {
|
||||
if ip.To4() != nil {
|
||||
v4Backends = append(v4Backends, netip.AddrFrom4([4]byte(ip.To4())))
|
||||
}
|
||||
if ip.To16() != nil {
|
||||
v6Backends = append(v6Backends, netip.AddrFrom16([16]byte(ip.To16())))
|
||||
}
|
||||
}
|
||||
|
||||
// Enable IP forwarding here as opposed to at the start of containerboot
|
||||
// as the IPv4/IPv6 requirements might have changed.
|
||||
// For Kubernetes operator proxies, forwarding for both IPv4 and IPv6 is
|
||||
// enabled by an init container, so in practice enabling forwarding here
|
||||
// is only needed if this proxy has been configured by manually setting
|
||||
// TS_EXPERIMENTAL_DEST_DNS_NAME env var for a containerboot instance.
|
||||
if err := enableIPForwarding(len(v4Backends) != 0, len(v6Backends) != 0, ""); err != nil {
|
||||
log.Printf("[unexpected] failed to ensure IP forwarding: %v", err)
|
||||
}
|
||||
|
||||
updateFirewall := func(dst netip.Addr, backendTargets []netip.Addr) error {
|
||||
if err := nfr.DNATWithLoadBalancer(dst, backendTargets); err != nil {
|
||||
return fmt.Errorf("installing DNAT rules for ingress backends %+#v: %w", backendTargets, err)
|
||||
}
|
||||
// The backend might advertize MSS higher than that of the
|
||||
// tailscale interfaces. Clamp MSS of packets going out via
|
||||
// tailscale0 interface to its MTU to prevent broken connections
|
||||
// in environments where path MTU discovery is not working.
|
||||
if err := nfr.ClampMSSToPMTU("tailscale0", dst); err != nil {
|
||||
return fmt.Errorf("adding rule to clamp traffic via tailscale0: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(v4Backends) != 0 {
|
||||
if !tsv4.IsValid() {
|
||||
log.Printf("backend targets %v contain at least one IPv4 address, but this node's Tailscale IPs do not contain a valid IPv4 address: %v", backendAddrs, tsIPs)
|
||||
} else if err := updateFirewall(tsv4, v4Backends); err != nil {
|
||||
return fmt.Errorf("Installing IPv4 firewall rules: %w", err)
|
||||
}
|
||||
}
|
||||
if len(v6Backends) != 0 && !tsv6.IsValid() {
|
||||
if !tsv6.IsValid() {
|
||||
log.Printf("backend targets %v contain at least one IPv6 address, but this node's Tailscale IPs do not contain a valid IPv6 address: %v", backendAddrs, tsIPs)
|
||||
} else if !nfr.HasIPV6NAT() {
|
||||
log.Printf("backend targets %v contain at least one IPv6 address, but the chosen firewall mode does not support IPv6 NAT", backendAddrs)
|
||||
} else if err := updateFirewall(tsv6, v6Backends); err != nil {
|
||||
return fmt.Errorf("Installing IPv6 firewall rules: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// settings is all the configuration for containerboot.
|
||||
type settings struct {
|
||||
AuthKey string
|
||||
Hostname string
|
||||
Routes *string
|
||||
// ProxyTo is the destination IP to which all incoming
|
||||
// ProxyTargetIP is the destination IP to which all incoming
|
||||
// Tailscale traffic should be proxied. If empty, no proxying
|
||||
// is done. This is typically a locally reachable IP.
|
||||
ProxyTo string
|
||||
ProxyTargetIP string
|
||||
// ProxyTargetDNSName is a DNS name to whose backing IP addresses all
|
||||
// incoming Tailscale traffic should be proxied.
|
||||
ProxyTargetDNSName string
|
||||
// TailnetTargetIP is the destination IP to which all incoming
|
||||
// non-Tailscale traffic should be proxied. This is typically a
|
||||
// Tailscale IP.
|
||||
@@ -934,22 +1092,23 @@ type settings struct {
|
||||
// TailnetTargetFQDN is an MagicDNS name to which all incoming
|
||||
// non-Tailscale traffic should be proxied. This must be a full Tailnet
|
||||
// node FQDN.
|
||||
TailnetTargetFQDN string
|
||||
ServeConfigPath string
|
||||
DaemonExtraArgs string
|
||||
ExtraArgs string
|
||||
InKubernetes bool
|
||||
UserspaceMode bool
|
||||
StateDir string
|
||||
AcceptDNS *bool
|
||||
KubeSecret string
|
||||
SOCKSProxyAddr string
|
||||
HTTPProxyAddr string
|
||||
Socket string
|
||||
AuthOnce bool
|
||||
Root string
|
||||
KubernetesCanPatch bool
|
||||
TailscaledConfigFilePath string
|
||||
TailnetTargetFQDN string
|
||||
ServeConfigPath string
|
||||
DaemonExtraArgs string
|
||||
ExtraArgs string
|
||||
InKubernetes bool
|
||||
UserspaceMode bool
|
||||
StateDir string
|
||||
AcceptDNS *bool
|
||||
KubeSecret string
|
||||
SOCKSProxyAddr string
|
||||
HTTPProxyAddr string
|
||||
Socket string
|
||||
AuthOnce bool
|
||||
Root string
|
||||
KubernetesCanPatch bool
|
||||
TailscaledConfigFilePath string
|
||||
EnableForwardingOptimizations bool
|
||||
// If set to true and, if this containerboot instance is a Kubernetes
|
||||
// ingress proxy, set up rules to forward incoming cluster traffic to be
|
||||
// forwarded to the ingress target in cluster.
|
||||
@@ -962,13 +1121,26 @@ type settings struct {
|
||||
|
||||
func (s *settings) validate() error {
|
||||
if s.TailscaledConfigFilePath != "" {
|
||||
dir, file := path.Split(s.TailscaledConfigFilePath)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return fmt.Errorf("error validating whether directory with tailscaled config file %s exists: %w", dir, err)
|
||||
}
|
||||
if _, err := os.Stat(s.TailscaledConfigFilePath); err != nil {
|
||||
return fmt.Errorf("error validating whether tailscaled config directory %q contains tailscaled config for current capability version %q: %w. If this is a Tailscale Kubernetes operator proxy, please ensure that the version of the operator is not older than the version of the proxy", dir, file, err)
|
||||
}
|
||||
if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil {
|
||||
return fmt.Errorf("error validating tailscaled configfile contents: %w", err)
|
||||
}
|
||||
}
|
||||
if s.ProxyTo != "" && s.UserspaceMode {
|
||||
if s.ProxyTargetIP != "" && s.UserspaceMode {
|
||||
return errors.New("TS_DEST_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.ProxyTargetDNSName != "" && s.UserspaceMode {
|
||||
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME is not supported with TS_USERSPACE")
|
||||
}
|
||||
if s.ProxyTargetDNSName != "" && s.ProxyTargetIP != "" {
|
||||
return errors.New("TS_EXPERIMENTAL_DEST_DNS_NAME and TS_DEST_IP cannot both be set")
|
||||
}
|
||||
if s.TailnetTargetIP != "" && s.UserspaceMode {
|
||||
return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
@@ -979,7 +1151,7 @@ func (s *settings) validate() error {
|
||||
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
|
||||
}
|
||||
if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") {
|
||||
return errors.New("EXPERIMENTAL_TS_CONFIGFILE_PATH cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
|
||||
return errors.New("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
|
||||
}
|
||||
if s.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode {
|
||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode")
|
||||
@@ -990,9 +1162,34 @@ func (s *settings) validate() error {
|
||||
if s.AllowProxyingClusterTrafficViaIngress && s.PodIP == "" {
|
||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is set but POD_IP is not set")
|
||||
}
|
||||
if s.EnableForwardingOptimizations && s.UserspaceMode {
|
||||
return errors.New("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS is not supported in userspace mode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDNS(ctx context.Context, name string) ([]net.IP, error) {
|
||||
// TODO (irbekrm): look at using recursive.Resolver instead to resolve
|
||||
// the DNS names as well as retrieve TTLs. It looks though that this
|
||||
// seems to return very short TTLs (shorter than on the actual records).
|
||||
ip4s, err := net.DefaultResolver.LookupIP(ctx, "ip4", name)
|
||||
if err != nil {
|
||||
if e, ok := err.(*net.DNSError); !(ok && e.IsNotFound) {
|
||||
return nil, fmt.Errorf("error looking up IPv4 addresses: %v", err)
|
||||
}
|
||||
}
|
||||
ip6s, err := net.DefaultResolver.LookupIP(ctx, "ip6", name)
|
||||
if err != nil {
|
||||
if e, ok := err.(*net.DNSError); !(ok && e.IsNotFound) {
|
||||
return nil, fmt.Errorf("error looking up IPv6 addresses: %v", err)
|
||||
}
|
||||
}
|
||||
if len(ip4s) == 0 && len(ip6s) == 0 {
|
||||
return nil, fmt.Errorf("no IPv4 or IPv6 addresses found for host: %s", name)
|
||||
}
|
||||
return append(ip4s, ip6s...), nil
|
||||
}
|
||||
|
||||
// defaultEnv returns the value of the given envvar name, or defVal if
|
||||
// unset.
|
||||
func defaultEnv(name, defVal string) string {
|
||||
@@ -1089,3 +1286,42 @@ func isTwoStepConfigAlwaysAuth(cfg *settings) bool {
|
||||
func isOneStepConfig(cfg *settings) bool {
|
||||
return cfg.TailscaledConfigFilePath != ""
|
||||
}
|
||||
|
||||
// tailscaledConfigFilePath returns the path to the tailscaled config file that
|
||||
// should be used for the current capability version. It is determined by the
|
||||
// TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR environment variable and looks for a
|
||||
// file named cap-<capability_version>.hujson in the directory. It searches for
|
||||
// the highest capability version that is less than or equal to the current
|
||||
// capability version.
|
||||
func tailscaledConfigFilePath() string {
|
||||
dir := os.Getenv("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR")
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
fe, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
log.Fatalf("error reading tailscaled config directory %q: %v", dir, err)
|
||||
}
|
||||
maxCompatVer := tailcfg.CapabilityVersion(-1)
|
||||
for _, e := range fe {
|
||||
// We don't check if type if file as in most cases this will
|
||||
// come from a mounted kube Secret, where the directory contents
|
||||
// will be various symlinks.
|
||||
if e.Type().IsDir() {
|
||||
continue
|
||||
}
|
||||
cv, err := kubeutils.CapVerFromFileName(e.Name())
|
||||
if err != nil {
|
||||
log.Printf("skipping file %q in tailscaled config directory %q: %v", e.Name(), dir, err)
|
||||
continue
|
||||
}
|
||||
if cv > maxCompatVer && cv <= tailcfg.CurrentCapabilityVersion {
|
||||
maxCompatVer = cv
|
||||
}
|
||||
}
|
||||
if maxCompatVer == -1 {
|
||||
log.Fatalf("no tailscaled config file found in %q for current capability version %q", dir, tailcfg.CurrentCapabilityVersion)
|
||||
}
|
||||
log.Printf("Using tailscaled config file %q for capability version %q", maxCompatVer, tailcfg.CurrentCapabilityVersion)
|
||||
return path.Join(dir, kubeutils.TailscaledConfigFileNameForCap(maxCompatVer))
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
"dev/net",
|
||||
"proc/sys/net/ipv4",
|
||||
"proc/sys/net/ipv6/conf/all",
|
||||
"etc",
|
||||
"etc/tailscaled",
|
||||
}
|
||||
for _, path := range dirs {
|
||||
if err := os.MkdirAll(filepath.Join(d, path), 0700); err != nil {
|
||||
@@ -80,7 +80,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
"dev/net/tun": []byte(""),
|
||||
"proc/sys/net/ipv4/ip_forward": []byte("0"),
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": []byte("0"),
|
||||
"etc/tailscaled": tailscaledConfBytes,
|
||||
"etc/tailscaled/cap-95.hujson": tailscaledConfBytes,
|
||||
}
|
||||
resetFiles := func() {
|
||||
for path, content := range files {
|
||||
@@ -638,14 +638,14 @@ func TestContainerBoot(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "experimental tailscaled configfile",
|
||||
Name: "experimental tailscaled config path",
|
||||
Env: map[string]string{
|
||||
"EXPERIMENTAL_TS_CONFIGFILE_PATH": filepath.Join(d, "etc/tailscaled"),
|
||||
"TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR": filepath.Join(d, "etc/tailscaled/"),
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --config=/etc/tailscaled",
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --config=/etc/tailscaled/cap-95.hujson",
|
||||
},
|
||||
}, {
|
||||
Notify: runningNotify,
|
||||
|
||||
@@ -5,35 +5,45 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"expvar"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/slicesx"
|
||||
)
|
||||
|
||||
const refreshTimeout = time.Minute
|
||||
|
||||
type dnsEntryMap map[string][]net.IP
|
||||
type dnsEntryMap struct {
|
||||
IPs map[string][]net.IP
|
||||
Percent map[string]float64 // "foo.com" => 0.5 for 50%
|
||||
}
|
||||
|
||||
var (
|
||||
dnsCache syncs.AtomicValue[dnsEntryMap]
|
||||
dnsCache atomic.Pointer[dnsEntryMap]
|
||||
dnsCacheBytes syncs.AtomicValue[[]byte] // of JSON
|
||||
unpublishedDNSCache syncs.AtomicValue[dnsEntryMap]
|
||||
unpublishedDNSCache atomic.Pointer[dnsEntryMap]
|
||||
bootstrapLookupMap syncs.Map[string, bool]
|
||||
)
|
||||
|
||||
var (
|
||||
bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
|
||||
publishedDNSHits = expvar.NewInt("counter_bootstrap_dns_published_hits")
|
||||
publishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_published_misses")
|
||||
unpublishedDNSHits = expvar.NewInt("counter_bootstrap_dns_unpublished_hits")
|
||||
unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses")
|
||||
bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
|
||||
publishedDNSHits = expvar.NewInt("counter_bootstrap_dns_published_hits")
|
||||
publishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_published_misses")
|
||||
unpublishedDNSHits = expvar.NewInt("counter_bootstrap_dns_unpublished_hits")
|
||||
unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses")
|
||||
unpublishedDNSPercentMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_percent_misses")
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -59,15 +69,13 @@ func refreshBootstrapDNS() {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
|
||||
defer cancel()
|
||||
dnsEntries := resolveList(ctx, strings.Split(*bootstrapDNS, ","))
|
||||
dnsEntries := resolveList(ctx, *bootstrapDNS)
|
||||
// Randomize the order of the IPs for each name to avoid the client biasing
|
||||
// to IPv6
|
||||
for k := range dnsEntries {
|
||||
ips := dnsEntries[k]
|
||||
slicesx.Shuffle(ips)
|
||||
dnsEntries[k] = ips
|
||||
for _, vv := range dnsEntries.IPs {
|
||||
slicesx.Shuffle(vv)
|
||||
}
|
||||
j, err := json.MarshalIndent(dnsEntries, "", "\t")
|
||||
j, err := json.MarshalIndent(dnsEntries.IPs, "", "\t")
|
||||
if err != nil {
|
||||
// leave the old values in place
|
||||
return
|
||||
@@ -81,27 +89,50 @@ func refreshUnpublishedDNS() {
|
||||
if *unpublishedDNS == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
|
||||
defer cancel()
|
||||
|
||||
dnsEntries := resolveList(ctx, strings.Split(*unpublishedDNS, ","))
|
||||
dnsEntries := resolveList(ctx, *unpublishedDNS)
|
||||
unpublishedDNSCache.Store(dnsEntries)
|
||||
}
|
||||
|
||||
func resolveList(ctx context.Context, names []string) dnsEntryMap {
|
||||
dnsEntries := make(dnsEntryMap)
|
||||
// resolveList takes a comma-separated list of DNS names to resolve.
|
||||
//
|
||||
// If an entry contains a slash, it's two DNS names: the first is the one to
|
||||
// resolve and the second is that of a TXT recording containing the rollout
|
||||
// percentage in range "0".."100". If the TXT record doesn't exist or is
|
||||
// malformed, the percentage is 0. If the TXT record is not provided (there's no
|
||||
// slash), then the percentage is 100.
|
||||
func resolveList(ctx context.Context, list string) *dnsEntryMap {
|
||||
ents := strings.Split(list, ",")
|
||||
|
||||
ret := &dnsEntryMap{}
|
||||
|
||||
var r net.Resolver
|
||||
for _, name := range names {
|
||||
for _, ent := range ents {
|
||||
name, txtName, _ := strings.Cut(ent, "/")
|
||||
addrs, err := r.LookupIP(ctx, "ip", name)
|
||||
if err != nil {
|
||||
log.Printf("bootstrap DNS lookup %q: %v", name, err)
|
||||
continue
|
||||
}
|
||||
dnsEntries[name] = addrs
|
||||
mak.Set(&ret.IPs, name, addrs)
|
||||
|
||||
if txtName == "" {
|
||||
mak.Set(&ret.Percent, name, 1.0)
|
||||
continue
|
||||
}
|
||||
vals, err := r.LookupTXT(ctx, txtName)
|
||||
if err != nil {
|
||||
log.Printf("bootstrap DNS lookup %q: %v", txtName, err)
|
||||
continue
|
||||
}
|
||||
for _, v := range vals {
|
||||
if v, err := strconv.Atoi(v); err == nil && v >= 0 && v <= 100 {
|
||||
mak.Set(&ret.Percent, name, float64(v)/100)
|
||||
}
|
||||
}
|
||||
}
|
||||
return dnsEntries
|
||||
return ret
|
||||
}
|
||||
|
||||
func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -115,22 +146,36 @@ func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
|
||||
// Try answering a query from our hidden map first
|
||||
if q := r.URL.Query().Get("q"); q != "" {
|
||||
bootstrapLookupMap.Store(q, true)
|
||||
if ips, ok := unpublishedDNSCache.Load()[q]; ok && len(ips) > 0 {
|
||||
if bootstrapLookupMap.Len() > 500 { // defensive
|
||||
bootstrapLookupMap.Clear()
|
||||
}
|
||||
if m := unpublishedDNSCache.Load(); m != nil && len(m.IPs[q]) > 0 {
|
||||
unpublishedDNSHits.Add(1)
|
||||
|
||||
// Only return the specific query, not everything.
|
||||
m := dnsEntryMap{q: ips}
|
||||
j, err := json.MarshalIndent(m, "", "\t")
|
||||
if err == nil {
|
||||
w.Write(j)
|
||||
return
|
||||
percent := m.Percent[q]
|
||||
if remoteAddrMatchesPercent(r.RemoteAddr, percent) {
|
||||
// Only return the specific query, not everything.
|
||||
m := map[string][]net.IP{q: m.IPs[q]}
|
||||
j, err := json.MarshalIndent(m, "", "\t")
|
||||
if err == nil {
|
||||
w.Write(j)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
unpublishedDNSPercentMisses.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a "q" query for a name in the published cache
|
||||
// list, then track whether that's a hit/miss.
|
||||
if m, ok := dnsCache.Load()[q]; ok {
|
||||
if len(m) > 0 {
|
||||
m := dnsCache.Load()
|
||||
var inPub bool
|
||||
var ips []net.IP
|
||||
if m != nil {
|
||||
ips, inPub = m.IPs[q]
|
||||
}
|
||||
if inPub {
|
||||
if len(ips) > 0 {
|
||||
publishedDNSHits.Add(1)
|
||||
} else {
|
||||
publishedDNSMisses.Add(1)
|
||||
@@ -146,3 +191,29 @@ func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
|
||||
j := dnsCacheBytes.Load()
|
||||
w.Write(j)
|
||||
}
|
||||
|
||||
// percent is [0.0, 1.0].
|
||||
func remoteAddrMatchesPercent(remoteAddr string, percent float64) bool {
|
||||
if percent == 0 {
|
||||
return false
|
||||
}
|
||||
if percent == 1 {
|
||||
return true
|
||||
}
|
||||
reqIPStr, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
reqIP, err := netip.ParseAddr(reqIPStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if reqIP.IsLoopback() {
|
||||
// For local testing.
|
||||
return rand.Float64() < 0.5
|
||||
}
|
||||
reqIP16 := reqIP.As16()
|
||||
rndSrc := rand.NewPCG(binary.LittleEndian.Uint64(reqIP16[:8]), binary.LittleEndian.Uint64(reqIP16[8:]))
|
||||
rnd := rand.New(rndSrc)
|
||||
return percent > rnd.Float64()
|
||||
}
|
||||
|
||||
@@ -4,15 +4,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/tstest/nettest"
|
||||
)
|
||||
|
||||
func BenchmarkHandleBootstrapDNS(b *testing.B) {
|
||||
@@ -37,7 +41,7 @@ func (b *bitbucketResponseWriter) Write(p []byte) (int, error) { return len(p),
|
||||
|
||||
func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {}
|
||||
|
||||
func getBootstrapDNS(t *testing.T, q string) dnsEntryMap {
|
||||
func getBootstrapDNS(t *testing.T, q string) map[string][]net.IP {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -47,14 +51,17 @@ func getBootstrapDNS(t *testing.T, q string) dnsEntryMap {
|
||||
if res.StatusCode != 200 {
|
||||
t.Fatalf("got status=%d; want %d", res.StatusCode, 200)
|
||||
}
|
||||
var ips dnsEntryMap
|
||||
if err := json.NewDecoder(res.Body).Decode(&ips); err != nil {
|
||||
t.Fatalf("error decoding response body: %v", err)
|
||||
var m map[string][]net.IP
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewDecoder(io.TeeReader(res.Body, &buf)).Decode(&m); err != nil {
|
||||
t.Fatalf("error decoding response body %q: %v", buf.Bytes(), err)
|
||||
}
|
||||
return ips
|
||||
return m
|
||||
}
|
||||
|
||||
func TestUnpublishedDNS(t *testing.T) {
|
||||
nettest.SkipIfNoNetwork(t)
|
||||
|
||||
const published = "login.tailscale.com"
|
||||
const unpublished = "log.tailscale.io"
|
||||
|
||||
@@ -104,15 +111,21 @@ func resetMetrics() {
|
||||
// Verify that we don't count an empty list in the unpublishedDNSCache as a
|
||||
// cache hit in our metrics.
|
||||
func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
pub := dnsEntryMap{
|
||||
"tailscale.com": {net.IPv4(10, 10, 10, 10)},
|
||||
pub := &dnsEntryMap{
|
||||
IPs: map[string][]net.IP{"tailscale.com": {net.IPv4(10, 10, 10, 10)}},
|
||||
}
|
||||
dnsCache.Store(pub)
|
||||
dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`))
|
||||
|
||||
unpublishedDNSCache.Store(dnsEntryMap{
|
||||
"log.tailscale.io": {},
|
||||
"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)},
|
||||
unpublishedDNSCache.Store(&dnsEntryMap{
|
||||
IPs: map[string][]net.IP{
|
||||
"log.tailscale.io": {},
|
||||
"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)},
|
||||
},
|
||||
Percent: map[string]float64{
|
||||
"log.tailscale.io": 1.0,
|
||||
"controlplane.tailscale.com": 1.0,
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("CacheMiss", func(t *testing.T) {
|
||||
@@ -122,8 +135,8 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
ips := getBootstrapDNS(t, q)
|
||||
|
||||
// Expected our public map to be returned on a cache miss
|
||||
if !reflect.DeepEqual(ips, pub) {
|
||||
t.Errorf("got ips=%+v; want %+v", ips, pub)
|
||||
if !reflect.DeepEqual(ips, pub.IPs) {
|
||||
t.Errorf("got ips=%+v; want %+v", ips, pub.IPs)
|
||||
}
|
||||
if v := unpublishedDNSHits.Value(); v != 0 {
|
||||
t.Errorf("got hits=%d; want 0", v)
|
||||
@@ -138,7 +151,7 @@ func TestUnpublishedDNSEmptyList(t *testing.T) {
|
||||
t.Run("CacheHit", func(t *testing.T) {
|
||||
resetMetrics()
|
||||
ips := getBootstrapDNS(t, "controlplane.tailscale.com")
|
||||
want := dnsEntryMap{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
|
||||
want := map[string][]net.IP{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
|
||||
if !reflect.DeepEqual(ips, want) {
|
||||
t.Errorf("got ips=%+v; want %+v", ips, want)
|
||||
}
|
||||
@@ -163,3 +176,54 @@ func TestLookupMetric(t *testing.T) {
|
||||
t.Errorf("bootstrapLookupMap.Len() want=5, got %v", bootstrapLookupMap.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteAddrMatchesPercent(t *testing.T) {
|
||||
tests := []struct {
|
||||
remoteAddr string
|
||||
percent float64
|
||||
want bool
|
||||
}{
|
||||
// 0% and 100%.
|
||||
{"10.0.0.1:1234", 0.0, false},
|
||||
{"10.0.0.1:1234", 1.0, true},
|
||||
|
||||
// Invalid IP.
|
||||
{"", 1.0, true},
|
||||
{"", 0.0, false},
|
||||
{"", 0.5, false},
|
||||
|
||||
// Small manual sample at 50%. The func uses a deterministic PRNG seed.
|
||||
{"1.2.3.4:567", 0.5, true},
|
||||
{"1.2.3.5:567", 0.5, true},
|
||||
{"1.2.3.6:567", 0.5, false},
|
||||
{"1.2.3.7:567", 0.5, true},
|
||||
{"1.2.3.8:567", 0.5, false},
|
||||
{"1.2.3.9:567", 0.5, true},
|
||||
{"1.2.3.10:567", 0.5, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := remoteAddrMatchesPercent(tt.remoteAddr, tt.percent)
|
||||
if got != tt.want {
|
||||
t.Errorf("remoteAddrMatchesPercent(%q, %v) = %v; want %v", tt.remoteAddr, tt.percent, got, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
var match, all int
|
||||
const wantPercent = 0.5
|
||||
for a := range 256 {
|
||||
for b := range 256 {
|
||||
all++
|
||||
if remoteAddrMatchesPercent(
|
||||
netip.AddrPortFrom(netip.AddrFrom4([4]byte{1, 2, byte(a), byte(b)}), 12345).String(),
|
||||
wantPercent) {
|
||||
match++
|
||||
}
|
||||
}
|
||||
}
|
||||
gotPercent := float64(match) / float64(all)
|
||||
const tolerance = 0.005
|
||||
t.Logf("got percent %v (goal %v)", gotPercent, wantPercent)
|
||||
if gotPercent < wantPercent-tolerance || gotPercent > wantPercent+tolerance {
|
||||
t.Errorf("got %v; want %v ± %v", gotPercent, wantPercent, tolerance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
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/tsweb
|
||||
github.com/google/uuid from tailscale.com/util/fastuuid
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
||||
L github.com/josharian/native from github.com/mdlayher/netlink+
|
||||
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
|
||||
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
|
||||
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
|
||||
L 💣 github.com/mdlayher/netlink from github.com/google/nftables+
|
||||
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
|
||||
@@ -47,13 +47,14 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
github.com/x448/float16 from github.com/fxamacker/cbor/v2
|
||||
💣 go4.org/mem from tailscale.com/client/tailscale+
|
||||
go4.org/netipx from tailscale.com/net/tsaddr+
|
||||
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
|
||||
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/netmon+
|
||||
google.golang.org/protobuf/encoding/protodelim from github.com/prometheus/common/expfmt
|
||||
google.golang.org/protobuf/encoding/prototext from github.com/prometheus/common/expfmt+
|
||||
google.golang.org/protobuf/encoding/protowire from google.golang.org/protobuf/encoding/protodelim+
|
||||
google.golang.org/protobuf/internal/descfmt from google.golang.org/protobuf/internal/filedesc
|
||||
google.golang.org/protobuf/internal/descopts from google.golang.org/protobuf/internal/filedesc+
|
||||
google.golang.org/protobuf/internal/detrand from google.golang.org/protobuf/internal/descfmt+
|
||||
google.golang.org/protobuf/internal/editiondefaults from google.golang.org/protobuf/internal/filedesc
|
||||
google.golang.org/protobuf/internal/encoding/defval from google.golang.org/protobuf/internal/encoding/tag+
|
||||
google.golang.org/protobuf/internal/encoding/messageset from google.golang.org/protobuf/encoding/prototext+
|
||||
google.golang.org/protobuf/internal/encoding/tag from google.golang.org/protobuf/internal/impl
|
||||
@@ -86,19 +87,19 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/derp from tailscale.com/cmd/derper+
|
||||
tailscale.com/derp/derphttp from tailscale.com/cmd/derper
|
||||
tailscale.com/disco from tailscale.com/derp
|
||||
tailscale.com/drive from tailscale.com/client/tailscale+
|
||||
tailscale.com/envknob from tailscale.com/client/tailscale+
|
||||
tailscale.com/health from tailscale.com/net/tlsdial
|
||||
tailscale.com/hostinfo from tailscale.com/net/interfaces+
|
||||
tailscale.com/health from tailscale.com/net/tlsdial+
|
||||
tailscale.com/hostinfo from tailscale.com/net/netmon+
|
||||
tailscale.com/ipn from tailscale.com/client/tailscale
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
|
||||
tailscale.com/metrics from tailscale.com/cmd/derper+
|
||||
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/flowtrack from tailscale.com/net/packet+
|
||||
💣 tailscale.com/net/interfaces from tailscale.com/net/netmon+
|
||||
tailscale.com/net/ktimeout from tailscale.com/cmd/derper
|
||||
tailscale.com/net/netaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/netknob from tailscale.com/net/netns
|
||||
tailscale.com/net/netmon from tailscale.com/derp/derphttp+
|
||||
💣 tailscale.com/net/netmon from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/netns from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/netutil from tailscale.com/client/tailscale
|
||||
tailscale.com/net/packet from tailscale.com/wgengine/filter
|
||||
@@ -114,9 +115,8 @@ 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/tka from tailscale.com/client/tailscale+
|
||||
W tailscale.com/tsconst from tailscale.com/net/interfaces
|
||||
W tailscale.com/tsconst from tailscale.com/net/netmon
|
||||
tailscale.com/tstime from tailscale.com/derp+
|
||||
tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||
tailscale.com/tstime/rate from tailscale.com/derp+
|
||||
@@ -137,16 +137,18 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/types/structs from tailscale.com/ipn+
|
||||
tailscale.com/types/tkatype from tailscale.com/client/tailscale+
|
||||
tailscale.com/types/views from tailscale.com/ipn+
|
||||
tailscale.com/util/cibuild from tailscale.com/health
|
||||
tailscale.com/util/clientmetric from tailscale.com/net/netmon+
|
||||
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
|
||||
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
|
||||
tailscale.com/util/ctxkey from tailscale.com/tsweb+
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/hostinfo+
|
||||
tailscale.com/util/fastuuid from tailscale.com/tsweb
|
||||
tailscale.com/util/httpm from tailscale.com/client/tailscale
|
||||
tailscale.com/util/lineread from tailscale.com/hostinfo+
|
||||
L tailscale.com/util/linuxfw from tailscale.com/net/netns
|
||||
tailscale.com/util/mak from tailscale.com/net/interfaces+
|
||||
tailscale.com/util/mak from tailscale.com/health+
|
||||
tailscale.com/util/multierr from tailscale.com/health+
|
||||
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
|
||||
tailscale.com/util/set from tailscale.com/derp+
|
||||
@@ -155,6 +157,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/util/syspolicy from tailscale.com/ipn
|
||||
tailscale.com/util/vizerror from tailscale.com/tailcfg+
|
||||
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
tailscale.com/version/distro from tailscale.com/envknob+
|
||||
tailscale.com/wgengine/filter from tailscale.com/types/netmap
|
||||
@@ -172,6 +175,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
W golang.org/x/exp/constraints from tailscale.com/util/winutil
|
||||
L golang.org/x/net/bpf from github.com/mdlayher/netlink+
|
||||
golang.org/x/net/dns/dnsmessage from net+
|
||||
golang.org/x/net/http/httpguts from net/http
|
||||
@@ -232,7 +236,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
encoding/pem from crypto/tls+
|
||||
errors from bufio+
|
||||
expvar from github.com/prometheus/client_golang/prometheus+
|
||||
flag from tailscale.com/cmd/derper+
|
||||
flag from tailscale.com/cmd/derper
|
||||
fmt from compress/flate+
|
||||
go/token from google.golang.org/protobuf/internal/strs
|
||||
hash from crypto+
|
||||
@@ -250,6 +254,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
math/big from crypto/dsa+
|
||||
math/bits from compress/flate+
|
||||
math/rand from github.com/mdlayher/netlink+
|
||||
math/rand/v2 from tailscale.com/util/fastuuid+
|
||||
mime from github.com/prometheus/common/expfmt+
|
||||
mime/multipart from net/http
|
||||
mime/quotedprintable from mime/multipart
|
||||
@@ -273,7 +278,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
runtime/debug from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/metrics from github.com/prometheus/client_golang/prometheus+
|
||||
runtime/pprof from net/http/pprof
|
||||
runtime/trace from net/http/pprof+
|
||||
runtime/trace from net/http/pprof
|
||||
slices from tailscale.com/ipn/ipnstate+
|
||||
sort from compress/flate+
|
||||
strconv from compress/flate+
|
||||
@@ -281,7 +286,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
sync from compress/flate+
|
||||
sync/atomic from context+
|
||||
syscall from crypto/rand+
|
||||
testing from tailscale.com/util/syspolicy
|
||||
text/tabwriter from runtime/pprof
|
||||
time from compress/gzip+
|
||||
unicode from bytes+
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
"golang.org/x/time/rate"
|
||||
"tailscale.com/atomicfile"
|
||||
"tailscale.com/derp"
|
||||
@@ -36,24 +35,27 @@ import (
|
||||
"tailscale.com/net/stunserver"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
var (
|
||||
dev = flag.Bool("dev", false, "run in localhost development mode (overrides -a)")
|
||||
addr = flag.String("a", ":443", "server HTTP/HTTPS listen address, in form \":port\", \"ip:port\", or for IPv6 \"[ip]:port\". If the IP is omitted, it defaults to all interfaces. Serves HTTPS if the port is 443 and/or -certmode is manual, otherwise HTTP.")
|
||||
httpPort = flag.Int("http-port", 80, "The port on which to serve HTTP. Set to -1 to disable. The listener is bound to the same IP (if any) as specified in the -a flag.")
|
||||
stunPort = flag.Int("stun-port", 3478, "The UDP port on which to serve STUN. The listener is bound to the same IP (if any) as specified in the -a flag.")
|
||||
configPath = flag.String("c", "", "config file path")
|
||||
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt")
|
||||
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443")
|
||||
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443")
|
||||
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
|
||||
runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.")
|
||||
dev = flag.Bool("dev", false, "run in localhost development mode (overrides -a)")
|
||||
versionFlag = flag.Bool("version", false, "print version and exit")
|
||||
addr = flag.String("a", ":443", "server HTTP/HTTPS listen address, in form \":port\", \"ip:port\", or for IPv6 \"[ip]:port\". If the IP is omitted, it defaults to all interfaces. Serves HTTPS if the port is 443 and/or -certmode is manual, otherwise HTTP.")
|
||||
httpPort = flag.Int("http-port", 80, "The port on which to serve HTTP. Set to -1 to disable. The listener is bound to the same IP (if any) as specified in the -a flag.")
|
||||
stunPort = flag.Int("stun-port", 3478, "The UDP port on which to serve STUN. The listener is bound to the same IP (if any) as specified in the -a flag.")
|
||||
configPath = flag.String("c", "", "config file path")
|
||||
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt")
|
||||
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443")
|
||||
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443")
|
||||
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
|
||||
runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.")
|
||||
|
||||
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
|
||||
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list")
|
||||
bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns")
|
||||
unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list")
|
||||
unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list. If an entry contains a slash, the second part names a DNS record to poll for its TXT record with a `0` to `100` value for rollout percentage.")
|
||||
verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.")
|
||||
verifyClientURL = flag.String("verify-client-url", "", "if non-empty, an admission controller URL for permitting client connections; see tailcfg.DERPAdmitClientRequest")
|
||||
verifyFailOpen = flag.Bool("verify-client-url-fail-open", true, "whether we fail open if --verify-client-url is unreachable")
|
||||
@@ -129,6 +131,10 @@ func writeNewConfig() config {
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *versionFlag {
|
||||
fmt.Println(version.Long())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
@@ -185,7 +191,12 @@ func main() {
|
||||
http.Error(w, "derp server disabled", http.StatusNotFound)
|
||||
}))
|
||||
}
|
||||
mux.HandleFunc("/derp/probe", probeHandler)
|
||||
|
||||
// These two endpoints are the same. Different versions of the clients
|
||||
// have assumes different paths over time so we support both.
|
||||
mux.HandleFunc("/derp/probe", derphttp.ProbeHandler)
|
||||
mux.HandleFunc("/derp/latency-check", derphttp.ProbeHandler)
|
||||
|
||||
go refreshBootstrapDNSLoop()
|
||||
mux.HandleFunc("/bootstrap-dns", tsweb.BrowserHeaderHandlerFunc(handleBootstrapDNS))
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -235,7 +246,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,
|
||||
@@ -364,17 +375,6 @@ func isChallengeChar(c rune) bool {
|
||||
c == '.' || c == '-' || c == '_'
|
||||
}
|
||||
|
||||
// probeHandler is the endpoint that js/wasm clients hit to measure
|
||||
// DERP latency, since they can't do UDP STUN queries.
|
||||
func probeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "HEAD", "GET":
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
default:
|
||||
http.Error(w, "bogus probe method", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
|
||||
|
||||
func prodAutocertHostPolicy(_ context.Context, host string) error {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ func TestNoContent(t *testing.T) {
|
||||
func TestDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
BadDeps: map[string]string{
|
||||
"testing": "do not use testing package in production code",
|
||||
"gvisor.dev/gvisor/pkg/buffer": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/derp/derphttp"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
@@ -36,7 +37,8 @@ func startMesh(s *derp.Server) error {
|
||||
|
||||
func startMeshWithHost(s *derp.Server, host string) error {
|
||||
logf := logger.WithPrefix(log.Printf, fmt.Sprintf("mesh(%q): ", host))
|
||||
c, err := derphttp.NewClient(s.PrivateKey(), "https://"+host+"/derp", logf)
|
||||
netMon := netmon.NewStatic() // good enough for cmd/derper; no need for netns fanciness
|
||||
c, err := derphttp.NewClient(s.PrivateKey(), "https://"+host+"/derp", logf, netMon)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,21 +16,40 @@ import (
|
||||
|
||||
"tailscale.com/prober"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
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://) or 'local' to use the local tailscaled's DERP map")
|
||||
versionFlag = flag.Bool("version", false, "print version and exit")
|
||||
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()
|
||||
if *versionFlag {
|
||||
fmt.Println(version.Long())
|
||||
return
|
||||
}
|
||||
|
||||
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 +72,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))
|
||||
}
|
||||
|
||||
|
||||
13
cmd/dist/dist.go
vendored
13
cmd/dist/dist.go
vendored
@@ -13,11 +13,16 @@ import (
|
||||
|
||||
"tailscale.com/release/dist"
|
||||
"tailscale.com/release/dist/cli"
|
||||
"tailscale.com/release/dist/qnap"
|
||||
"tailscale.com/release/dist/synology"
|
||||
"tailscale.com/release/dist/unixpkgs"
|
||||
)
|
||||
|
||||
var synologyPackageCenter bool
|
||||
var (
|
||||
synologyPackageCenter bool
|
||||
qnapPrivateKeyPath string
|
||||
qnapCertificatePath string
|
||||
)
|
||||
|
||||
func getTargets() ([]dist.Target, error) {
|
||||
var ret []dist.Target
|
||||
@@ -37,6 +42,10 @@ func getTargets() ([]dist.Target, error) {
|
||||
// To build for package center, run
|
||||
// ./tool/go run ./cmd/dist build --synology-package-center synology
|
||||
ret = append(ret, synology.Targets(synologyPackageCenter, nil)...)
|
||||
if (qnapPrivateKeyPath == "") != (qnapCertificatePath == "") {
|
||||
return nil, errors.New("both --qnap-private-key-path and --qnap-certificate-path must be set")
|
||||
}
|
||||
ret = append(ret, qnap.Targets(qnapPrivateKeyPath, qnapCertificatePath)...)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
@@ -45,6 +54,8 @@ func main() {
|
||||
for _, subcmd := range cmd.Subcommands {
|
||||
if subcmd.Name == "build" {
|
||||
subcmd.FlagSet.BoolVar(&synologyPackageCenter, "synology-package-center", false, "build synology packages with extra metadata for the official package center")
|
||||
subcmd.FlagSet.StringVar(&qnapPrivateKeyPath, "qnap-private-key-path", "", "sign qnap packages with given key (must also provide --qnap-certificate-path)")
|
||||
subcmd.FlagSet.StringVar(&qnapCertificatePath, "qnap-certificate-path", "", "sign qnap packages with given certificate (must also provide --qnap-private-key-path)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
379
cmd/k8s-nameserver/main.go
Normal file
379
cmd/k8s-nameserver/main.go
Normal file
@@ -0,0 +1,379 @@
|
||||
// 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"
|
||||
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", 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")
|
||||
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): TTL is currently set to 0, meaning
|
||||
// that cluster workloads will not cache the DNS
|
||||
// records. Revisit this in future when we understand
|
||||
// the usage patterns better- is it putting too much
|
||||
// load on kube DNS server or is this fine?
|
||||
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): add IPv6 support.
|
||||
// The nameserver currently does not support IPv6
|
||||
// (records are not being created for IPv6 Pod addresses).
|
||||
// However, we can expect that some callers will
|
||||
// nevertheless send AAAA queries.
|
||||
// We have to return NOERROR if a query is received for
|
||||
// an AAAA record for a DNS name that we have an A
|
||||
// record for- else the caller might not follow with an
|
||||
// A record query.
|
||||
// https://github.com/tailscale/tailscale/issues/12321
|
||||
// https://datatracker.ietf.org/doc/html/rfc4074
|
||||
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
|
||||
ips := n.lookupIP4(fqdn)
|
||||
if 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
|
||||
}
|
||||
m.SetRcode(r, dns.RcodeSuccess)
|
||||
default:
|
||||
log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s", 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...")
|
||||
if err := n.resetRecords(); err != nil { // ensure records are up to date before the nameserver starts
|
||||
log.Fatalf("error setting nameserver's records: %v", err)
|
||||
}
|
||||
log.Print("nameserver's records were updated")
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("context cancelled, exiting records reconciler")
|
||||
return
|
||||
case <-n.configWatcher:
|
||||
log.Print("configuration update detected, resetting records")
|
||||
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", err)
|
||||
}
|
||||
log.Print("nameserver records were reset")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 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", err)
|
||||
return err
|
||||
}
|
||||
if dnsCfgBytes == nil || len(dnsCfgBytes) < 1 {
|
||||
log.Print("nameserver's configuration is empty, any in-memory records will be unset")
|
||||
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 len(dnsCfg.IP4) == 0 {
|
||||
log.Print("nameserver's configuration contains no records, any in-memory records will be unset")
|
||||
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", 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", 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", net)
|
||||
s.Shutdown()
|
||||
}()
|
||||
log.Printf("listening for %s queries on %s", net, addr)
|
||||
if err := s.ListenAndServe(); err != nil {
|
||||
log.Fatalf("error running %s server: %v", 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", 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", defaultDNSConfigDir)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Print("context cancelled, exiting ConfigMap watcher")
|
||||
return
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
log.Fatal("watcher finished; exiting")
|
||||
}
|
||||
if event.Name == toWatch {
|
||||
msg := fmt.Sprintf("ConfigMap update received: %s", event)
|
||||
log.Print(msg)
|
||||
c <- msg
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
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", err)
|
||||
}
|
||||
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] errors watcher exited")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err = watcher.Add(defaultDNSConfigDir); err != nil {
|
||||
log.Fatalf("failed setting up a watcher for the mounted ConfigMap: %v", 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
|
||||
// records.json file in a ConfigMap mounted at /config.
|
||||
var configMapConfigReader configReaderFunc = func() ([]byte, error) {
|
||||
if contents, err := os.ReadFile(filepath.Join(defaultDNSConfigDir, operatorutils.DNSRecordsCMKey)); 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
|
||||
}
|
||||
229
cmd/k8s-nameserver/main_test.go
Normal file
229
cmd/k8s-nameserver/main_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// 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, A 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.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.RcodeSuccess,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
Authoritative: true,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "AAAA record query, A 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.TypeAAAA}},
|
||||
MsgHdr: dns.MsgHdr{Id: 1},
|
||||
},
|
||||
wantResp: &dns.Msg{
|
||||
Question: []dns.Question{{Name: "baz.bar.com", Qtype: dns.TypeAAAA}},
|
||||
MsgHdr: dns.MsgHdr{
|
||||
Id: 1,
|
||||
Rcode: dns.RcodeNameError,
|
||||
Response: true,
|
||||
Opcode: dns.OpcodeQuery,
|
||||
Authoritative: true,
|
||||
}},
|
||||
},
|
||||
{
|
||||
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() {}
|
||||
@@ -108,7 +108,7 @@ func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Reque
|
||||
}
|
||||
|
||||
oldCnStatus := cn.Status.DeepCopy()
|
||||
setStatus := func(cn *tsapi.Connector, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
setStatus := func(cn *tsapi.Connector, _ tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
tsoperator.SetConnectorCondition(cn, tsapi.ConnectorReady, status, reason, message, cn.Generation, a.clock, logger)
|
||||
if !apiequality.Semantic.DeepEqual(oldCnStatus, cn.Status) {
|
||||
// An error encountered here should get returned by the Reconcile function.
|
||||
@@ -184,7 +184,7 @@ func (a *ConnectorReconciler) maybeProvisionConnector(ctx context.Context, logge
|
||||
Connector: &connector{
|
||||
isExitNode: cn.Spec.ExitNode,
|
||||
},
|
||||
ProxyClass: proxyClass,
|
||||
ProxyClassName: proxyClass,
|
||||
}
|
||||
|
||||
if cn.Spec.SubnetRouter != nil && len(cn.Spec.SubnetRouter.AdvertiseRoutes) > 0 {
|
||||
@@ -211,7 +211,27 @@ func (a *ConnectorReconciler) maybeProvisionConnector(ctx context.Context, logge
|
||||
gaugeConnectorResources.Set(int64(connectors.Len()))
|
||||
|
||||
_, err := a.ssr.Provision(ctx, logger, sts)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, tsHost, ips, err := a.ssr.DeviceInfo(ctx, crl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tsHost == "" {
|
||||
logger.Debugf("no Tailscale hostname known yet, waiting for connector pod to finish auth")
|
||||
// No hostname yet. Wait for the connector pod to auth.
|
||||
cn.Status.TailnetIPs = nil
|
||||
cn.Status.Hostname = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
cn.Status.TailnetIPs = ips
|
||||
cn.Status.Hostname = tsHost
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ConnectorReconciler) maybeCleanupConnector(ctx context.Context, logger *zap.SugaredLogger, cn *tsapi.Connector) (bool, error) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
func TestConnector(t *testing.T) {
|
||||
@@ -29,7 +30,7 @@ func TestConnector(t *testing.T) {
|
||||
},
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: tsapi.ConnectorKind,
|
||||
APIVersion: "tailscale.io/v1alpha1",
|
||||
APIVersion: "tailscale.com/v1alpha1",
|
||||
},
|
||||
Spec: tsapi.ConnectorSpec{
|
||||
SubnetRouter: &tsapi.SubnetRouter{
|
||||
@@ -73,38 +74,51 @@ func TestConnector(t *testing.T) {
|
||||
hostname: "test-connector",
|
||||
isExitNode: true,
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
confFileHash: "9321660203effb80983eaecc7b5ac5a8c53934926f46e895b9fe295dcfc5a904",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// Connector status should get updated with the IP/hostname info when available.
|
||||
const hostname = "foo.tailnetxyz.ts.net"
|
||||
mustUpdate(t, fc, "operator-ns", opts.secretName, func(secret *corev1.Secret) {
|
||||
mak.Set(&secret.Data, "device_id", []byte("1234"))
|
||||
mak.Set(&secret.Data, "device_fqdn", []byte(hostname))
|
||||
mak.Set(&secret.Data, "device_ips", []byte(`["127.0.0.1", "::1"]`))
|
||||
})
|
||||
expectReconciled(t, cr, "", "test")
|
||||
cn.Finalizers = append(cn.Finalizers, "tailscale.com/finalizer")
|
||||
cn.Status.IsExitNode = cn.Spec.ExitNode
|
||||
cn.Status.SubnetRoutes = cn.Spec.SubnetRouter.AdvertiseRoutes.Stringify()
|
||||
cn.Status.Hostname = hostname
|
||||
cn.Status.TailnetIPs = []string{"127.0.0.1", "::1"}
|
||||
expectEqual(t, fc, cn, func(o *tsapi.Connector) {
|
||||
o.Status.Conditions = nil
|
||||
})
|
||||
|
||||
// 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) {
|
||||
@@ -113,9 +127,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 {
|
||||
@@ -156,19 +169,17 @@ func TestConnector(t *testing.T) {
|
||||
parentType: "connector",
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
hostname: "test-connector",
|
||||
confFileHash: "57d922331890c9b1c8c6ae664394cb254334c551d9cd9db14537b5d9da9fb17e",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, fc, 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 {
|
||||
@@ -243,10 +254,9 @@ func TestConnectorWithProxyClass(t *testing.T) {
|
||||
hostname: "test-connector",
|
||||
isExitNode: true,
|
||||
subnetRoutes: "10.40.0.0/14",
|
||||
confFileHash: "9321660203effb80983eaecc7b5ac5a8c53934926f46e895b9fe295dcfc5a904",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, fc, 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
|
||||
@@ -255,7 +265,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
|
||||
@@ -269,12 +279,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
|
||||
@@ -284,5 +290,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)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ spec:
|
||||
{{- end }}
|
||||
labels:
|
||||
app: operator
|
||||
{{- with .Values.operatorConfig.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
@@ -46,7 +49,7 @@ spec:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- $operatorTag:= printf ":%s" ( .Values.operatorConfig.image.tag | default .Chart.AppVersion )}}
|
||||
image: {{ .Values.operatorConfig.image.repo }}{{- if .Values.operatorConfig.image.digest -}}{{ printf "@%s" .Values.operatorConfig.image.digest}}{{- else -}}{{ printf "%s" $operatorTag }}{{- end }}
|
||||
image: {{ coalesce .Values.operatorConfig.image.repo .Values.operatorConfig.image.repository }}{{- if .Values.operatorConfig.image.digest -}}{{ printf "@%s" .Values.operatorConfig.image.digest}}{{- else -}}{{ printf "%s" $operatorTag }}{{- end }}
|
||||
imagePullPolicy: {{ .Values.operatorConfig.image.pullPolicy }}
|
||||
env:
|
||||
- name: OPERATOR_INITIAL_TAGS
|
||||
@@ -67,7 +70,7 @@ spec:
|
||||
value: /oauth/client_secret
|
||||
{{- $proxyTag := printf ":%s" ( .Values.proxyConfig.image.tag | default .Chart.AppVersion )}}
|
||||
- name: PROXY_IMAGE
|
||||
value: {{ .Values.proxyConfig.image.repo }}{{- if .Values.proxyConfig.image.digest -}}{{ printf "@%s" .Values.proxyConfig.image.digest}}{{- else -}}{{ printf "%s" $proxyTag }}{{- end }}
|
||||
value: {{ coalesce .Values.proxyConfig.image.repo .Values.proxyConfig.image.repository }}{{- if .Values.proxyConfig.image.digest -}}{{ printf "@%s" .Values.proxyConfig.image.digest}}{{- else -}}{{ printf "%s" $proxyTag }}{{- end }}
|
||||
- name: PROXY_TAGS
|
||||
value: {{ .Values.proxyConfig.defaultTags }}
|
||||
- name: APISERVER_PROXY
|
||||
|
||||
@@ -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,11 +48,14 @@ metadata:
|
||||
namespace: {{ .Release.Namespace }}
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resources: ["secrets", "serviceaccounts", "configmaps"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["statefulsets"]
|
||||
resources: ["statefulsets", "deployments"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: ["discovery.k8s.io"]
|
||||
resources: ["endpointslices"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
|
||||
@@ -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
|
||||
@@ -23,7 +23,8 @@ operatorConfig:
|
||||
- "tag:k8s-operator"
|
||||
|
||||
image:
|
||||
repo: tailscale/k8s-operator
|
||||
# Repository defaults to DockerHub, but images are also synced to ghcr.io/tailscale/k8s-operator.
|
||||
repository: tailscale/k8s-operator
|
||||
# Digest will be prioritized over tag. If neither are set appVersion will be
|
||||
# used.
|
||||
tag: ""
|
||||
@@ -37,6 +38,7 @@ operatorConfig:
|
||||
resources: {}
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
@@ -50,9 +52,14 @@ operatorConfig:
|
||||
# proxies created by the operator.
|
||||
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-ingress
|
||||
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-egress
|
||||
# Note that this section contains only a few global configuration options and
|
||||
# will not be updated with more configuration options in the future.
|
||||
# If you need more configuration options, take a look at ProxyClass:
|
||||
# https://tailscale.com/kb/1236/kubernetes-operator#cluster-resource-customization-using-proxyclass-custom-resource
|
||||
proxyConfig:
|
||||
image:
|
||||
repo: tailscale/tailscale
|
||||
# Repository defaults to DockerHub, but images are also synced to ghcr.io/tailscale/tailscale.
|
||||
repository: tailscale/tailscale
|
||||
# Digest will be prioritized over tag. If neither are set appVersion will be
|
||||
# used.
|
||||
tag: ""
|
||||
|
||||
@@ -31,6 +31,7 @@ spec:
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: 'Connector defines a Tailscale node that will be deployed in the cluster. The node can be configured to act as a Tailscale subnet router and/or a Tailscale exit node. Connector is a cluster-scoped resource. More info: https://tailscale.com/kb/1236/kubernetes-operator#deploying-exit-nodes-and-subnet-routers-on-kubernetes-using-connector-custom-resource'
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
@@ -44,7 +45,7 @@ spec:
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: ConnectorSpec describes the desired Tailscale component.
|
||||
description: 'ConnectorSpec describes the desired Tailscale component. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status'
|
||||
type: object
|
||||
properties:
|
||||
exitNode:
|
||||
@@ -116,12 +117,20 @@ spec:
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
hostname:
|
||||
description: Hostname is the fully qualified domain name of the Connector node. If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the node.
|
||||
type: string
|
||||
isExitNode:
|
||||
description: IsExitNode is set to true if the Connector acts as an exit node.
|
||||
type: boolean
|
||||
subnetRoutes:
|
||||
description: SubnetRoutes are the routes currently exposed to tailnet via this Connector instance.
|
||||
type: string
|
||||
tailnetIPs:
|
||||
description: TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) assigned to the Connector node.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
|
||||
105
cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml
Normal file
105
cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml
Normal file
@@ -0,0 +1,105 @@
|
||||
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.nameserver.ip
|
||||
name: NameserverIP
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: 'DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS names resolvable by cluster workloads. Use this if: A) you need to refer to tailnet services, exposed to cluster via Tailscale Kubernetes operator egress proxies by the MagicDNS names of those tailnet services (usually because the services run over HTTPS) B) you have exposed a cluster workload to the tailnet using Tailscale Ingress and you also want to refer to the workload from within the cluster over the Ingress''s MagicDNS name (usually because you have some callback component that needs to use the same URL as that used by a non-cluster client on tailnet). When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will deploy a nameserver for ts.net DNS names and automatically populate it with records for any Tailscale egress or Ingress proxies deployed to that cluster. Currently you must manually update your cluster DNS configuration to add the IP address of the deployed nameserver as a ts.net stub nameserver. Instructions for how to do it: https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS), https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns). Tailscale Kubernetes operator will write the address of a Service fronting the nameserver to dsnconfig.status.nameserver.ip. DNSConfig is a singleton - you must not create more than one. NB: if you want cluster workloads to be able to refer to Tailscale Ingress using its MagicDNS name, you must also annotate the Ingress resource with tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to ensure that the proxy created for the Ingress listens on its Pod IP address. NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported.'
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
properties:
|
||||
apiVersion:
|
||||
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
|
||||
type: string
|
||||
kind:
|
||||
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: 'Spec describes the desired DNS configuration. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status'
|
||||
type: object
|
||||
required:
|
||||
- nameserver
|
||||
properties:
|
||||
nameserver:
|
||||
description: Configuration for a nameserver that can resolve ts.net DNS names associated with in-cluster proxies for Tailscale egress Services and Tailscale Ingresses. The operator will always deploy this nameserver when a DNSConfig is applied.
|
||||
type: object
|
||||
properties:
|
||||
image:
|
||||
description: Nameserver image.
|
||||
type: object
|
||||
properties:
|
||||
repo:
|
||||
description: Repo defaults to tailscale/k8s-nameserver.
|
||||
type: string
|
||||
tag:
|
||||
description: Tag defaults to operator's own tag.
|
||||
type: string
|
||||
status:
|
||||
description: Status describes the status of the DNSConfig. This is set and managed by the Tailscale operator.
|
||||
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
|
||||
nameserver:
|
||||
description: Nameserver describes the status of nameserver cluster resources.
|
||||
type: object
|
||||
properties:
|
||||
ip:
|
||||
description: IP is the ClusterIP of the Service fronting the deployed ts.net nameserver. Currently you must manually update your cluster DNS config to add this address as a stub nameserver for ts.net for cluster workloads to be able to resolve MagicDNS names associated with egress or Ingress proxies. The IP address will change if you delete and recreate the DNSConfig.
|
||||
type: string
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
@@ -21,6 +21,7 @@ spec:
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: 'ProxyClass describes a set of configuration parameters that can be applied to proxy resources created by the Tailscale Kubernetes operator. To apply a given ProxyClass to resources created for a tailscale Ingress or Service, use tailscale.com/proxy-class=<proxyclass-name> label. To apply a given ProxyClass to resources created for a Connector, use connector.spec.proxyClass field. ProxyClass is a cluster scoped resource. More info: https://tailscale.com/kb/1236/kubernetes-operator#cluster-resource-customization-using-proxyclass-custom-resource.'
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
@@ -34,12 +35,20 @@ spec:
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: Specification of the desired state of the ProxyClass resource. https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
|
||||
type: object
|
||||
required:
|
||||
- statefulSet
|
||||
properties:
|
||||
metrics:
|
||||
description: Configuration for proxy metrics. Metrics are currently not supported for egress proxies and for Ingress proxies that have been configured with tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation. Note that the metrics are currently considered unstable and will likely change in breaking ways in the future - we only recommend that you use those for debugging purposes.
|
||||
type: object
|
||||
required:
|
||||
- enable
|
||||
properties:
|
||||
enable:
|
||||
description: Setting enable to true will make the proxy serve Tailscale metrics at <pod-ip>:9001/debug/metrics. Defaults to false.
|
||||
type: boolean
|
||||
statefulSet:
|
||||
description: Proxy's StatefulSet spec.
|
||||
description: Configuration parameters for the proxy's StatefulSet. Tailscale Kubernetes operator deploys a StatefulSet for each of the user configured proxies (Tailscale Ingress, Tailscale Service, Connector).
|
||||
type: object
|
||||
properties:
|
||||
annotations:
|
||||
@@ -56,6 +65,526 @@ spec:
|
||||
description: Configuration for the proxy Pod.
|
||||
type: object
|
||||
properties:
|
||||
affinity:
|
||||
description: Proxy Pod's affinity rules. By default, the Tailscale Kubernetes operator does not apply any affinity rules. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity
|
||||
type: object
|
||||
properties:
|
||||
nodeAffinity:
|
||||
description: Describes node affinity scheduling rules for the pod.
|
||||
type: object
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: The scheduler will prefer to schedule pods to nodes that satisfy the affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling affinity expressions, etc.), compute a sum by iterating through the elements of this field and adding "weight" to the sum if the node matches the corresponding matchExpressions; the node(s) with the highest sum are the most preferred.
|
||||
type: array
|
||||
items:
|
||||
description: An empty preferred scheduling term matches all objects with implicit weight 0 (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).
|
||||
type: object
|
||||
required:
|
||||
- preference
|
||||
- weight
|
||||
properties:
|
||||
preference:
|
||||
description: A node selector term, associated with the corresponding weight.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: A list of node selector requirements by node's labels.
|
||||
type: array
|
||||
items:
|
||||
description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
matchFields:
|
||||
description: A list of node selector requirements by node's fields.
|
||||
type: array
|
||||
items:
|
||||
description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
weight:
|
||||
description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.
|
||||
type: integer
|
||||
format: int32
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: If the affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to an update), the system may or may not try to eventually evict the pod from its node.
|
||||
type: object
|
||||
required:
|
||||
- nodeSelectorTerms
|
||||
properties:
|
||||
nodeSelectorTerms:
|
||||
description: Required. A list of node selector terms. The terms are ORed.
|
||||
type: array
|
||||
items:
|
||||
description: A null or empty node selector term matches no objects. The requirements of them are ANDed. The TopologySelectorTerm type implements a subset of the NodeSelectorTerm.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: A list of node selector requirements by node's labels.
|
||||
type: array
|
||||
items:
|
||||
description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
matchFields:
|
||||
description: A list of node selector requirements by node's fields.
|
||||
type: array
|
||||
items:
|
||||
description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
x-kubernetes-map-type: atomic
|
||||
podAffinity:
|
||||
description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).
|
||||
type: object
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: The scheduler will prefer to schedule pods to nodes that satisfy the affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling affinity expressions, etc.), compute a sum by iterating through the elements of this field and adding "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the node(s) with the highest sum are the most preferred.
|
||||
type: array
|
||||
items:
|
||||
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
|
||||
type: object
|
||||
required:
|
||||
- podAffinityTerm
|
||||
- weight
|
||||
properties:
|
||||
podAffinityTerm:
|
||||
description: Required. A pod affinity term, associated with the corresponding weight.
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
matchLabels:
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. Also, MatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MismatchLabelKeys and LabelSelector. Also, MismatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
matchLabels:
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
topologyKey:
|
||||
description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed.
|
||||
type: string
|
||||
weight:
|
||||
description: weight associated with matching the corresponding podAffinityTerm, in the range 1-100.
|
||||
type: integer
|
||||
format: int32
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: If the affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to a pod label update), the system may or may not try to eventually evict the pod from its node. When there are multiple elements, the lists of nodes corresponding to each podAffinityTerm are intersected, i.e. all terms must be satisfied.
|
||||
type: array
|
||||
items:
|
||||
description: Defines a set of pods (namely those matching the labelSelector relative to the given namespace(s)) that this pod should be co-located (affinity) or not co-located (anti-affinity) with, where co-located is defined as running on a node whose value of the label with key <topologyKey> matches that of any node on which a pod of the set of pods is running
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
matchLabels:
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. Also, MatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MismatchLabelKeys and LabelSelector. Also, MismatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
matchLabels:
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
topologyKey:
|
||||
description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed.
|
||||
type: string
|
||||
podAntiAffinity:
|
||||
description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).
|
||||
type: object
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: The scheduler will prefer to schedule pods to nodes that satisfy the anti-affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling anti-affinity expressions, etc.), compute a sum by iterating through the elements of this field and adding "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the node(s) with the highest sum are the most preferred.
|
||||
type: array
|
||||
items:
|
||||
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
|
||||
type: object
|
||||
required:
|
||||
- podAffinityTerm
|
||||
- weight
|
||||
properties:
|
||||
podAffinityTerm:
|
||||
description: Required. A pod affinity term, associated with the corresponding weight.
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
matchLabels:
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. Also, MatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MismatchLabelKeys and LabelSelector. Also, MismatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
matchLabels:
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
topologyKey:
|
||||
description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed.
|
||||
type: string
|
||||
weight:
|
||||
description: weight associated with matching the corresponding podAffinityTerm, in the range 1-100.
|
||||
type: integer
|
||||
format: int32
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: If the anti-affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the anti-affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to a pod label update), the system may or may not try to eventually evict the pod from its node. When there are multiple elements, the lists of nodes corresponding to each podAffinityTerm are intersected, i.e. all terms must be satisfied.
|
||||
type: array
|
||||
items:
|
||||
description: Defines a set of pods (namely those matching the labelSelector relative to the given namespace(s)) that this pod should be co-located (affinity) or not co-located (anti-affinity) with, where co-located is defined as running on a node whose value of the label with key <topologyKey> matches that of any node on which a pod of the set of pods is running
|
||||
type: object
|
||||
required:
|
||||
- topologyKey
|
||||
properties:
|
||||
labelSelector:
|
||||
description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
matchLabels:
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. Also, MatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MismatchLabelKeys and LabelSelector. Also, MismatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces.
|
||||
type: object
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
type: array
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
matchLabels:
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
topologyKey:
|
||||
description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed.
|
||||
type: string
|
||||
annotations:
|
||||
description: Annotations that will be added to the proxy Pod. Any annotations specified here will be merged with the default annotations applied to the Pod by the Tailscale Kubernetes operator. Annotations must be valid Kubernetes annotations. https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set
|
||||
type: object
|
||||
@@ -177,6 +706,31 @@ spec:
|
||||
description: Configuration for the proxy container running tailscale.
|
||||
type: object
|
||||
properties:
|
||||
env:
|
||||
description: List of environment variables to set in the container. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables Note that environment variables provided here will take precedence over Tailscale-specific environment variables set by the operator, however running proxies with custom values for Tailscale environment variables (i.e TS_USERSPACE) is not recommended and might break in the future.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
description: Name of the environment variable. Must be a C_IDENTIFIER.
|
||||
type: string
|
||||
pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$
|
||||
value:
|
||||
description: 'Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and any service environment variables. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless of whether the variable exists or not. Defaults to "".'
|
||||
type: string
|
||||
image:
|
||||
description: Container image name. By default images are pulled from docker.io/tailscale/tailscale, but the official images are also available at ghcr.io/tailscale/tailscale. Specifying image name here will override any proxy image values specified via the Kubernetes operator's Helm chart values or PROXY_IMAGE env var in the operator Deployment. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
|
||||
type: string
|
||||
imagePullPolicy:
|
||||
description: Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
|
||||
type: string
|
||||
enum:
|
||||
- Always
|
||||
- Never
|
||||
- IfNotPresent
|
||||
resources:
|
||||
description: Container resource requirements. By default Tailscale Kubernetes operator does not apply any resource requirements. The amount of resources required wil depend on the amount of resources the operator needs to parse, usage patterns and cluster size. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources
|
||||
type: object
|
||||
@@ -305,6 +859,31 @@ spec:
|
||||
description: Configuration for the proxy init container that enables forwarding.
|
||||
type: object
|
||||
properties:
|
||||
env:
|
||||
description: List of environment variables to set in the container. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables Note that environment variables provided here will take precedence over Tailscale-specific environment variables set by the operator, however running proxies with custom values for Tailscale environment variables (i.e TS_USERSPACE) is not recommended and might break in the future.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
description: Name of the environment variable. Must be a C_IDENTIFIER.
|
||||
type: string
|
||||
pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$
|
||||
value:
|
||||
description: 'Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and any service environment variables. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless of whether the variable exists or not. Defaults to "".'
|
||||
type: string
|
||||
image:
|
||||
description: Container image name. By default images are pulled from docker.io/tailscale/tailscale, but the official images are also available at ghcr.io/tailscale/tailscale. Specifying image name here will override any proxy image values specified via the Kubernetes operator's Helm chart values or PROXY_IMAGE env var in the operator Deployment. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
|
||||
type: string
|
||||
imagePullPolicy:
|
||||
description: Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
|
||||
type: string
|
||||
enum:
|
||||
- Always
|
||||
- Never
|
||||
- IfNotPresent
|
||||
resources:
|
||||
description: Container resource requirements. By default Tailscale Kubernetes operator does not apply any resource requirements. The amount of resources required wil depend on the amount of resources the operator needs to parse, usage patterns and cluster size. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources
|
||||
type: object
|
||||
@@ -452,7 +1031,15 @@ spec:
|
||||
value:
|
||||
description: Value is the taint value the toleration matches to. If the operator is Exists, the value should be empty, otherwise just a regular string.
|
||||
type: string
|
||||
tailscale:
|
||||
description: TailscaleConfig contains options to configure the tailscale-specific parameters of proxies.
|
||||
type: object
|
||||
properties:
|
||||
acceptRoutes:
|
||||
description: AcceptRoutes can be set to true to make the proxy instance accept routes advertized by other nodes on the tailnet, such as subnet routes. This is equivalent of passing --accept-routes flag to a tailscale Linux client. https://tailscale.com/kb/1019/subnets#use-your-subnet-routes-from-other-machines Defaults to false.
|
||||
type: boolean
|
||||
status:
|
||||
description: Status of the ProxyClass. This is set and managed automatically. https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
|
||||
6
cmd/k8s-operator/deploy/examples/dnsconfig.yaml
Normal file
6
cmd/k8s-operator/deploy/examples/dnsconfig.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: tailscale.com/v1alpha1
|
||||
kind: DNSConfig
|
||||
metadata:
|
||||
name: ts-dns
|
||||
spec:
|
||||
nameserver: {}
|
||||
@@ -3,13 +3,21 @@ kind: ProxyClass
|
||||
metadata:
|
||||
name: prod
|
||||
spec:
|
||||
metrics:
|
||||
enable: true
|
||||
statefulSet:
|
||||
annotations:
|
||||
platform-component: infra
|
||||
platform-component: infra
|
||||
pod:
|
||||
labels:
|
||||
team: eng
|
||||
nodeSelector:
|
||||
beta.kubernetes.io/os: "linux"
|
||||
kubernetes.io/os: "linux"
|
||||
imagePullSecrets:
|
||||
- name: "foo"
|
||||
tailscaleContainer:
|
||||
image: "ghcr.io/tailscale/tailscale:v1.64"
|
||||
imagePullPolicy: IfNotPresent
|
||||
tailscaleInitContainer:
|
||||
image: "ghcr.io/tailscale/tailscale:v1.64"
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
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: dnsrecords
|
||||
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: dnsrecords
|
||||
mountPath: /config
|
||||
restartPolicy: Always
|
||||
serviceAccount: nameserver
|
||||
serviceAccountName: nameserver
|
||||
volumes:
|
||||
- name: dnsrecords
|
||||
configMap:
|
||||
name: dnsrecords
|
||||
4
cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml
Normal file
4
cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: nameserver
|
||||
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
|
||||
@@ -60,6 +60,7 @@ spec:
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: 'Connector defines a Tailscale node that will be deployed in the cluster. The node can be configured to act as a Tailscale subnet router and/or a Tailscale exit node. Connector is a cluster-scoped resource. More info: https://tailscale.com/kb/1236/kubernetes-operator#deploying-exit-nodes-and-subnet-routers-on-kubernetes-using-connector-custom-resource'
|
||||
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'
|
||||
@@ -70,7 +71,7 @@ spec:
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: ConnectorSpec describes the desired Tailscale component.
|
||||
description: 'ConnectorSpec describes the desired Tailscale component. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status'
|
||||
properties:
|
||||
exitNode:
|
||||
description: ExitNode defines whether the Connector node should act as a Tailscale exit node. Defaults to false. https://tailscale.com/kb/1103/exit-nodes
|
||||
@@ -141,12 +142,126 @@ spec:
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
hostname:
|
||||
description: Hostname is the fully qualified domain name of the Connector node. If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the node.
|
||||
type: string
|
||||
isExitNode:
|
||||
description: IsExitNode is set to true if the Connector acts as an exit node.
|
||||
type: boolean
|
||||
subnetRoutes:
|
||||
description: SubnetRoutes are the routes currently exposed to tailnet via this Connector instance.
|
||||
type: string
|
||||
tailnetIPs:
|
||||
description: TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) assigned to the Connector node.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
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
|
||||
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.nameserver.ip
|
||||
name: NameserverIP
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: 'DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS names resolvable by cluster workloads. Use this if: A) you need to refer to tailnet services, exposed to cluster via Tailscale Kubernetes operator egress proxies by the MagicDNS names of those tailnet services (usually because the services run over HTTPS) B) you have exposed a cluster workload to the tailnet using Tailscale Ingress and you also want to refer to the workload from within the cluster over the Ingress''s MagicDNS name (usually because you have some callback component that needs to use the same URL as that used by a non-cluster client on tailnet). When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will deploy a nameserver for ts.net DNS names and automatically populate it with records for any Tailscale egress or Ingress proxies deployed to that cluster. Currently you must manually update your cluster DNS configuration to add the IP address of the deployed nameserver as a ts.net stub nameserver. Instructions for how to do it: https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS), https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns). Tailscale Kubernetes operator will write the address of a Service fronting the nameserver to dsnconfig.status.nameserver.ip. DNSConfig is a singleton - you must not create more than one. NB: if you want cluster workloads to be able to refer to Tailscale Ingress using its MagicDNS name, you must also annotate the Ingress resource with tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to ensure that the proxy created for the Ingress listens on its Pod IP address. NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported.'
|
||||
properties:
|
||||
apiVersion:
|
||||
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
|
||||
type: string
|
||||
kind:
|
||||
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: 'Spec describes the desired DNS configuration. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status'
|
||||
properties:
|
||||
nameserver:
|
||||
description: Configuration for a nameserver that can resolve ts.net DNS names associated with in-cluster proxies for Tailscale egress Services and Tailscale Ingresses. The operator will always deploy this nameserver when a DNSConfig is applied.
|
||||
properties:
|
||||
image:
|
||||
description: Nameserver image.
|
||||
properties:
|
||||
repo:
|
||||
description: Repo defaults to tailscale/k8s-nameserver.
|
||||
type: string
|
||||
tag:
|
||||
description: Tag defaults to operator's own tag.
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
required:
|
||||
- nameserver
|
||||
type: object
|
||||
status:
|
||||
description: Status describes the status of the DNSConfig. This is set and managed by the Tailscale operator.
|
||||
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
|
||||
nameserver:
|
||||
description: Nameserver describes the status of nameserver cluster resources.
|
||||
properties:
|
||||
ip:
|
||||
description: IP is the ClusterIP of the Service fronting the deployed ts.net nameserver. Currently you must manually update your cluster DNS config to add this address as a stub nameserver for ts.net for cluster workloads to be able to resolve MagicDNS names associated with egress or Ingress proxies. The IP address will change if you delete and recreate the DNSConfig.
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
@@ -179,6 +294,7 @@ spec:
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: 'ProxyClass describes a set of configuration parameters that can be applied to proxy resources created by the Tailscale Kubernetes operator. To apply a given ProxyClass to resources created for a tailscale Ingress or Service, use tailscale.com/proxy-class=<proxyclass-name> label. To apply a given ProxyClass to resources created for a Connector, use connector.spec.proxyClass field. ProxyClass is a cluster scoped resource. More info: https://tailscale.com/kb/1236/kubernetes-operator#cluster-resource-customization-using-proxyclass-custom-resource.'
|
||||
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'
|
||||
@@ -189,9 +305,19 @@ spec:
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: Specification of the desired state of the ProxyClass resource. https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
|
||||
properties:
|
||||
metrics:
|
||||
description: Configuration for proxy metrics. Metrics are currently not supported for egress proxies and for Ingress proxies that have been configured with tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation. Note that the metrics are currently considered unstable and will likely change in breaking ways in the future - we only recommend that you use those for debugging purposes.
|
||||
properties:
|
||||
enable:
|
||||
description: Setting enable to true will make the proxy serve Tailscale metrics at <pod-ip>:9001/debug/metrics. Defaults to false.
|
||||
type: boolean
|
||||
required:
|
||||
- enable
|
||||
type: object
|
||||
statefulSet:
|
||||
description: Proxy's StatefulSet spec.
|
||||
description: Configuration parameters for the proxy's StatefulSet. Tailscale Kubernetes operator deploys a StatefulSet for each of the user configured proxies (Tailscale Ingress, Tailscale Service, Connector).
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
@@ -206,6 +332,526 @@ spec:
|
||||
pod:
|
||||
description: Configuration for the proxy Pod.
|
||||
properties:
|
||||
affinity:
|
||||
description: Proxy Pod's affinity rules. By default, the Tailscale Kubernetes operator does not apply any affinity rules. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#affinity
|
||||
properties:
|
||||
nodeAffinity:
|
||||
description: Describes node affinity scheduling rules for the pod.
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: The scheduler will prefer to schedule pods to nodes that satisfy the affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling affinity expressions, etc.), compute a sum by iterating through the elements of this field and adding "weight" to the sum if the node matches the corresponding matchExpressions; the node(s) with the highest sum are the most preferred.
|
||||
items:
|
||||
description: An empty preferred scheduling term matches all objects with implicit weight 0 (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).
|
||||
properties:
|
||||
preference:
|
||||
description: A node selector term, associated with the corresponding weight.
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: A list of node selector requirements by node's labels.
|
||||
items:
|
||||
description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
matchFields:
|
||||
description: A list of node selector requirements by node's fields.
|
||||
items:
|
||||
description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
weight:
|
||||
description: Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.
|
||||
format: int32
|
||||
type: integer
|
||||
required:
|
||||
- preference
|
||||
- weight
|
||||
type: object
|
||||
type: array
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: If the affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to an update), the system may or may not try to eventually evict the pod from its node.
|
||||
properties:
|
||||
nodeSelectorTerms:
|
||||
description: Required. A list of node selector terms. The terms are ORed.
|
||||
items:
|
||||
description: A null or empty node selector term matches no objects. The requirements of them are ANDed. The TopologySelectorTerm type implements a subset of the NodeSelectorTerm.
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: A list of node selector requirements by node's labels.
|
||||
items:
|
||||
description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
matchFields:
|
||||
description: A list of node selector requirements by node's fields.
|
||||
items:
|
||||
description: A node selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: The label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: Represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.
|
||||
type: string
|
||||
values:
|
||||
description: An array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. If the operator is Gt or Lt, the values array must have a single element, which will be interpreted as an integer. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
type: array
|
||||
required:
|
||||
- nodeSelectorTerms
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
type: object
|
||||
podAffinity:
|
||||
description: Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: The scheduler will prefer to schedule pods to nodes that satisfy the affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling affinity expressions, etc.), compute a sum by iterating through the elements of this field and adding "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the node(s) with the highest sum are the most preferred.
|
||||
items:
|
||||
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
|
||||
properties:
|
||||
podAffinityTerm:
|
||||
description: Required. A pod affinity term, associated with the corresponding weight.
|
||||
properties:
|
||||
labelSelector:
|
||||
description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods.
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
matchLabels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. Also, MatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MismatchLabelKeys and LabelSelector. Also, MismatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces.
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
matchLabels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
topologyKey:
|
||||
description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed.
|
||||
type: string
|
||||
required:
|
||||
- topologyKey
|
||||
type: object
|
||||
weight:
|
||||
description: weight associated with matching the corresponding podAffinityTerm, in the range 1-100.
|
||||
format: int32
|
||||
type: integer
|
||||
required:
|
||||
- podAffinityTerm
|
||||
- weight
|
||||
type: object
|
||||
type: array
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: If the affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to a pod label update), the system may or may not try to eventually evict the pod from its node. When there are multiple elements, the lists of nodes corresponding to each podAffinityTerm are intersected, i.e. all terms must be satisfied.
|
||||
items:
|
||||
description: Defines a set of pods (namely those matching the labelSelector relative to the given namespace(s)) that this pod should be co-located (affinity) or not co-located (anti-affinity) with, where co-located is defined as running on a node whose value of the label with key <topologyKey> matches that of any node on which a pod of the set of pods is running
|
||||
properties:
|
||||
labelSelector:
|
||||
description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods.
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
matchLabels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. Also, MatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MismatchLabelKeys and LabelSelector. Also, MismatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces.
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
matchLabels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
topologyKey:
|
||||
description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed.
|
||||
type: string
|
||||
required:
|
||||
- topologyKey
|
||||
type: object
|
||||
type: array
|
||||
type: object
|
||||
podAntiAffinity:
|
||||
description: Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).
|
||||
properties:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
description: The scheduler will prefer to schedule pods to nodes that satisfy the anti-affinity expressions specified by this field, but it may choose a node that violates one or more of the expressions. The node that is most preferred is the one with the greatest sum of weights, i.e. for each node that meets all of the scheduling requirements (resource request, requiredDuringScheduling anti-affinity expressions, etc.), compute a sum by iterating through the elements of this field and adding "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the node(s) with the highest sum are the most preferred.
|
||||
items:
|
||||
description: The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)
|
||||
properties:
|
||||
podAffinityTerm:
|
||||
description: Required. A pod affinity term, associated with the corresponding weight.
|
||||
properties:
|
||||
labelSelector:
|
||||
description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods.
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
matchLabels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. Also, MatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MismatchLabelKeys and LabelSelector. Also, MismatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces.
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
matchLabels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
topologyKey:
|
||||
description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed.
|
||||
type: string
|
||||
required:
|
||||
- topologyKey
|
||||
type: object
|
||||
weight:
|
||||
description: weight associated with matching the corresponding podAffinityTerm, in the range 1-100.
|
||||
format: int32
|
||||
type: integer
|
||||
required:
|
||||
- podAffinityTerm
|
||||
- weight
|
||||
type: object
|
||||
type: array
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
description: If the anti-affinity requirements specified by this field are not met at scheduling time, the pod will not be scheduled onto the node. If the anti-affinity requirements specified by this field cease to be met at some point during pod execution (e.g. due to a pod label update), the system may or may not try to eventually evict the pod from its node. When there are multiple elements, the lists of nodes corresponding to each podAffinityTerm are intersected, i.e. all terms must be satisfied.
|
||||
items:
|
||||
description: Defines a set of pods (namely those matching the labelSelector relative to the given namespace(s)) that this pod should be co-located (affinity) or not co-located (anti-affinity) with, where co-located is defined as running on a node whose value of the label with key <topologyKey> matches that of any node on which a pod of the set of pods is running
|
||||
properties:
|
||||
labelSelector:
|
||||
description: A label query over a set of resources, in this case pods. If it's null, this PodAffinityTerm matches with no Pods.
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
matchLabels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
matchLabelKeys:
|
||||
description: MatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key in (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. Also, MatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: atomic
|
||||
mismatchLabelKeys:
|
||||
description: MismatchLabelKeys is a set of pod label keys to select which pods will be taken into consideration. The keys are used to lookup values from the incoming pod labels, those key-value labels are merged with `LabelSelector` as `key notin (value)` to select the group of existing pods which pods will be taken into consideration for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both MismatchLabelKeys and LabelSelector. Also, MismatchLabelKeys cannot be set when LabelSelector isn't set. This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-kubernetes-list-type: atomic
|
||||
namespaceSelector:
|
||||
description: A label query over the set of namespaces that the term applies to. The term is applied to the union of the namespaces selected by this field and the ones listed in the namespaces field. null selector and null or empty namespaces list means "this pod's namespace". An empty selector ({}) matches all namespaces.
|
||||
properties:
|
||||
matchExpressions:
|
||||
description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
|
||||
items:
|
||||
description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values.
|
||||
properties:
|
||||
key:
|
||||
description: key is the label key that the selector applies to.
|
||||
type: string
|
||||
operator:
|
||||
description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist.
|
||||
type: string
|
||||
values:
|
||||
description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- key
|
||||
- operator
|
||||
type: object
|
||||
type: array
|
||||
matchLabels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||
type: object
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
namespaces:
|
||||
description: namespaces specifies a static list of namespace names that the term applies to. The term is applied to the union of the namespaces listed in this field and the ones selected by namespaceSelector. null or empty namespaces list and null namespaceSelector means "this pod's namespace".
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
topologyKey:
|
||||
description: This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching the labelSelector in the specified namespaces, where co-located is defined as running on a node whose value of the label with key topologyKey matches that of any node on which any of the selected pods is running. Empty topologyKey is not allowed.
|
||||
type: string
|
||||
required:
|
||||
- topologyKey
|
||||
type: object
|
||||
type: array
|
||||
type: object
|
||||
type: object
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -326,6 +972,31 @@ spec:
|
||||
tailscaleContainer:
|
||||
description: Configuration for the proxy container running tailscale.
|
||||
properties:
|
||||
env:
|
||||
description: List of environment variables to set in the container. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables Note that environment variables provided here will take precedence over Tailscale-specific environment variables set by the operator, however running proxies with custom values for Tailscale environment variables (i.e TS_USERSPACE) is not recommended and might break in the future.
|
||||
items:
|
||||
properties:
|
||||
name:
|
||||
description: Name of the environment variable. Must be a C_IDENTIFIER.
|
||||
pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$
|
||||
type: string
|
||||
value:
|
||||
description: 'Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and any service environment variables. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless of whether the variable exists or not. Defaults to "".'
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
type: array
|
||||
image:
|
||||
description: Container image name. By default images are pulled from docker.io/tailscale/tailscale, but the official images are also available at ghcr.io/tailscale/tailscale. Specifying image name here will override any proxy image values specified via the Kubernetes operator's Helm chart values or PROXY_IMAGE env var in the operator Deployment. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
|
||||
type: string
|
||||
imagePullPolicy:
|
||||
description: Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
|
||||
enum:
|
||||
- Always
|
||||
- Never
|
||||
- IfNotPresent
|
||||
type: string
|
||||
resources:
|
||||
description: Container resource requirements. By default Tailscale Kubernetes operator does not apply any resource requirements. The amount of resources required wil depend on the amount of resources the operator needs to parse, usage patterns and cluster size. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources
|
||||
properties:
|
||||
@@ -454,6 +1125,31 @@ spec:
|
||||
tailscaleInitContainer:
|
||||
description: Configuration for the proxy init container that enables forwarding.
|
||||
properties:
|
||||
env:
|
||||
description: List of environment variables to set in the container. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#environment-variables Note that environment variables provided here will take precedence over Tailscale-specific environment variables set by the operator, however running proxies with custom values for Tailscale environment variables (i.e TS_USERSPACE) is not recommended and might break in the future.
|
||||
items:
|
||||
properties:
|
||||
name:
|
||||
description: Name of the environment variable. Must be a C_IDENTIFIER.
|
||||
pattern: ^[-._a-zA-Z][-._a-zA-Z0-9]*$
|
||||
type: string
|
||||
value:
|
||||
description: 'Variable references $(VAR_NAME) are expanded using the previously defined environment variables in the container and any service environment variables. If a variable cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless of whether the variable exists or not. Defaults to "".'
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
type: array
|
||||
image:
|
||||
description: Container image name. By default images are pulled from docker.io/tailscale/tailscale, but the official images are also available at ghcr.io/tailscale/tailscale. Specifying image name here will override any proxy image values specified via the Kubernetes operator's Helm chart values or PROXY_IMAGE env var in the operator Deployment. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
|
||||
type: string
|
||||
imagePullPolicy:
|
||||
description: Image pull policy. One of Always, Never, IfNotPresent. Defaults to Always. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#image
|
||||
enum:
|
||||
- Always
|
||||
- Never
|
||||
- IfNotPresent
|
||||
type: string
|
||||
resources:
|
||||
description: Container resource requirements. By default Tailscale Kubernetes operator does not apply any resource requirements. The amount of resources required wil depend on the amount of resources the operator needs to parse, usage patterns and cluster size. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#resources
|
||||
properties:
|
||||
@@ -604,10 +1300,16 @@ spec:
|
||||
type: array
|
||||
type: object
|
||||
type: object
|
||||
required:
|
||||
- statefulSet
|
||||
tailscale:
|
||||
description: TailscaleConfig contains options to configure the tailscale-specific parameters of proxies.
|
||||
properties:
|
||||
acceptRoutes:
|
||||
description: AcceptRoutes can be set to true to make the proxy instance accept routes advertized by other nodes on the tailnet, such as subnet routes. This is equivalent of passing --accept-routes flag to a tailscale Linux client. https://tailscale.com/kb/1019/subnets#use-your-subnet-routes-from-other-machines Defaults to false.
|
||||
type: boolean
|
||||
type: object
|
||||
type: object
|
||||
status:
|
||||
description: Status of the ProxyClass. This is set and managed automatically. https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
|
||||
properties:
|
||||
conditions:
|
||||
description: List of status conditions to indicate the status of the ProxyClass. Known condition types are `ProxyClassReady`.
|
||||
@@ -691,6 +1393,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,14 +1427,25 @@ rules:
|
||||
- ""
|
||||
resources:
|
||||
- secrets
|
||||
- serviceaccounts
|
||||
- configmaps
|
||||
verbs:
|
||||
- '*'
|
||||
- apiGroups:
|
||||
- apps
|
||||
resources:
|
||||
- statefulsets
|
||||
- deployments
|
||||
verbs:
|
||||
- '*'
|
||||
- apiGroups:
|
||||
- discovery.k8s.io
|
||||
resources:
|
||||
- endpointslices
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
|
||||
@@ -14,10 +14,8 @@ spec:
|
||||
- name: sysctler
|
||||
securityContext:
|
||||
privileged: true
|
||||
command: ["/bin/sh"]
|
||||
args:
|
||||
- -c
|
||||
- sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1
|
||||
command: ["/bin/sh", "-c"]
|
||||
args: [sysctl -w net.ipv4.ip_forward=1 && if sysctl net.ipv6.conf.all.forwarding; then sysctl -w net.ipv6.conf.all.forwarding=1; fi]
|
||||
resources:
|
||||
requests:
|
||||
cpu: 1m
|
||||
|
||||
@@ -20,3 +20,7 @@ spec:
|
||||
env:
|
||||
- name: TS_USERSPACE
|
||||
value: "true"
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
|
||||
337
cmd/k8s-operator/dnsrecords.go
Normal file
337
cmd/k8s-operator/dnsrecords.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/utils/net"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
const (
|
||||
dnsRecordsRecocilerFinalizer = "tailscale.com/dns-records-reconciler"
|
||||
annotationTSMagicDNSName = "tailscale.com/magic-dnsname"
|
||||
)
|
||||
|
||||
// dnsRecordsReconciler knows how to update dnsrecords ConfigMap with DNS
|
||||
// records.
|
||||
// The records that it creates are:
|
||||
// - For tailscale Ingress, a mapping of the Ingress's MagicDNSName to the IP address of
|
||||
// the ingress proxy Pod.
|
||||
// - For egress proxies configured via tailscale.com/tailnet-fqdn annotation, a
|
||||
// mapping of the tailnet FQDN to the IP address of the egress proxy Pod.
|
||||
//
|
||||
// Records will only be created if there is exactly one ready
|
||||
// tailscale.com/v1alpha1.DNSConfig instance in the cluster (so that we know
|
||||
// that there is a ts.net nameserver deployed in the cluster).
|
||||
type dnsRecordsReconciler struct {
|
||||
client.Client
|
||||
tsNamespace string // namespace in which we provision tailscale resources
|
||||
logger *zap.SugaredLogger
|
||||
isDefaultLoadBalancer bool // true if operator is the default ingress controller in this cluster
|
||||
}
|
||||
|
||||
// Reconcile takes a reconcile.Request for a headless Service fronting a
|
||||
// tailscale proxy and updates DNS Records in dnsrecords ConfigMap for the
|
||||
// in-cluster ts.net nameserver if required.
|
||||
func (dnsRR *dnsRecordsReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
logger := dnsRR.logger.With("Service", req.NamespacedName)
|
||||
logger.Debugf("starting reconcile")
|
||||
defer logger.Debugf("reconcile finished")
|
||||
|
||||
headlessSvc := new(corev1.Service)
|
||||
err = dnsRR.Client.Get(ctx, req.NamespacedName, headlessSvc)
|
||||
if apierrors.IsNotFound(err) {
|
||||
logger.Debugf("Service not found")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get Service: %w", err)
|
||||
}
|
||||
if !(isManagedByType(headlessSvc, "svc") || isManagedByType(headlessSvc, "ingress")) {
|
||||
logger.Debugf("Service is not a headless Service for a tailscale ingress or egress proxy; do nothing")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
if !headlessSvc.DeletionTimestamp.IsZero() {
|
||||
logger.Debug("Service is being deleted, clean up resources")
|
||||
return reconcile.Result{}, dnsRR.maybeCleanup(ctx, headlessSvc, logger)
|
||||
}
|
||||
|
||||
// Check that there is a ts.net nameserver deployed to the cluster by
|
||||
// checking that there is tailscale.com/v1alpha1.DNSConfig resource in a
|
||||
// Ready state.
|
||||
dnsCfgLst := new(tsapi.DNSConfigList)
|
||||
if err = dnsRR.List(ctx, dnsCfgLst); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("error listing DNSConfigs: %w", err)
|
||||
}
|
||||
if len(dnsCfgLst.Items) == 0 {
|
||||
logger.Debugf("DNSConfig does not exist, not creating DNS records")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
if len(dnsCfgLst.Items) > 1 {
|
||||
logger.Errorf("Invalid cluster state - more than one DNSConfig found in cluster. Please ensure no more than one exists")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
dnsCfg := dnsCfgLst.Items[0]
|
||||
if !operatorutils.DNSCfgIsReady(&dnsCfg) {
|
||||
logger.Info("DNSConfig is not ready yet, waiting...")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
return reconcile.Result{}, dnsRR.maybeProvision(ctx, headlessSvc, logger)
|
||||
}
|
||||
|
||||
// maybeProvision ensures that dnsrecords ConfigMap contains a record for the
|
||||
// proxy associated with the headless Service.
|
||||
// The record is only provisioned if the proxy is for a tailscale Ingress or
|
||||
// egress configured via tailscale.com/tailnet-fqdn annotation.
|
||||
//
|
||||
// For Ingress, the record is a mapping between the MagicDNSName of the Ingress, retrieved from
|
||||
// ingress.status.loadBalancer.ingress.hostname field and the proxy Pod IP addresses
|
||||
// retrieved from the EndpoinSlice associated with this headless Service, i.e
|
||||
// Records{IP4: <MagicDNS name of the Ingress>: <[IPs of the ingress proxy Pods]>}
|
||||
//
|
||||
// For egress, the record is a mapping between tailscale.com/tailnet-fqdn
|
||||
// annotation and the proxy Pod IP addresses, retrieved from the EndpointSlice
|
||||
// associated with this headless Service, i.e
|
||||
// Records{IP4: {<tailscale.com/tailnet-fqdn>: <[IPs of the egress proxy Pods]>}
|
||||
//
|
||||
// If records need to be created for this proxy, maybeProvision will also:
|
||||
// - update the headless Service with a tailscale.com/magic-dnsname annotation
|
||||
// - update the headless Service with a finalizer
|
||||
func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, headlessSvc *corev1.Service, logger *zap.SugaredLogger) error {
|
||||
if headlessSvc == nil {
|
||||
logger.Info("[unexpected] maybeProvision called with a nil Service")
|
||||
return nil
|
||||
}
|
||||
isEgressFQDNSvc, err := dnsRR.isSvcForFQDNEgressProxy(ctx, headlessSvc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking whether the Service is for an egress proxy: %w", err)
|
||||
}
|
||||
if !(isEgressFQDNSvc || isManagedByType(headlessSvc, "ingress")) {
|
||||
logger.Debug("Service is not fronting a proxy that we create DNS records for; do nothing")
|
||||
return nil
|
||||
}
|
||||
fqdn, err := dnsRR.fqdnForDNSRecord(ctx, headlessSvc, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error determining DNS name for record: %w", err)
|
||||
}
|
||||
if fqdn == "" {
|
||||
logger.Debugf("MagicDNS name does not (yet) exist, not provisioning DNS record")
|
||||
return nil // a new reconcile will be triggered once it's added
|
||||
}
|
||||
|
||||
oldHeadlessSvc := headlessSvc.DeepCopy()
|
||||
// Ensure that headless Service is annotated with a finalizer to help
|
||||
// with records cleanup when proxy resources are deleted.
|
||||
if !slices.Contains(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer) {
|
||||
headlessSvc.Finalizers = append(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer)
|
||||
}
|
||||
// Ensure that headless Service is annotated with the current MagicDNS
|
||||
// name to help with records cleanup when proxy resources are deleted or
|
||||
// MagicDNS name changes.
|
||||
oldFqdn := headlessSvc.Annotations[annotationTSMagicDNSName]
|
||||
if oldFqdn != "" && oldFqdn != fqdn { // i.e user has changed the value of tailscale.com/tailnet-fqdn annotation
|
||||
logger.Debugf("MagicDNS name has changed, remvoving record for %s", oldFqdn)
|
||||
updateFunc := func(rec *operatorutils.Records) {
|
||||
delete(rec.IP4, oldFqdn)
|
||||
}
|
||||
if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
|
||||
return fmt.Errorf("error removing record for %s: %w", oldFqdn, err)
|
||||
}
|
||||
}
|
||||
mak.Set(&headlessSvc.Annotations, annotationTSMagicDNSName, fqdn)
|
||||
if !apiequality.Semantic.DeepEqual(oldHeadlessSvc, headlessSvc) {
|
||||
logger.Infof("provisioning DNS record for MagicDNS name: %s", fqdn) // this will be printed exactly once
|
||||
if err := dnsRR.Update(ctx, headlessSvc); err != nil {
|
||||
return fmt.Errorf("error updating proxy headless Service metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the Pod IP addresses for the proxy from the EndpointSlice for the
|
||||
// headless Service.
|
||||
labels := map[string]string{discoveryv1.LabelServiceName: headlessSvc.Name} // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership
|
||||
eps, err := getSingleObject[discoveryv1.EndpointSlice](ctx, dnsRR.Client, dnsRR.tsNamespace, labels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting the EndpointSlice for the proxy's headless Service: %w", err)
|
||||
}
|
||||
if eps == nil {
|
||||
logger.Debugf("proxy's headless Service EndpointSlice does not yet exist. We will reconcile again once it's created")
|
||||
return nil
|
||||
}
|
||||
// An EndpointSlice for a Service can have a list of endpoints that each
|
||||
// can have multiple addresses - these are the IP addresses of any Pods
|
||||
// selected by that Service. Pick all the IPv4 addresses.
|
||||
ips := make([]string, 0)
|
||||
for _, ep := range eps.Endpoints {
|
||||
for _, ip := range ep.Addresses {
|
||||
if !net.IsIPv4String(ip) {
|
||||
logger.Infof("EndpointSlice contains IP address %q that is not IPv4, ignoring. Currently only IPv4 is supported", ip)
|
||||
} else {
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
logger.Debugf("EndpointSlice for the Service contains no IPv4 addresses. We will reconcile again once they are created.")
|
||||
return nil
|
||||
}
|
||||
updateFunc := func(rec *operatorutils.Records) {
|
||||
mak.Set(&rec.IP4, fqdn, ips)
|
||||
}
|
||||
if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
|
||||
return fmt.Errorf("error updating DNS records: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeCleanup ensures that the DNS record for the proxy has been removed from
|
||||
// dnsrecords ConfigMap and the tailscale.com/dns-records-reconciler finalizer
|
||||
// has been removed from the Service. If the record is not found in the
|
||||
// ConfigMap, the ConfigMap does not exist, or the Service does not have
|
||||
// tailscale.com/magic-dnsname annotation, just remove the finalizer.
|
||||
func (h *dnsRecordsReconciler) maybeCleanup(ctx context.Context, headlessSvc *corev1.Service, logger *zap.SugaredLogger) error {
|
||||
ix := slices.Index(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer)
|
||||
if ix == -1 {
|
||||
logger.Debugf("no finalizer, nothing to do")
|
||||
return nil
|
||||
}
|
||||
cm := &corev1.ConfigMap{}
|
||||
err := h.Client.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: h.tsNamespace}, cm)
|
||||
if apierrors.IsNotFound(err) {
|
||||
logger.Debug("'dsnrecords' ConfigMap not found")
|
||||
return h.removeHeadlessSvcFinalizer(ctx, headlessSvc)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving 'dnsrecords' ConfigMap: %w", err)
|
||||
}
|
||||
if cm.Data == nil {
|
||||
logger.Debug("'dnsrecords' ConfigMap contains no records")
|
||||
return h.removeHeadlessSvcFinalizer(ctx, headlessSvc)
|
||||
}
|
||||
_, ok := cm.Data[operatorutils.DNSRecordsCMKey]
|
||||
if !ok {
|
||||
logger.Debug("'dnsrecords' ConfigMap contains no records")
|
||||
return h.removeHeadlessSvcFinalizer(ctx, headlessSvc)
|
||||
}
|
||||
fqdn, _ := headlessSvc.GetAnnotations()[annotationTSMagicDNSName]
|
||||
if fqdn == "" {
|
||||
return h.removeHeadlessSvcFinalizer(ctx, headlessSvc)
|
||||
}
|
||||
logger.Infof("removing DNS record for MagicDNS name %s", fqdn)
|
||||
updateFunc := func(rec *operatorutils.Records) {
|
||||
delete(rec.IP4, fqdn)
|
||||
}
|
||||
if err = h.updateDNSConfig(ctx, updateFunc); err != nil {
|
||||
return fmt.Errorf("error updating DNS config: %w", err)
|
||||
}
|
||||
return h.removeHeadlessSvcFinalizer(ctx, headlessSvc)
|
||||
}
|
||||
|
||||
func (dnsRR *dnsRecordsReconciler) removeHeadlessSvcFinalizer(ctx context.Context, headlessSvc *corev1.Service) error {
|
||||
idx := slices.Index(headlessSvc.Finalizers, dnsRecordsRecocilerFinalizer)
|
||||
if idx == -1 {
|
||||
return nil
|
||||
}
|
||||
headlessSvc.Finalizers = append(headlessSvc.Finalizers[:idx], headlessSvc.Finalizers[idx+1:]...)
|
||||
return dnsRR.Update(ctx, headlessSvc)
|
||||
}
|
||||
|
||||
// fqdnForDNSRecord returns MagicDNS name associated with a given headless Service.
|
||||
// If the headless Service is for a tailscale Ingress proxy, returns ingress.status.loadBalancer.ingress.hostname.
|
||||
// If the headless Service is for an tailscale egress proxy configured via tailscale.com/tailnet-fqdn annotation, returns the annotation value.
|
||||
// This function is not expected to be called with headless Services for other
|
||||
// proxy types, or any other Services, but it just returns an empty string if
|
||||
// that happens.
|
||||
func (dnsRR *dnsRecordsReconciler) fqdnForDNSRecord(ctx context.Context, headlessSvc *corev1.Service, logger *zap.SugaredLogger) (string, error) {
|
||||
parentName := parentFromObjectLabels(headlessSvc)
|
||||
if isManagedByType(headlessSvc, "ingress") {
|
||||
ing := new(networkingv1.Ingress)
|
||||
if err := dnsRR.Get(ctx, parentName, ing); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ing.Status.LoadBalancer.Ingress) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return ing.Status.LoadBalancer.Ingress[0].Hostname, nil
|
||||
}
|
||||
if isManagedByType(headlessSvc, "svc") {
|
||||
svc := new(corev1.Service)
|
||||
if err := dnsRR.Get(ctx, parentName, svc); apierrors.IsNotFound(err) {
|
||||
logger.Info("[unexpected] parent Service for egress proxy %s not found", headlessSvc.Name)
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return svc.Annotations[AnnotationTailnetTargetFQDN], nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// updateDNSConfig runs the provided update function against dnsrecords
|
||||
// ConfigMap. At this point the in-cluster ts.net nameserver is expected to be
|
||||
// successfully created together with the ConfigMap.
|
||||
func (dnsRR *dnsRecordsReconciler) updateDNSConfig(ctx context.Context, update func(*operatorutils.Records)) error {
|
||||
cm := &corev1.ConfigMap{}
|
||||
err := dnsRR.Get(ctx, types.NamespacedName{Name: operatorutils.DNSRecordsCMName, Namespace: dnsRR.tsNamespace}, cm)
|
||||
if apierrors.IsNotFound(err) {
|
||||
dnsRR.logger.Info("[unexpected] dnsrecords ConfigMap not found in cluster. Not updating DNS records. Please open an isue and attach operator logs.")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving dnsrecords ConfigMap: %w", err)
|
||||
}
|
||||
dnsRecords := operatorutils.Records{Version: operatorutils.Alpha1Version, IP4: map[string][]string{}}
|
||||
if cm.Data != nil && cm.Data[operatorutils.DNSRecordsCMKey] != "" {
|
||||
if err := json.Unmarshal([]byte(cm.Data[operatorutils.DNSRecordsCMKey]), &dnsRecords); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
update(&dnsRecords)
|
||||
dnsRecordsBs, err := json.Marshal(dnsRecords)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling DNS records: %w", err)
|
||||
}
|
||||
mak.Set(&cm.Data, operatorutils.DNSRecordsCMKey, string(dnsRecordsBs))
|
||||
return dnsRR.Update(ctx, cm)
|
||||
}
|
||||
|
||||
// isSvcForFQDNEgressProxy returns true if the Service is a headless Service
|
||||
// created for a proxy for a tailscale egress Service configured via
|
||||
// tailscale.com/tailnet-fqdn annotation.
|
||||
func (dnsRR *dnsRecordsReconciler) isSvcForFQDNEgressProxy(ctx context.Context, svc *corev1.Service) (bool, error) {
|
||||
if !isManagedByType(svc, "svc") {
|
||||
return false, nil
|
||||
}
|
||||
parentName := parentFromObjectLabels(svc)
|
||||
parentSvc := new(corev1.Service)
|
||||
if err := dnsRR.Get(ctx, parentName, parentSvc); apierrors.IsNotFound(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
annots := parentSvc.Annotations
|
||||
return annots != nil && annots[AnnotationTailnetTargetFQDN] != "", nil
|
||||
}
|
||||
198
cmd/k8s-operator/dnsrecords_test.go
Normal file
198
cmd/k8s-operator/dnsrecords_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/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"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/ptr"
|
||||
)
|
||||
|
||||
func TestDNSRecordsReconciler(t *testing.T) {
|
||||
// Preconfigure a cluster with a DNSConfig
|
||||
dnsConfig := &tsapi.DNSConfig{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
},
|
||||
TypeMeta: metav1.TypeMeta{Kind: "DNSConfig"},
|
||||
Spec: tsapi.DNSConfigSpec{
|
||||
Nameserver: &tsapi.Nameserver{},
|
||||
}}
|
||||
ing := &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "ts-ingress",
|
||||
Namespace: "test",
|
||||
},
|
||||
Spec: networkingv1.IngressSpec{
|
||||
IngressClassName: ptr.To("tailscale"),
|
||||
},
|
||||
Status: networkingv1.IngressStatus{
|
||||
LoadBalancer: networkingv1.IngressLoadBalancerStatus{
|
||||
Ingress: []networkingv1.IngressLoadBalancerIngress{{
|
||||
Hostname: "cluster.ingress.ts.net"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsrecords", Namespace: "tailscale"}}
|
||||
fc := fake.NewClientBuilder().
|
||||
WithScheme(tsapi.GlobalScheme).
|
||||
WithObjects(cm).
|
||||
WithObjects(dnsConfig).
|
||||
WithObjects(ing).
|
||||
WithStatusSubresource(dnsConfig, ing).
|
||||
Build()
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||
// Set the ready condition of the DNSConfig
|
||||
mustUpdateStatus[tsapi.DNSConfig](t, fc, "", "test", func(c *tsapi.DNSConfig) {
|
||||
operatorutils.SetDNSConfigCondition(c, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated, 0, cl, zl.Sugar())
|
||||
})
|
||||
dnsRR := &dnsRecordsReconciler{
|
||||
Client: fc,
|
||||
logger: zl.Sugar(),
|
||||
tsNamespace: "tailscale",
|
||||
}
|
||||
|
||||
// 1. DNS record is created for an egress proxy configured via
|
||||
// tailscale.com/tailnet-fqdn annotation
|
||||
egressSvcFQDN := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "egress-fqdn",
|
||||
Namespace: "test",
|
||||
Annotations: map[string]string{"tailscale.com/tailnet-fqdn": "foo.bar.ts.net"},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ExternalName: "unused",
|
||||
Type: corev1.ServiceTypeExternalName,
|
||||
},
|
||||
}
|
||||
headlessForEgressSvcFQDN := headlessSvcForParent(egressSvcFQDN, "svc") // create the proxy headless Service
|
||||
ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7")
|
||||
mustCreate(t, fc, egressSvcFQDN)
|
||||
mustCreate(t, fc, headlessForEgressSvcFQDN)
|
||||
mustCreate(t, fc, ep)
|
||||
expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
|
||||
// ConfigMap should now have a record for foo.bar.ts.net -> 10.8.8.7
|
||||
wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}}
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
|
||||
// 2. DNS record is updated if tailscale.com/tailnet-fqdn annotation's
|
||||
// value changes
|
||||
mustUpdate(t, fc, "test", "egress-fqdn", func(svc *corev1.Service) {
|
||||
svc.Annotations["tailscale.com/tailnet-fqdn"] = "baz.bar.ts.net"
|
||||
})
|
||||
expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
|
||||
wantHosts = map[string][]string{"baz.bar.ts.net": {"10.9.8.7"}}
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
|
||||
// 3. DNS record is updated if the IP address of the proxy Pod changes.
|
||||
ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4")
|
||||
mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
|
||||
ep.Endpoints[0].Addresses = []string{"10.6.5.4"}
|
||||
})
|
||||
expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
|
||||
wantHosts = map[string][]string{"baz.bar.ts.net": {"10.6.5.4"}}
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
|
||||
// 4. DNS record is created for an ingress proxy configured via Ingress
|
||||
headlessForIngress := headlessSvcForParent(ing, "ingress")
|
||||
ep = endpointSliceForService(headlessForIngress, "10.9.8.7")
|
||||
mustCreate(t, fc, headlessForIngress)
|
||||
mustCreate(t, fc, ep)
|
||||
expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service
|
||||
wantHosts["cluster.ingress.ts.net"] = []string{"10.9.8.7"}
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
|
||||
// 5. DNS records are updated if Ingress's MagicDNS name changes (i.e users changed spec.tls.hosts[0])
|
||||
t.Log("test case 5")
|
||||
mustUpdateStatus(t, fc, "test", "ts-ingress", func(ing *networkingv1.Ingress) {
|
||||
ing.Status.LoadBalancer.Ingress[0].Hostname = "another.ingress.ts.net"
|
||||
})
|
||||
expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service
|
||||
delete(wantHosts, "cluster.ingress.ts.net")
|
||||
wantHosts["another.ingress.ts.net"] = []string{"10.9.8.7"}
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
|
||||
// 6. DNS records are updated if Ingress proxy's Pod IP changes
|
||||
mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
|
||||
ep.Endpoints[0].Addresses = []string{"7.8.9.10"}
|
||||
})
|
||||
expectReconciled(t, dnsRR, "tailscale", "ts-ingress")
|
||||
wantHosts["another.ingress.ts.net"] = []string{"7.8.9.10"}
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
}
|
||||
|
||||
func headlessSvcForParent(o client.Object, typ string) *corev1.Service {
|
||||
return &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: o.GetName(),
|
||||
Namespace: "tailscale",
|
||||
Labels: map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelParentName: o.GetName(),
|
||||
LabelParentNamespace: o.GetNamespace(),
|
||||
LabelParentType: typ,
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "None",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
Selector: map[string]string{"foo": "bar"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func endpointSliceForService(svc *corev1.Service, ip string) *discoveryv1.EndpointSlice {
|
||||
return &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: svc.Name,
|
||||
Namespace: svc.Namespace,
|
||||
Labels: map[string]string{discoveryv1.LabelServiceName: svc.Name},
|
||||
},
|
||||
Endpoints: []discoveryv1.Endpoint{{
|
||||
Addresses: []string{ip},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func expectHostsRecords(t *testing.T, cl client.Client, wantsHosts map[string][]string) {
|
||||
t.Helper()
|
||||
cm := new(corev1.ConfigMap)
|
||||
if err := cl.Get(context.Background(), types.NamespacedName{Name: "dnsrecords", Namespace: "tailscale"}, cm); err != nil {
|
||||
t.Fatalf("getting dnsconfig ConfigMap: %v", err)
|
||||
}
|
||||
if cm.Data == nil {
|
||||
t.Fatal("dnsconfig ConfigMap has no data")
|
||||
}
|
||||
dnsConfigString, ok := cm.Data[operatorutils.DNSRecordsCMKey]
|
||||
if !ok {
|
||||
t.Fatal("dnsconfig ConfigMap does not contain dnsconfig")
|
||||
}
|
||||
dnsConfig := &operatorutils.Records{}
|
||||
if err := json.Unmarshal([]byte(dnsConfigString), dnsConfig); err != nil {
|
||||
t.Fatalf("unmarshaling dnsconfig: %v", err)
|
||||
}
|
||||
if diff := cmp.Diff(dnsConfig.IP4, wantsHosts); diff != "" {
|
||||
t.Fatalf("unexpected dns config (-got +want):\n%s", diff)
|
||||
}
|
||||
}
|
||||
@@ -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 -}}"
|
||||
@@ -36,10 +38,10 @@ func main() {
|
||||
}
|
||||
repoRoot := "../../"
|
||||
switch os.Args[1] {
|
||||
case "helmcrd": // insert CRD to Helm templates behind a installCRDs=true conditional check
|
||||
log.Print("Adding Connector CRD to Helm templates")
|
||||
case "helmcrd": // insert CRDs to Helm templates behind a installCRDs=true conditional check
|
||||
log.Print("Adding CRDs to Helm templates")
|
||||
if err := generate("./"); err != nil {
|
||||
log.Fatalf("error adding Connector CRD to Helm templates: %v", err)
|
||||
log.Fatalf("error adding CRDs to Helm templates: %v", err)
|
||||
}
|
||||
return
|
||||
case "staticmanifests": // generate static manifests from Helm templates (including the CRD)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
ServeConfig: sc,
|
||||
Tags: tags,
|
||||
ChildResourceLabels: crl,
|
||||
ProxyClass: proxyClass,
|
||||
ProxyClassName: proxyClass,
|
||||
}
|
||||
|
||||
if val := ing.GetAnnotations()[AnnotationExperimentalForwardClusterTrafficViaL7IngresProxy]; val == "true" {
|
||||
|
||||
@@ -88,12 +88,11 @@ func TestTailscaleIngress(t *testing.T) {
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
|
||||
opts := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "ingress",
|
||||
hostname: "default-test",
|
||||
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "ingress",
|
||||
hostname: "default-test",
|
||||
}
|
||||
serveConfig := &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
@@ -101,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, fc, 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.
|
||||
@@ -118,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,11 +125,8 @@ func TestTailscaleIngress(t *testing.T) {
|
||||
mak.Set(&ing.ObjectMeta.Annotations, AnnotationExperimentalForwardClusterTrafficViaL7IngresProxy, "true")
|
||||
})
|
||||
opts.shouldEnableForwardingClusterTrafficViaIngress = true
|
||||
// configfile hash changed at this point in test env only because we
|
||||
// lost auth key due to how changes are applied in test client.
|
||||
opts.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d"
|
||||
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) {
|
||||
@@ -223,12 +219,11 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "ingress")
|
||||
opts := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "ingress",
|
||||
hostname: "default-test",
|
||||
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "ingress",
|
||||
hostname: "default-test",
|
||||
}
|
||||
serveConfig := &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
@@ -236,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, fc, 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.
|
||||
@@ -246,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
|
||||
@@ -261,10 +256,7 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, ingR, "default", "test")
|
||||
opts.proxyClass = pc.Name
|
||||
// configfile hash changed at this point in test env only because we
|
||||
// lost auth key due to how changes are applied in test client.
|
||||
opts.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d"
|
||||
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
|
||||
@@ -274,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)
|
||||
}
|
||||
|
||||
283
cmd/k8s-operator/nameserver.go
Normal file
283
cmd/k8s-operator/nameserver.go
Normal file
@@ -0,0 +1,283 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
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."
|
||||
|
||||
defaultNameserverImageRepo = "tailscale/k8s-nameserver"
|
||||
// TODO (irbekrm): once we start publishing nameserver images for stable
|
||||
// track, replace 'unstable' here with the version of this operator
|
||||
// instance.
|
||||
defaultNameserverImageTag = "unstable"
|
||||
)
|
||||
|
||||
// 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.Nameserver = &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,
|
||||
imageRepo: defaultNameserverImageRepo,
|
||||
imageTag: defaultNameserverImageTag,
|
||||
}
|
||||
if tsDNSCfg.Spec.Nameserver.Image != nil && tsDNSCfg.Spec.Nameserver.Image.Repo != "" {
|
||||
dCfg.imageRepo = tsDNSCfg.Spec.Nameserver.Image.Repo
|
||||
}
|
||||
if tsDNSCfg.Spec.Nameserver.Image != nil && 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
|
||||
},
|
||||
}
|
||||
)
|
||||
127
cmd/k8s-operator/nameserver_test.go
Normal file
127
cmd/k8s-operator/nameserver_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// 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.Nameserver = &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", "dnsrecords", func(cm *corev1.ConfigMap) {
|
||||
mak.Set(&cm.Data, "records.json", string(bs))
|
||||
})
|
||||
expectReconciled(t, nr, "", "test")
|
||||
wantCm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "dnsrecords",
|
||||
Namespace: "tailscale", Labels: labels, OwnerReferences: []metav1.OwnerReference{*dnsCfgOwnerRef}},
|
||||
TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"},
|
||||
Data: map[string]string{"records.json": string(bs)},
|
||||
}
|
||||
expectEqual(t, fc, wantCm, nil)
|
||||
|
||||
// Verify that if dnsconfig.spec.nameserver.image.{repo,tag} are unset,
|
||||
// the nameserver image defaults to tailscale/k8s-nameserver:unstable.
|
||||
mustUpdate(t, fc, "", "test", func(dnsCfg *tsapi.DNSConfig) {
|
||||
dnsCfg.Spec.Nameserver.Image = nil
|
||||
})
|
||||
expectReconciled(t, nr, "", "test")
|
||||
wantsDeploy.Spec.Template.Spec.Containers[0].Image = "tailscale/k8s-nameserver:unstable"
|
||||
expectEqual(t, fc, wantsDeploy, nil)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/rest"
|
||||
@@ -44,12 +45,12 @@ import (
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
// Generate static manifests for deploying Tailscale operator on Kubernetes from the operator's Helm chart.
|
||||
//go:generate go run tailscale.com/cmd/k8s-operator/generate staticmanifests
|
||||
|
||||
// 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 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 CRD docs from the yamls
|
||||
//go:generate go run fybrik.io/crdoc --resources=./deploy/crds --output=../../k8s-operator/api.md
|
||||
|
||||
@@ -59,12 +60,13 @@ func main() {
|
||||
tailscale.I_Acknowledge_This_API_Is_Unstable = true
|
||||
|
||||
var (
|
||||
tsNamespace = defaultEnv("OPERATOR_NAMESPACE", "")
|
||||
tslogging = defaultEnv("OPERATOR_LOGGING", "info")
|
||||
image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest")
|
||||
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
|
||||
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
|
||||
tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "")
|
||||
tsNamespace = defaultEnv("OPERATOR_NAMESPACE", "")
|
||||
tslogging = defaultEnv("OPERATOR_LOGGING", "info")
|
||||
image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest")
|
||||
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
|
||||
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
|
||||
tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "")
|
||||
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
|
||||
)
|
||||
|
||||
var opts []kzap.Opts
|
||||
@@ -93,9 +95,19 @@ func main() {
|
||||
defer s.Close()
|
||||
restConfig := config.GetConfigOrDie()
|
||||
maybeLaunchAPIServerProxy(zlog, restConfig, s, mode)
|
||||
// TODO (irbekrm): gather the reconciler options into an opts struct
|
||||
// rather than passing a million of them in one by one.
|
||||
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode)
|
||||
rOpts := reconcilerOpts{
|
||||
log: zlog,
|
||||
tsServer: s,
|
||||
tsClient: tsClient,
|
||||
tailscaleNamespace: tsNamespace,
|
||||
restConfig: restConfig,
|
||||
proxyImage: image,
|
||||
proxyPriorityClassName: priorityClassName,
|
||||
proxyActAsDefaultLoadBalancer: isDefaultLoadBalancer,
|
||||
proxyTags: tags,
|
||||
proxyFirewallMode: tsFirewallMode,
|
||||
}
|
||||
runReconcilers(rOpts)
|
||||
}
|
||||
|
||||
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
|
||||
@@ -203,11 +215,8 @@ waitOnline:
|
||||
|
||||
// runReconcilers starts the controller-runtime manager and registers the
|
||||
// ServiceReconciler. It blocks forever.
|
||||
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string) {
|
||||
var (
|
||||
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
|
||||
)
|
||||
startlog := zlog.Named("startReconcilers")
|
||||
func runReconcilers(opts reconcilerOpts) {
|
||||
startlog := opts.log.Named("startReconcilers")
|
||||
// For secrets and statefulsets, we only get permission to touch the objects
|
||||
// in the controller's own namespace. This cannot be expressed by
|
||||
// .Watches(...) below, instead you have to add a per-type field selector to
|
||||
@@ -215,7 +224,7 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
// implicitly filter what parts of the world the builder code gets to see at
|
||||
// all.
|
||||
nsFilter := cache.ByObject{
|
||||
Field: client.InNamespace(tsNamespace).AsSelector(),
|
||||
Field: client.InNamespace(opts.tailscaleNamespace).AsSelector(),
|
||||
}
|
||||
mgrOpts := manager.Options{
|
||||
// TODO (irbekrm): stricter filtering what we watch/cache/call
|
||||
@@ -223,33 +232,37 @@ 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,
|
||||
&discoveryv1.EndpointSlice{}: nsFilter,
|
||||
},
|
||||
},
|
||||
Scheme: tsapi.GlobalScheme,
|
||||
}
|
||||
mgr, err := manager.New(restConfig, mgrOpts)
|
||||
mgr, err := manager.New(opts.restConfig, mgrOpts)
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create manager: %v", err)
|
||||
}
|
||||
|
||||
svcFilter := handler.EnqueueRequestsFromMapFunc(serviceHandler)
|
||||
svcChildFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("svc"))
|
||||
// If a ProxyClassChanges, enqueue all Services labeled with that
|
||||
// If a ProxyClass changes, enqueue all Services labeled with that
|
||||
// ProxyClass's name.
|
||||
proxyClassFilterForSvc := handler.EnqueueRequestsFromMapFunc(proxyClassHandlerForSvc(mgr.GetClient(), startlog))
|
||||
|
||||
eventRecorder := mgr.GetEventRecorderFor("tailscale-operator")
|
||||
ssr := &tailscaleSTSReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
tsnetServer: s,
|
||||
tsClient: tsClient,
|
||||
defaultTags: strings.Split(tags, ","),
|
||||
operatorNamespace: tsNamespace,
|
||||
proxyImage: image,
|
||||
proxyPriorityClassName: priorityClassName,
|
||||
tsFirewallMode: tsFirewallMode,
|
||||
tsnetServer: opts.tsServer,
|
||||
tsClient: opts.tsClient,
|
||||
defaultTags: strings.Split(opts.proxyTags, ","),
|
||||
operatorNamespace: opts.tailscaleNamespace,
|
||||
proxyImage: opts.proxyImage,
|
||||
proxyPriorityClassName: opts.proxyPriorityClassName,
|
||||
tsFirewallMode: opts.proxyFirewallMode,
|
||||
}
|
||||
err = builder.
|
||||
ControllerManagedBy(mgr).
|
||||
@@ -261,9 +274,10 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
Complete(&ServiceReconciler{
|
||||
ssr: ssr,
|
||||
Client: mgr.GetClient(),
|
||||
logger: zlog.Named("service-reconciler"),
|
||||
isDefaultLoadBalancer: isDefaultLoadBalancer,
|
||||
logger: opts.log.Named("service-reconciler"),
|
||||
isDefaultLoadBalancer: opts.proxyActAsDefaultLoadBalancer,
|
||||
recorder: eventRecorder,
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create service reconciler: %v", err)
|
||||
@@ -285,7 +299,7 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
ssr: ssr,
|
||||
recorder: eventRecorder,
|
||||
Client: mgr.GetClient(),
|
||||
logger: zlog.Named("ingress-reconciler"),
|
||||
logger: opts.log.Named("ingress-reconciler"),
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create ingress reconciler: %v", err)
|
||||
@@ -304,29 +318,201 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
ssr: ssr,
|
||||
recorder: eventRecorder,
|
||||
Client: mgr.GetClient(),
|
||||
logger: zlog.Named("connector-reconciler"),
|
||||
logger: opts.log.Named("connector-reconciler"),
|
||||
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: opts.tailscaleNamespace,
|
||||
Client: mgr.GetClient(),
|
||||
logger: opts.log.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{}).
|
||||
Complete(&ProxyClassReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
recorder: eventRecorder,
|
||||
logger: zlog.Named("proxyclass-reconciler"),
|
||||
logger: opts.log.Named("proxyclass-reconciler"),
|
||||
clock: tstime.DefaultClock{},
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatal("could not create proxyclass reconciler: %v", err)
|
||||
}
|
||||
logger := startlog.Named("dns-records-reconciler-event-handlers")
|
||||
// On EndpointSlice events, if it is an EndpointSlice for an
|
||||
// ingress/egress proxy headless Service, reconcile the headless
|
||||
// Service.
|
||||
dnsRREpsOpts := handler.EnqueueRequestsFromMapFunc(dnsRecordsReconcilerEndpointSliceHandler)
|
||||
// On DNSConfig changes, reconcile all headless Services for
|
||||
// ingress/egress proxies in operator namespace.
|
||||
dnsRRDNSConfigOpts := handler.EnqueueRequestsFromMapFunc(enqueueAllIngressEgressProxySvcsInNS(opts.tailscaleNamespace, mgr.GetClient(), logger))
|
||||
// On Service events, if it is an ingress/egress proxy headless Service, reconcile it.
|
||||
dnsRRServiceOpts := handler.EnqueueRequestsFromMapFunc(dnsRecordsReconcilerServiceHandler)
|
||||
// On Ingress events, if it is a tailscale Ingress or if tailscale is the default ingress controller, reconcile the proxy
|
||||
// headless Service.
|
||||
dnsRRIngressOpts := handler.EnqueueRequestsFromMapFunc(dnsRecordsReconcilerIngressHandler(opts.tailscaleNamespace, opts.proxyActAsDefaultLoadBalancer, mgr.GetClient(), logger))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
Named("dns-records-reconciler").
|
||||
Watches(&corev1.Service{}, dnsRRServiceOpts).
|
||||
Watches(&networkingv1.Ingress{}, dnsRRIngressOpts).
|
||||
Watches(&discoveryv1.EndpointSlice{}, dnsRREpsOpts).
|
||||
Watches(&tsapi.DNSConfig{}, dnsRRDNSConfigOpts).
|
||||
Complete(&dnsRecordsReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
logger: opts.log.Named("dns-records-reconciler"),
|
||||
isDefaultLoadBalancer: opts.proxyActAsDefaultLoadBalancer,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create DNS records reconciler: %v", err)
|
||||
}
|
||||
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
|
||||
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
|
||||
startlog.Fatalf("could not start manager: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type reconcilerOpts struct {
|
||||
log *zap.SugaredLogger
|
||||
tsServer *tsnet.Server
|
||||
tsClient *tailscale.Client
|
||||
tailscaleNamespace string // namespace in which operator resources will be deployed
|
||||
restConfig *rest.Config // config for connecting to the kube API server
|
||||
proxyImage string // <proxy-image-repo>:<proxy-image-tag>
|
||||
// proxyPriorityClassName isPriorityClass to be set for proxy Pods. This
|
||||
// is a legacy mechanism for cluster resource configuration options -
|
||||
// going forward use ProxyClass.
|
||||
// https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/#priorityclass
|
||||
proxyPriorityClassName string
|
||||
// proxyTags are ACL tags to tag proxy auth keys. Multiple tags should
|
||||
// be provided as a string with comma-separated tag values. Proxy tags
|
||||
// default to tag:k8s.
|
||||
// https://tailscale.com/kb/1085/auth-keys
|
||||
proxyTags string
|
||||
// proxyActAsDefaultLoadBalancer determines whether this operator
|
||||
// instance should act as the default ingress controller when looking at
|
||||
// Ingress resources with unset ingress.spec.ingressClassName.
|
||||
// TODO (irbekrm): this setting does not respect the default
|
||||
// IngressClass.
|
||||
// https://kubernetes.io/docs/concepts/services-networking/ingress/#default-ingress-class
|
||||
// We should fix that and preferably integrate with that mechanism as
|
||||
// well - perhaps make the operator itself create the default
|
||||
// IngressClass if this is set to true.
|
||||
proxyActAsDefaultLoadBalancer bool
|
||||
// proxyFirewallMode determines whether non-userspace proxies should use
|
||||
// iptables or nftables for firewall configuration. Accepted values are
|
||||
// iptables, nftables and auto. If set to auto, proxy will automatically
|
||||
// determine which mode is supported for a given host (prefer nftables).
|
||||
// Auto is usually the best choice, unless you want to explicitly set
|
||||
// specific mode for debugging purposes.
|
||||
proxyFirewallMode string
|
||||
}
|
||||
|
||||
// enqueueAllIngressEgressProxySvcsinNS returns a reconcile request for each
|
||||
// ingress/egress proxy headless Service found in the provided namespace.
|
||||
func enqueueAllIngressEgressProxySvcsInNS(ns string, cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
|
||||
return func(ctx context.Context, _ client.Object) []reconcile.Request {
|
||||
reqs := make([]reconcile.Request, 0)
|
||||
|
||||
// Get all headless Services for proxies configured using Service.
|
||||
svcProxyLabels := map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelParentType: "svc",
|
||||
}
|
||||
svcHeadlessSvcList := &corev1.ServiceList{}
|
||||
if err := cl.List(ctx, svcHeadlessSvcList, client.InNamespace(ns), client.MatchingLabels(svcProxyLabels)); err != nil {
|
||||
logger.Errorf("error listing headless Services for tailscale ingress/egress Services in operator namespace: %v", err)
|
||||
return nil
|
||||
}
|
||||
for _, svc := range svcHeadlessSvcList.Items {
|
||||
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: svc.Namespace, Name: svc.Name}})
|
||||
}
|
||||
|
||||
// Get all headless Services for proxies configured using Ingress.
|
||||
ingProxyLabels := map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelParentType: "ingress",
|
||||
}
|
||||
ingHeadlessSvcList := &corev1.ServiceList{}
|
||||
if err := cl.List(ctx, ingHeadlessSvcList, client.InNamespace(ns), client.MatchingLabels(ingProxyLabels)); err != nil {
|
||||
logger.Errorf("error listing headless Services for tailscale Ingresses in operator namespace: %v", err)
|
||||
return nil
|
||||
}
|
||||
for _, svc := range ingHeadlessSvcList.Items {
|
||||
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: svc.Namespace, Name: svc.Name}})
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
}
|
||||
|
||||
// dnsRecordsReconciler filters EndpointSlice events for which
|
||||
// dns-records-reconciler should reconcile a headless Service. The only events
|
||||
// it should reconcile are those for EndpointSlices associated with proxy
|
||||
// headless Services.
|
||||
func dnsRecordsReconcilerEndpointSliceHandler(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
if !isManagedByType(o, "svc") && !isManagedByType(o, "ingress") {
|
||||
return nil
|
||||
}
|
||||
headlessSvcName, ok := o.GetLabels()[discoveryv1.LabelServiceName] // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: o.GetNamespace(), Name: headlessSvcName}}}
|
||||
}
|
||||
|
||||
// dnsRecordsReconcilerServiceHandler filters Service events for which
|
||||
// dns-records-reconciler should reconcile. If the event is for a cluster
|
||||
// ingress/cluster egress proxy's headless Service, returns the Service for
|
||||
// reconcile.
|
||||
func dnsRecordsReconcilerServiceHandler(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
if isManagedByType(o, "svc") || isManagedByType(o, "ingress") {
|
||||
return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: o.GetNamespace(), Name: o.GetName()}}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dnsRecordsReconcilerIngressHandler filters Ingress events to ensure that
|
||||
// dns-records-reconciler only reconciles on tailscale Ingress events. When an
|
||||
// event is observed on a tailscale Ingress, reconcile the proxy headless Service.
|
||||
func dnsRecordsReconcilerIngressHandler(ns string, isDefaultLoadBalancer bool, cl client.Client, logger *zap.SugaredLogger) handler.MapFunc {
|
||||
return func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
ing, ok := o.(*networkingv1.Ingress)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !isDefaultLoadBalancer && (ing.Spec.IngressClassName == nil || *ing.Spec.IngressClassName != "tailscale") {
|
||||
return nil
|
||||
}
|
||||
proxyResourceLabels := childResourceLabels(ing.Name, ing.Namespace, "ingress")
|
||||
headlessSvc, err := getSingleObject[corev1.Service](ctx, cl, ns, proxyResourceLabels)
|
||||
if err != nil {
|
||||
logger.Errorf("error getting headless Service from parent labels: %v", err)
|
||||
return nil
|
||||
}
|
||||
if headlessSvc == nil {
|
||||
return nil
|
||||
}
|
||||
return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: headlessSvc.Namespace, Name: headlessSvc.Name}}}
|
||||
}
|
||||
}
|
||||
|
||||
type tsClient interface {
|
||||
CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error)
|
||||
DeleteDevice(ctx context.Context, nodeStableID string) error
|
||||
|
||||
@@ -20,7 +20,9 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/net/dns/resolvconffile"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
@@ -71,12 +73,11 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, fc, 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
|
||||
@@ -119,7 +120,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.
|
||||
@@ -158,7 +159,7 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
@@ -213,12 +214,11 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
parentType: "svc",
|
||||
tailnetTargetFQDN: tailnetTargetFQDN,
|
||||
hostname: "default-test",
|
||||
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, fc, 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",
|
||||
@@ -239,10 +239,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, fc, 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
|
||||
@@ -324,12 +324,11 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
parentType: "svc",
|
||||
tailnetTargetIP: tailnetTargetIP,
|
||||
hostname: "default-test",
|
||||
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, fc, 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",
|
||||
@@ -350,10 +349,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, fc, 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
|
||||
@@ -432,12 +431,11 @@ func TestAnnotations(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, fc, 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",
|
||||
@@ -457,7 +455,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.
|
||||
@@ -489,7 +487,7 @@ func TestAnnotations(t *testing.T) {
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestAnnotationIntoLB(t *testing.T) {
|
||||
@@ -541,12 +539,11 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, fc, 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
|
||||
@@ -579,7 +576,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.
|
||||
@@ -590,10 +587,8 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
// None of the proxy machinery should have changed...
|
||||
// (although configfile hash will change in test env only because we lose auth key due to out test not syncing secret.StringData -> secret.Data)
|
||||
o.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d"
|
||||
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{
|
||||
@@ -625,7 +620,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestLBIntoAnnotation(t *testing.T) {
|
||||
@@ -675,12 +670,11 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, fc, 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
|
||||
@@ -723,7 +717,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.
|
||||
@@ -742,12 +736,8 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
// configfile hash changes on a re-apply in this case in tests only as
|
||||
// we lose the auth key due to the test apply not syncing
|
||||
// secret.StringData -> Data.
|
||||
o.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d"
|
||||
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{
|
||||
@@ -768,7 +758,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestCustomHostname(t *testing.T) {
|
||||
@@ -821,12 +811,11 @@ func TestCustomHostname(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "reindeer-flotilla",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
confFileHash: "42376226c7d76ed6d6318315dc6c402f7d993bc0b01a5b0e6c8a833106b7509e",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, o))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSecret(t, fc, 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",
|
||||
@@ -847,7 +836,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.
|
||||
@@ -882,7 +871,7 @@ func TestCustomHostname(t *testing.T) {
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want)
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
|
||||
func TestCustomPriorityClassName(t *testing.T) {
|
||||
@@ -937,20 +926,23 @@ func TestCustomPriorityClassName(t *testing.T) {
|
||||
hostname: "tailscale-critical",
|
||||
priorityClassName: "custom-priority-class-name",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
confFileHash: "13cdef0d5f6f0f2406af028710ea1e0f99f65aba4021e4e70ac75a73cf141fd1",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
}
|
||||
|
||||
func TestProxyClassForService(t *testing.T) {
|
||||
// Setup
|
||||
pc := &tsapi.ProxyClass{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "custom-metadata"},
|
||||
Spec: tsapi.ProxyClassSpec{StatefulSet: &tsapi.StatefulSet{
|
||||
Labels: map[string]string{"foo": "bar"},
|
||||
Annotations: map[string]string{"bar.io/foo": "some-val"},
|
||||
Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}},
|
||||
Spec: tsapi.ProxyClassSpec{
|
||||
TailscaleConfig: &tsapi.TailscaleConfig{
|
||||
AcceptRoutes: true,
|
||||
},
|
||||
StatefulSet: &tsapi.StatefulSet{
|
||||
Labels: map[string]string{"foo": "bar"},
|
||||
Annotations: map[string]string{"bar.io/foo": "some-val"},
|
||||
Pod: &tsapi.Pod{Annotations: map[string]string{"foo.io/bar": "some-val"}}}},
|
||||
}
|
||||
fc := fake.NewClientBuilder().
|
||||
WithScheme(tsapi.GlobalScheme).
|
||||
@@ -1000,11 +992,10 @@ func TestProxyClassForService(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
|
||||
}
|
||||
expectEqual(t, fc, expectedSecret(t, opts))
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSecret(t, fc, 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
|
||||
@@ -1013,7 +1004,8 @@ 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)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
|
||||
// 3. ProxyClass is set to Ready, the Service gets reconciled by the
|
||||
// services-reconciler and the customization from the ProxyClass is
|
||||
@@ -1027,12 +1019,9 @@ func TestProxyClassForService(t *testing.T) {
|
||||
}}}
|
||||
})
|
||||
opts.proxyClass = pc.Name
|
||||
// configfile hash changes on a second apply in test env only because we
|
||||
// lose auth key due to out test not syncing secret.StringData ->
|
||||
// secret.Data
|
||||
opts.confFileHash = "fb9006e30ecda75e88c29dcd0ca2dd28a2ae964d001c66e1be3efe159cc3821d"
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), removeAuthKeyIfExistsModifier(t))
|
||||
|
||||
// 4. tailscale.com/proxy-class label is removed from the Service, the
|
||||
// configuration from the ProxyClass is removed from the cluster
|
||||
@@ -1042,7 +1031,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) {
|
||||
@@ -1086,7 +1075,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,
|
||||
@@ -1094,9 +1083,9 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
|
||||
}
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o))
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
}
|
||||
|
||||
func TestProxyFirewallMode(t *testing.T) {
|
||||
@@ -1148,12 +1137,71 @@ func TestProxyFirewallMode(t *testing.T) {
|
||||
hostname: "default-test",
|
||||
firewallMode: "nftables",
|
||||
clusterTargetIP: "10.20.30.40",
|
||||
confFileHash: "6cceb342cd3e1c56cd1bd94c29df63df3653c35fe98a7e7afcdee0dcaa2ad549",
|
||||
}
|
||||
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: "e09bededa0379920141cbd0b0dbdf9b8b66545877f9e8397423f5ce3e1ba439e",
|
||||
}
|
||||
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 = "5d754cf55463135ee34aa9821f2fd8483b53eb0570c3740c84a086304f427684"
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), nil)
|
||||
}
|
||||
func Test_isMagicDNSName(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
@@ -1311,3 +1359,148 @@ func Test_serviceHandlerForIngress(t *testing.T) {
|
||||
t.Errorf("unexpected reconcile request for a Service that does not belong to any Ingress: %#+v\n", gotReqs)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_clusterDomainFromResolverConf(t *testing.T) {
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
conf *resolvconffile.Config
|
||||
namespace string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "success- custom domain",
|
||||
conf: &resolvconffile.Config{
|
||||
SearchDomains: []dnsname.FQDN{toFQDN(t, "foo.svc.department.org.io"), toFQDN(t, "svc.department.org.io"), toFQDN(t, "department.org.io")},
|
||||
},
|
||||
namespace: "foo",
|
||||
want: "department.org.io",
|
||||
},
|
||||
{
|
||||
name: "success- default domain",
|
||||
conf: &resolvconffile.Config{
|
||||
SearchDomains: []dnsname.FQDN{toFQDN(t, "foo.svc.cluster.local."), toFQDN(t, "svc.cluster.local."), toFQDN(t, "cluster.local.")},
|
||||
},
|
||||
namespace: "foo",
|
||||
want: "cluster.local",
|
||||
},
|
||||
{
|
||||
name: "only two search domains found",
|
||||
conf: &resolvconffile.Config{
|
||||
SearchDomains: []dnsname.FQDN{toFQDN(t, "svc.department.org.io"), toFQDN(t, "department.org.io")},
|
||||
},
|
||||
namespace: "foo",
|
||||
want: "cluster.local",
|
||||
},
|
||||
{
|
||||
name: "first search domain does not match the expected structure",
|
||||
conf: &resolvconffile.Config{
|
||||
SearchDomains: []dnsname.FQDN{toFQDN(t, "foo.bar.department.org.io"), toFQDN(t, "svc.department.org.io"), toFQDN(t, "some.other.fqdn")},
|
||||
},
|
||||
namespace: "foo",
|
||||
want: "cluster.local",
|
||||
},
|
||||
{
|
||||
name: "second search domain does not match the expected structure",
|
||||
conf: &resolvconffile.Config{
|
||||
SearchDomains: []dnsname.FQDN{toFQDN(t, "foo.svc.department.org.io"), toFQDN(t, "foo.department.org.io"), toFQDN(t, "some.other.fqdn")},
|
||||
},
|
||||
namespace: "foo",
|
||||
want: "cluster.local",
|
||||
},
|
||||
{
|
||||
name: "third search domain does not match the expected structure",
|
||||
conf: &resolvconffile.Config{
|
||||
SearchDomains: []dnsname.FQDN{toFQDN(t, "foo.svc.department.org.io"), toFQDN(t, "svc.department.org.io"), toFQDN(t, "some.other.fqdn")},
|
||||
},
|
||||
namespace: "foo",
|
||||
want: "cluster.local",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := clusterDomainFromResolverConf(tt.conf, tt.namespace, zl.Sugar()); got != tt.want {
|
||||
t.Errorf("clusterDomainFromResolverConf() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_externalNameService(t *testing.T) {
|
||||
fc := fake.NewFakeClient()
|
||||
ft := &fakeTSClient{}
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 1. A External name Service that should be exposed via Tailscale gets
|
||||
// created.
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
Client: fc,
|
||||
tsClient: ft,
|
||||
defaultTags: []string{"tag:k8s"},
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
}
|
||||
|
||||
// 1. Create an ExternalName 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"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationExpose: "true",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Type: corev1.ServiceTypeExternalName,
|
||||
ExternalName: "foo.com",
|
||||
},
|
||||
})
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
opts := configOpts{
|
||||
stsName: shortName,
|
||||
secretName: fullName,
|
||||
namespace: "default",
|
||||
parentType: "svc",
|
||||
hostname: "default-test",
|
||||
clusterTargetDNS: "foo.com",
|
||||
}
|
||||
|
||||
expectEqual(t, fc, expectedSecret(t, fc, opts), nil)
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
// 2. Change the ExternalName and verify that changes get propagated.
|
||||
mustUpdate(t, sr, "default", "test", func(s *corev1.Service) {
|
||||
s.Spec.ExternalName = "bar.com"
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
opts.clusterTargetDNS = "bar.com"
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
}
|
||||
|
||||
func toFQDN(t *testing.T, s string) dnsname.FQDN {
|
||||
t.Helper()
|
||||
fqdn, err := dnsname.ToFQDN(s)
|
||||
if err != nil {
|
||||
t.Fatalf("error coverting %q to dnsname.FQDN: %v", s, err)
|
||||
}
|
||||
return fqdn
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"k8s.io/client-go/transport"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
tskube "tailscale.com/kube"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/util/clientmetric"
|
||||
@@ -207,32 +208,24 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLo
|
||||
}
|
||||
|
||||
const (
|
||||
capabilityName = "tailscale.com/cap/kubernetes"
|
||||
oldCapabilityName = "https://" + capabilityName
|
||||
// oldCapabilityName is a legacy form of
|
||||
// tailfcg.PeerCapabilityKubernetes capability. The only capability rule
|
||||
// that is respected for this form is group impersonation - for
|
||||
// backwards compatibility reasons.
|
||||
// TODO (irbekrm): determine if anyone uses this and remove if possible.
|
||||
oldCapabilityName = "https://" + tailcfg.PeerCapabilityKubernetes
|
||||
)
|
||||
|
||||
type capRule struct {
|
||||
// Impersonate is a list of rules that specify how to impersonate the caller
|
||||
// when proxying to the Kubernetes API.
|
||||
Impersonate *impersonateRule `json:"impersonate,omitempty"`
|
||||
}
|
||||
|
||||
// TODO(maisem): move this to some well-known location so that it can be shared
|
||||
// with control.
|
||||
type impersonateRule struct {
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// addImpersonationHeaders adds the appropriate headers to r to impersonate the
|
||||
// caller when proxying to the Kubernetes API. It uses the WhoIsResponse stashed
|
||||
// in the context by the apiserverProxy.
|
||||
func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error {
|
||||
log = log.With("remote", r.RemoteAddr)
|
||||
who := whoIsKey.Value(r.Context())
|
||||
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, capabilityName)
|
||||
rules, err := tailcfg.UnmarshalCapJSON[tskube.KubernetesCapRule](who.CapMap, tailcfg.PeerCapabilityKubernetes)
|
||||
if len(rules) == 0 && err == nil {
|
||||
// Try the old capability name for backwards compatibility.
|
||||
rules, err = tailcfg.UnmarshalCapJSON[capRule](who.CapMap, oldCapabilityName)
|
||||
rules, err = tailcfg.UnmarshalCapJSON[tskube.KubernetesCapRule](who.CapMap, oldCapabilityName)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal capability: %v", err)
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestImpersonationHeaders(t *testing.T) {
|
||||
name: "user-with-cap",
|
||||
emailish: "foo@example.com",
|
||||
capMap: tailcfg.PeerCapMap{
|
||||
capabilityName: {
|
||||
tailcfg.PeerCapabilityKubernetes: {
|
||||
tailcfg.RawMessage(`{"impersonate":{"groups":["group1","group2"]}}`),
|
||||
tailcfg.RawMessage(`{"impersonate":{"groups":["group1","group3"]}}`), // One group is duplicated.
|
||||
tailcfg.RawMessage(`{"impersonate":{"groups":["group4"]}}`),
|
||||
@@ -71,7 +71,7 @@ func TestImpersonationHeaders(t *testing.T) {
|
||||
emailish: "tagged-device",
|
||||
tags: []string{"tag:foo", "tag:bar"},
|
||||
capMap: tailcfg.PeerCapMap{
|
||||
capabilityName: {
|
||||
tailcfg.PeerCapabilityKubernetes: {
|
||||
tailcfg.RawMessage(`{"impersonate":{"groups":["group1"]}}`),
|
||||
},
|
||||
},
|
||||
@@ -80,12 +80,26 @@ func TestImpersonationHeaders(t *testing.T) {
|
||||
"Impersonate-User": {"node.ts.net"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mix-of-caps",
|
||||
emailish: "tagged-device",
|
||||
tags: []string{"tag:foo", "tag:bar"},
|
||||
capMap: tailcfg.PeerCapMap{
|
||||
tailcfg.PeerCapabilityKubernetes: {
|
||||
tailcfg.RawMessage(`{"impersonate":{"groups":["group1"]},"recorder":["tag:foo"],"enforceRecorder":true}`),
|
||||
},
|
||||
},
|
||||
wantHeaders: http.Header{
|
||||
"Impersonate-Group": {"group1"},
|
||||
"Impersonate-User": {"node.ts.net"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad-cap",
|
||||
emailish: "tagged-device",
|
||||
tags: []string{"tag:foo", "tag:bar"},
|
||||
capMap: tailcfg.PeerCapMap{
|
||||
capabilityName: {
|
||||
tailcfg.PeerCapabilityKubernetes: {
|
||||
tailcfg.RawMessage(`[]`),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// tailscale-operator provides a way to expose services running in a Kubernetes
|
||||
// cluster to your Tailnet.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
dockerref "github.com/distribution/reference"
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
@@ -30,7 +30,9 @@ import (
|
||||
const (
|
||||
reasonProxyClassInvalid = "ProxyClassInvalid"
|
||||
reasonProxyClassValid = "ProxyClassValid"
|
||||
reasonCustomTSEnvVar = "CustomTSEnvVar"
|
||||
messageProxyClassInvalid = "ProxyClass is not valid: %v"
|
||||
messageCustomTSEnvVar = "ProxyClass overrides the default value for %s env var for %s container. Running with custom values for Tailscale env vars is not recommended and might break in the future."
|
||||
)
|
||||
|
||||
type ProxyClassReconciler struct {
|
||||
@@ -98,6 +100,33 @@ func (a *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations field.
|
||||
violations = append(violations, errs...)
|
||||
}
|
||||
}
|
||||
if tc := pod.TailscaleContainer; tc != nil {
|
||||
for _, e := range tc.Env {
|
||||
if strings.HasPrefix(string(e.Name), "TS_") {
|
||||
a.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
|
||||
}
|
||||
if strings.EqualFold(string(e.Name), "EXPERIMENTAL_TS_CONFIGFILE_PATH") {
|
||||
a.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
|
||||
}
|
||||
if strings.EqualFold(string(e.Name), "EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS") {
|
||||
a.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
|
||||
}
|
||||
}
|
||||
if tc.Image != "" {
|
||||
// Same validation as used by kubelet https://github.com/kubernetes/kubernetes/blob/release-1.30/pkg/kubelet/images/image_manager.go#L212
|
||||
if _, err := dockerref.ParseNormalizedNamed(tc.Image); err != nil {
|
||||
violations = append(violations, field.TypeInvalid(field.NewPath("spec", "statefulSet", "pod", "tailscaleContainer", "image"), tc.Image, err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
if tc := pod.TailscaleInitContainer; tc != nil {
|
||||
if tc.Image != "" {
|
||||
// Same validation as used by kubelet https://github.com/kubernetes/kubernetes/blob/release-1.30/pkg/kubelet/images/image_manager.go#L212
|
||||
if _, err := dockerref.ParseNormalizedNamed(tc.Image); err != nil {
|
||||
violations = append(violations, field.TypeInvalid(field.NewPath("spec", "statefulSet", "pod", "tailscaleInitContainer", "image"), tc.Image, err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// We do not validate embedded fields (security context, resource
|
||||
|
||||
@@ -38,6 +38,11 @@ func TestProxyClass(t *testing.T) {
|
||||
Pod: &tsapi.Pod{
|
||||
Labels: map[string]string{"foo": "bar", "xyz1234": "abc567"},
|
||||
Annotations: map[string]string{"foo.io/bar": "{'key': 'val1232'}"},
|
||||
TailscaleContainer: &tsapi.Container{
|
||||
Env: []tsapi.Env{{Name: "FOO", Value: "BAR"}},
|
||||
ImagePullPolicy: "IfNotPresent",
|
||||
Image: "ghcr.my-repo/tailscale:v0.01testsomething",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -51,16 +56,17 @@ func TestProxyClass(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fr := record.NewFakeRecorder(3) // bump this if you expect a test case to throw more events
|
||||
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||
pcr := &ProxyClassReconciler{
|
||||
Client: fc,
|
||||
logger: zl.Sugar(),
|
||||
clock: cl,
|
||||
recorder: record.NewFakeRecorder(1),
|
||||
recorder: fr,
|
||||
}
|
||||
expectReconciled(t, pcr, "", "test")
|
||||
|
||||
// 1. A valid ProxyClass resource gets its status updated to Ready.
|
||||
expectReconciled(t, pcr, "", "test")
|
||||
pc.Status.Conditions = append(pc.Status.Conditions, tsapi.ConnectorCondition{
|
||||
Type: tsapi.ProxyClassready,
|
||||
Status: metav1.ConditionTrue,
|
||||
@@ -69,9 +75,9 @@ 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.
|
||||
// 2. A ProxyClass resource with invalid labels gets its status updated to Invalid with an error message.
|
||||
pc.Spec.StatefulSet.Labels["foo"] = "?!someVal"
|
||||
mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) {
|
||||
proxyClass.Spec.StatefulSet.Labels = pc.Spec.StatefulSet.Labels
|
||||
@@ -79,5 +85,52 @@ 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)
|
||||
expectedEvent := "Warning ProxyClassInvalid 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])?')"
|
||||
expectEvents(t, fr, []string{expectedEvent})
|
||||
|
||||
// 3. A ProxyClass resource with invalid image reference gets it status updated to Invalid with an error message.
|
||||
pc.Spec.StatefulSet.Labels = nil
|
||||
pc.Spec.StatefulSet.Pod.TailscaleContainer.Image = "FOO bar"
|
||||
mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) {
|
||||
proxyClass.Spec.StatefulSet.Labels = nil
|
||||
proxyClass.Spec.StatefulSet.Pod.TailscaleContainer.Image = pc.Spec.StatefulSet.Pod.TailscaleContainer.Image
|
||||
})
|
||||
expectReconciled(t, pcr, "", "test")
|
||||
msg = `ProxyClass is not valid: spec.statefulSet.pod.tailscaleContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase`
|
||||
tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassready, metav1.ConditionFalse, reasonProxyClassInvalid, msg, 0, cl, zl.Sugar())
|
||||
expectEqual(t, fc, pc, nil)
|
||||
expectedEvent = `Warning ProxyClassInvalid ProxyClass is not valid: spec.statefulSet.pod.tailscaleContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase`
|
||||
expectEvents(t, fr, []string{expectedEvent})
|
||||
|
||||
// 4. A ProxyClass resource with invalid init container image reference gets it status updated to Invalid with an error message.
|
||||
pc.Spec.StatefulSet.Labels = nil
|
||||
pc.Spec.StatefulSet.Pod.TailscaleContainer.Image = ""
|
||||
pc.Spec.StatefulSet.Pod.TailscaleInitContainer = &tsapi.Container{
|
||||
Image: "FOO bar",
|
||||
}
|
||||
mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) {
|
||||
proxyClass.Spec.StatefulSet.Pod.TailscaleContainer.Image = pc.Spec.StatefulSet.Pod.TailscaleContainer.Image
|
||||
proxyClass.Spec.StatefulSet.Pod.TailscaleInitContainer = &tsapi.Container{
|
||||
Image: pc.Spec.StatefulSet.Pod.TailscaleInitContainer.Image,
|
||||
}
|
||||
})
|
||||
expectReconciled(t, pcr, "", "test")
|
||||
msg = `ProxyClass is not valid: spec.statefulSet.pod.tailscaleInitContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase`
|
||||
tsoperator.SetProxyClassCondition(pc, tsapi.ProxyClassready, metav1.ConditionFalse, reasonProxyClassInvalid, msg, 0, cl, zl.Sugar())
|
||||
expectEqual(t, fc, pc, nil)
|
||||
expectedEvent = `Warning ProxyClassInvalid ProxyClass is not valid: spec.statefulSet.pod.tailscaleInitContainer.image: Invalid value: "FOO bar": invalid reference format: repository name (library/FOO bar) must be lowercase`
|
||||
expectEvents(t, fr, []string{expectedEvent})
|
||||
|
||||
// 5. An valid ProxyClass but with a Tailscale env vars set results in warning events.
|
||||
pc.Spec.StatefulSet.Pod.TailscaleInitContainer.Image = "" // unset previous test
|
||||
mustUpdate(t, fc, "", "test", func(proxyClass *tsapi.ProxyClass) {
|
||||
proxyClass.Spec.StatefulSet.Pod.TailscaleInitContainer.Image = pc.Spec.StatefulSet.Pod.TailscaleInitContainer.Image
|
||||
proxyClass.Spec.StatefulSet.Pod.TailscaleContainer.Env = []tsapi.Env{{Name: "TS_USERSPACE", Value: "true"}, {Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH"}, {Name: "EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS"}}
|
||||
})
|
||||
expectedEvents := []string{"Warning CustomTSEnvVar ProxyClass overrides the default value for TS_USERSPACE env var for tailscale container. Running with custom values for Tailscale env vars is not recommended and might break in the future.",
|
||||
"Warning CustomTSEnvVar ProxyClass overrides the default value for EXPERIMENTAL_TS_CONFIGFILE_PATH env var for tailscale container. Running with custom values for Tailscale env vars is not recommended and might break in the future.",
|
||||
"Warning CustomTSEnvVar ProxyClass overrides the default value for EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS env var for tailscale container. Running with custom values for Tailscale env vars is not recommended and might break in the future."}
|
||||
expectReconciled(t, pcr, "", "test")
|
||||
expectEvents(t, fr, expectedEvents)
|
||||
}
|
||||
|
||||
@@ -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,14 +87,11 @@ const (
|
||||
// ensure that it does not get removed when a ProxyClass configuration
|
||||
// is applied.
|
||||
podAnnotationLastSetClusterIP = "tailscale.com/operator-last-set-cluster-ip"
|
||||
podAnnotationLastSetClusterDNSName = "tailscale.com/operator-last-set-cluster-dns-name"
|
||||
podAnnotationLastSetTailnetTargetIP = "tailscale.com/operator-last-set-ts-tailnet-target-ip"
|
||||
podAnnotationLastSetTailnetTargetFQDN = "tailscale.com/operator-last-set-ts-tailnet-target-fqdn"
|
||||
// podAnnotationLastSetConfigFileHash is sha256 hash of the current tailscaled configuration contents.
|
||||
podAnnotationLastSetConfigFileHash = "tailscale.com/operator-last-set-config-file-hash"
|
||||
|
||||
// tailscaledConfigKey is the name of the key in proxy Secret Data that
|
||||
// holds the tailscaled config contents.
|
||||
tailscaledConfigKey = "tailscaled"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -108,8 +106,9 @@ type tailscaleSTSConfig struct {
|
||||
ParentResourceUID string
|
||||
ChildResourceLabels map[string]string
|
||||
|
||||
ServeConfig *ipn.ServeConfig // if serve config is set, this is a proxy for Ingress
|
||||
ClusterTargetIP string // ingress target
|
||||
ServeConfig *ipn.ServeConfig // if serve config is set, this is a proxy for Ingress
|
||||
ClusterTargetIP string // ingress target IP
|
||||
ClusterTargetDNSName string // ingress target DNS name
|
||||
// If set to true, operator should configure containerboot to forward
|
||||
// cluster traffic via the proxy set up for Kubernetes Ingress.
|
||||
ForwardClusterTrafficViaL7IngressProxy bool
|
||||
@@ -125,7 +124,9 @@ type tailscaleSTSConfig struct {
|
||||
// what this StatefulSet should be created for.
|
||||
Connector *connector
|
||||
|
||||
ProxyClass string
|
||||
ProxyClassName string // name of ProxyClass if one needs to be applied to the proxy
|
||||
|
||||
ProxyClass *tsapi.ProxyClass // ProxyClass that needs to be applied to the proxy (if there is one)
|
||||
}
|
||||
|
||||
type connector struct {
|
||||
@@ -171,11 +172,23 @@ func (a *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.Suga
|
||||
return nil, fmt.Errorf("failed to reconcile headless service: %w", err)
|
||||
}
|
||||
|
||||
secretName, tsConfigHash, err := a.createOrGetSecret(ctx, logger, sts, hsvc)
|
||||
proxyClass := new(tsapi.ProxyClass)
|
||||
if sts.ProxyClassName != "" {
|
||||
if err := a.Get(ctx, types.NamespacedName{Name: sts.ProxyClassName}, proxyClass); err != nil {
|
||||
return nil, fmt.Errorf("failed to get ProxyClass: %w", err)
|
||||
}
|
||||
if !tsoperator.ProxyClassIsReady(proxyClass) {
|
||||
logger.Infof("ProxyClass %s specified for the proxy, but it is not (yet) in a ready state, waiting..")
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
sts.ProxyClass = proxyClass
|
||||
|
||||
secretName, tsConfigHash, configs, err := a.createOrGetSecret(ctx, logger, sts, hsvc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create or get API key secret: %w", err)
|
||||
}
|
||||
_, err = a.reconcileSTS(ctx, logger, sts, hsvc, secretName, tsConfigHash)
|
||||
_, err = a.reconcileSTS(ctx, logger, sts, hsvc, secretName, tsConfigHash, configs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reconcile statefulset: %w", err)
|
||||
}
|
||||
@@ -288,7 +301,7 @@ func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, l
|
||||
return createOrUpdate(ctx, a.Client, a.operatorNamespace, hsvc, func(svc *corev1.Service) { svc.Spec = hsvc.Spec })
|
||||
}
|
||||
|
||||
func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *zap.SugaredLogger, stsC *tailscaleSTSConfig, hsvc *corev1.Service) (string, string, error) {
|
||||
func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *zap.SugaredLogger, stsC *tailscaleSTSConfig, hsvc *corev1.Service) (secretName, hash string, configs tailscaleConfigs, _ error) {
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
// Hardcode a -0 suffix so that in future, if we support
|
||||
@@ -304,25 +317,23 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
|
||||
logger.Debugf("secret %s/%s already exists", secret.GetNamespace(), secret.GetName())
|
||||
orig = secret.DeepCopy()
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return "", "", err
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
authKey, hash string
|
||||
)
|
||||
var authKey string
|
||||
if orig == nil {
|
||||
// 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
|
||||
return "", "", nil, err
|
||||
}
|
||||
if sts != nil {
|
||||
// StatefulSet exists, so we have already created the secret.
|
||||
// If the secret is missing, they should delete the StatefulSet.
|
||||
logger.Errorf("Tailscale proxy secret doesn't exist, but the corresponding StatefulSet %s/%s already does. Something is wrong, please delete the StatefulSet.", sts.GetNamespace(), sts.GetName())
|
||||
return "", "", nil
|
||||
return "", "", nil, nil
|
||||
}
|
||||
// Create API Key secret which is going to be used by the statefulset
|
||||
// to authenticate with Tailscale.
|
||||
@@ -333,36 +344,66 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
|
||||
}
|
||||
authKey, err = a.newAuthKey(ctx, tags)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return "", "", nil, err
|
||||
}
|
||||
}
|
||||
confFileBytes, h, err := tailscaledConfig(stsC, authKey, orig)
|
||||
configs, err := tailscaledConfig(stsC, authKey, orig)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("error creating tailscaled config: %w", err)
|
||||
return "", "", nil, fmt.Errorf("error creating tailscaled config: %w", err)
|
||||
}
|
||||
hash, err = tailscaledConfigHash(configs)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("error calculating hash of tailscaled configs: %w", err)
|
||||
}
|
||||
|
||||
latest := tailcfg.CapabilityVersion(-1)
|
||||
var latestConfig ipn.ConfigVAlpha
|
||||
for key, val := range configs {
|
||||
fn := tsoperator.TailscaledConfigFileNameForCap(key)
|
||||
b, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("error marshalling tailscaled config: %w", err)
|
||||
}
|
||||
mak.Set(&secret.StringData, fn, string(b))
|
||||
if key > latest {
|
||||
latest = key
|
||||
latestConfig = val
|
||||
}
|
||||
}
|
||||
hash = h
|
||||
mak.Set(&secret.StringData, tailscaledConfigKey, string(confFileBytes))
|
||||
|
||||
if stsC.ServeConfig != nil {
|
||||
j, err := json.Marshal(stsC.ServeConfig)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return "", "", nil, err
|
||||
}
|
||||
mak.Set(&secret.StringData, "serve-config", string(j))
|
||||
}
|
||||
|
||||
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(latestConfig))
|
||||
if err := a.Patch(ctx, secret, client.MergeFrom(orig)); err != nil {
|
||||
return "", "", err
|
||||
return "", "", nil, 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(latestConfig))
|
||||
if err := a.Create(ctx, secret); err != nil {
|
||||
return "", "", err
|
||||
return "", "", nil, err
|
||||
}
|
||||
}
|
||||
return secret.Name, hash, nil
|
||||
return secret.Name, hash, configs, nil
|
||||
}
|
||||
|
||||
// sanitizeConfigBytes returns ipn.ConfigVAlpha in string form with redacted
|
||||
// auth key.
|
||||
func sanitizeConfigBytes(c ipn.ConfigVAlpha) string {
|
||||
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
|
||||
@@ -417,7 +458,7 @@ var proxyYaml []byte
|
||||
//go:embed deploy/manifests/userspace-proxy.yaml
|
||||
var userspaceProxyYaml []byte
|
||||
|
||||
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecret, tsConfigHash string) (*appsv1.StatefulSet, error) {
|
||||
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecret, tsConfigHash string, configs map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha) (*appsv1.StatefulSet, error) {
|
||||
ss := new(appsv1.StatefulSet)
|
||||
if sts.ServeConfig != nil && sts.ForwardClusterTrafficViaL7IngressProxy != true { // If forwarding cluster traffic via is required we need non-userspace + NET_ADMIN + forwarding
|
||||
if err := yaml.Unmarshal(userspaceProxyYaml, &ss); err != nil {
|
||||
@@ -437,16 +478,6 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
}
|
||||
pod := &ss.Spec.Template
|
||||
container := &pod.Spec.Containers[0]
|
||||
proxyClass := new(tsapi.ProxyClass)
|
||||
if sts.ProxyClass != "" {
|
||||
if err := a.Get(ctx, types.NamespacedName{Name: sts.ProxyClass}, proxyClass); err != nil {
|
||||
return nil, fmt.Errorf("failed to get ProxyClass: %w", err)
|
||||
}
|
||||
if !tsoperator.ProxyClassIsReady(proxyClass) {
|
||||
logger.Infof("ProxyClass %s specified for the proxy, but it is not (yet) in a ready state, waiting..")
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
container.Image = a.proxyImage
|
||||
ss.ObjectMeta = metav1.ObjectMeta{
|
||||
Name: headlessSvc.Name,
|
||||
@@ -473,9 +504,15 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
Value: proxySecret,
|
||||
},
|
||||
corev1.EnvVar{
|
||||
// Old tailscaled config key is still used for backwards compatibility.
|
||||
Name: "EXPERIMENTAL_TS_CONFIGFILE_PATH",
|
||||
Value: "/etc/tsconfig/tailscaled",
|
||||
},
|
||||
corev1.EnvVar{
|
||||
// New style is in the form of cap-<capability-version>.hujson.
|
||||
Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR",
|
||||
Value: "/etc/tsconfig",
|
||||
},
|
||||
)
|
||||
if sts.ForwardClusterTrafficViaL7IngressProxy {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
@@ -485,18 +522,16 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
}
|
||||
// Configure containeboot to run tailscaled with a configfile read from the state Secret.
|
||||
mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetConfigFileHash, tsConfigHash)
|
||||
pod.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, corev1.Volume{
|
||||
|
||||
configVolume := corev1.Volume{
|
||||
Name: "tailscaledconfig",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: proxySecret,
|
||||
Items: []corev1.KeyToPath{{
|
||||
Key: tailscaledConfigKey,
|
||||
Path: tailscaledConfigKey,
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
pod.Spec.Volumes = append(ss.Spec.Template.Spec.Volumes, configVolume)
|
||||
container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{
|
||||
Name: "tailscaledconfig",
|
||||
ReadOnly: true,
|
||||
@@ -518,6 +553,12 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
Value: sts.ClusterTargetIP,
|
||||
})
|
||||
mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetClusterIP, sts.ClusterTargetIP)
|
||||
} else if sts.ClusterTargetDNSName != "" {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
Name: "TS_EXPERIMENTAL_DEST_DNS_NAME",
|
||||
Value: sts.ClusterTargetDNSName,
|
||||
})
|
||||
mak.Set(&ss.Spec.Template.Annotations, podAnnotationLastSetClusterDNSName, sts.ClusterTargetDNSName)
|
||||
} else if sts.TailnetTargetIP != "" {
|
||||
container.Env = append(container.Env, corev1.EnvVar{
|
||||
Name: "TS_TAILNET_TARGET_IP",
|
||||
@@ -545,18 +586,15 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: proxySecret,
|
||||
Items: []corev1.KeyToPath{{
|
||||
Key: "serve-config",
|
||||
Path: "serve-config",
|
||||
}},
|
||||
Items: []corev1.KeyToPath{{Key: "serve-config", Path: "serve-config"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
logger.Debugf("reconciling statefulset %s/%s", ss.GetNamespace(), ss.GetName())
|
||||
if sts.ProxyClass != "" {
|
||||
logger.Debugf("configuring proxy resources with ProxyClass %s", sts.ProxyClass)
|
||||
ss = applyProxyClassToStatefulSet(proxyClass, ss)
|
||||
if sts.ProxyClassName != "" {
|
||||
logger.Debugf("configuring proxy resources with ProxyClass %s", sts.ProxyClassName)
|
||||
ss = applyProxyClassToStatefulSet(sts.ProxyClass, ss, sts, logger)
|
||||
}
|
||||
updateSS := func(s *appsv1.StatefulSet) {
|
||||
s.Spec = ss.Spec
|
||||
@@ -587,8 +625,28 @@ func mergeStatefulSetLabelsOrAnnots(current, custom map[string]string, managed [
|
||||
return custom
|
||||
}
|
||||
|
||||
func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet) *appsv1.StatefulSet {
|
||||
if pc == nil || ss == nil || pc.Spec.StatefulSet == nil {
|
||||
func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet, stsCfg *tailscaleSTSConfig, logger *zap.SugaredLogger) *appsv1.StatefulSet {
|
||||
if pc == nil || ss == nil {
|
||||
return ss
|
||||
}
|
||||
if pc.Spec.Metrics != nil && pc.Spec.Metrics.Enable {
|
||||
if stsCfg.TailnetTargetFQDN == "" && stsCfg.TailnetTargetIP == "" && !stsCfg.ForwardClusterTrafficViaL7IngressProxy {
|
||||
enableMetrics(ss, pc)
|
||||
} else if stsCfg.ForwardClusterTrafficViaL7IngressProxy {
|
||||
// TODO (irbekrm): fix this
|
||||
// For Ingress proxies that have been configured with
|
||||
// tailscale.com/experimental-forward-cluster-traffic-via-ingress
|
||||
// annotation, all cluster traffic is forwarded to the
|
||||
// Ingress backend(s).
|
||||
logger.Info("ProxyClass specifies that metrics should be enabled, but this is currently not supported for Ingress proxies that accept cluster traffic.")
|
||||
} else {
|
||||
// TODO (irbekrm): fix this
|
||||
// For egress proxies, currently all cluster traffic is forwarded to the tailnet target.
|
||||
logger.Info("ProxyClass specifies that metrics should be enabled, but this is currently not supported for Ingress proxies that accept cluster traffic.")
|
||||
}
|
||||
}
|
||||
|
||||
if pc.Spec.StatefulSet == nil {
|
||||
return ss
|
||||
}
|
||||
|
||||
@@ -615,6 +673,7 @@ func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet)
|
||||
ss.Spec.Template.Spec.ImagePullSecrets = wantsPod.ImagePullSecrets
|
||||
ss.Spec.Template.Spec.NodeName = wantsPod.NodeName
|
||||
ss.Spec.Template.Spec.NodeSelector = wantsPod.NodeSelector
|
||||
ss.Spec.Template.Spec.Affinity = wantsPod.Affinity
|
||||
ss.Spec.Template.Spec.Tolerations = wantsPod.Tolerations
|
||||
|
||||
// Update containers.
|
||||
@@ -626,6 +685,21 @@ func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet)
|
||||
base.SecurityContext = overlay.SecurityContext
|
||||
}
|
||||
base.Resources = overlay.Resources
|
||||
for _, e := range overlay.Env {
|
||||
// Env vars configured via ProxyClass might override env
|
||||
// vars that have been specified by the operator, i.e
|
||||
// TS_USERSPACE. The intended behaviour is to allow this
|
||||
// and in practice it works without explicitly removing
|
||||
// the operator configured value here as a later value
|
||||
// in the env var list overrides an earlier one.
|
||||
base.Env = append(base.Env, corev1.EnvVar{Name: string(e.Name), Value: e.Value})
|
||||
}
|
||||
if overlay.Image != "" {
|
||||
base.Image = overlay.Image
|
||||
}
|
||||
if overlay.ImagePullPolicy != "" {
|
||||
base.ImagePullPolicy = overlay.ImagePullPolicy
|
||||
}
|
||||
return base
|
||||
}
|
||||
for i, c := range ss.Spec.Template.Spec.Containers {
|
||||
@@ -645,41 +719,105 @@ func applyProxyClassToStatefulSet(pc *tsapi.ProxyClass, ss *appsv1.StatefulSet)
|
||||
return ss
|
||||
}
|
||||
|
||||
func enableMetrics(ss *appsv1.StatefulSet, pc *tsapi.ProxyClass) {
|
||||
for i, c := range ss.Spec.Template.Spec.Containers {
|
||||
if c.Name == "tailscale" {
|
||||
// Serve metrics on on <pod-ip>:9001/debug/metrics. If
|
||||
// we didn't specify Pod IP here, the proxy would, in
|
||||
// some cases, also listen to its Tailscale IP- we don't
|
||||
// want folks to start relying on this side-effect as a
|
||||
// feature.
|
||||
ss.Spec.Template.Spec.Containers[i].Env = append(ss.Spec.Template.Spec.Containers[i].Env, corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(POD_IP):9001"})
|
||||
ss.Spec.Template.Spec.Containers[i].Ports = append(ss.Spec.Template.Spec.Containers[i].Ports, corev1.ContainerPort{Name: "metrics", Protocol: "TCP", HostPort: 9001, ContainerPort: 9001})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readAuthKey(secret *corev1.Secret, key string) (*string, error) {
|
||||
origConf := &ipn.ConfigVAlpha{}
|
||||
if err := json.Unmarshal([]byte(secret.Data[key]), origConf); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling previous tailscaled config in %q: %w", key, err)
|
||||
}
|
||||
return origConf.AuthKey, nil
|
||||
}
|
||||
|
||||
// tailscaledConfig takes a proxy config, a newly generated auth key if
|
||||
// generated and a Secret with the previous proxy state and auth key and
|
||||
// produces returns tailscaled configuration and a hash of that configuration.
|
||||
func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *corev1.Secret) ([]byte, string, error) {
|
||||
conf := ipn.ConfigVAlpha{
|
||||
Version: "alpha0",
|
||||
AcceptDNS: "false",
|
||||
Locked: "false",
|
||||
Hostname: &stsC.Hostname,
|
||||
// returns tailscaled configuration and a hash of that configuration.
|
||||
//
|
||||
// As of 2024-05-09 it also returns legacy tailscaled config without the
|
||||
// later added NoStatefulFilter field to support proxies older than cap95.
|
||||
// TODO (irbekrm): remove the legacy config once we no longer need to support
|
||||
// versions older than cap94,
|
||||
// https://tailscale.com/kb/1236/kubernetes-operator#operator-and-proxies
|
||||
func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *corev1.Secret) (tailscaleConfigs, error) {
|
||||
conf := &ipn.ConfigVAlpha{
|
||||
Version: "alpha0",
|
||||
AcceptDNS: "false",
|
||||
AcceptRoutes: "false", // AcceptRoutes defaults to true
|
||||
Locked: "false",
|
||||
Hostname: &stsC.Hostname,
|
||||
NoStatefulFiltering: "false",
|
||||
}
|
||||
|
||||
// For egress proxies only, we need to ensure that stateful filtering is
|
||||
// not in place so that traffic from cluster can be forwarded via
|
||||
// Tailscale IPs.
|
||||
if stsC.TailnetTargetFQDN != "" || stsC.TailnetTargetIP != "" {
|
||||
conf.NoStatefulFiltering = "true"
|
||||
}
|
||||
if stsC.Connector != nil {
|
||||
routes, err := netutil.CalcAdvertiseRoutes(stsC.Connector.routes, stsC.Connector.isExitNode)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("error calculating routes: %w", err)
|
||||
return nil, fmt.Errorf("error calculating routes: %w", err)
|
||||
}
|
||||
conf.AdvertiseRoutes = routes
|
||||
}
|
||||
if shouldAcceptRoutes(stsC.ProxyClass) {
|
||||
conf.AcceptRoutes = "true"
|
||||
}
|
||||
|
||||
if newAuthkey != "" {
|
||||
conf.AuthKey = &newAuthkey
|
||||
} else if oldSecret != nil && len(oldSecret.Data[tailscaledConfigKey]) > 0 { // write to StringData, read from Data as StringData is write-only
|
||||
origConf := &ipn.ConfigVAlpha{}
|
||||
if err := json.Unmarshal([]byte(oldSecret.Data[tailscaledConfigKey]), origConf); err != nil {
|
||||
return nil, "", fmt.Errorf("error unmarshaling previous tailscaled config: %w", err)
|
||||
} else if oldSecret != nil {
|
||||
var err error
|
||||
latest := tailcfg.CapabilityVersion(-1)
|
||||
latestStr := ""
|
||||
for k, data := range oldSecret.Data {
|
||||
// write to StringData, read from Data as StringData is write-only
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
v, err := tsoperator.CapVerFromFileName(k)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if v > latest {
|
||||
latestStr = k
|
||||
latest = v
|
||||
}
|
||||
}
|
||||
// Allow for configs that don't contain an auth key. Perhaps
|
||||
// users have some mechanisms to delete them. Auth key is
|
||||
// normally not needed after the initial login.
|
||||
if latestStr != "" {
|
||||
conf.AuthKey, err = readAuthKey(oldSecret, latestStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
conf.AuthKey = origConf.AuthKey
|
||||
}
|
||||
confFileBytes, err := json.Marshal(conf)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("error marshaling tailscaled config : %w", err)
|
||||
}
|
||||
hash, err := hashBytes(confFileBytes)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("error calculating config hash: %w", err)
|
||||
}
|
||||
return confFileBytes, hash, nil
|
||||
capVerConfigs := make(map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha)
|
||||
capVerConfigs[95] = *conf
|
||||
// legacy config should not contain NoStatefulFiltering field.
|
||||
conf.NoStatefulFiltering.Clear()
|
||||
capVerConfigs[94] = *conf
|
||||
return capVerConfigs, nil
|
||||
}
|
||||
|
||||
func shouldAcceptRoutes(pc *tsapi.ProxyClass) bool {
|
||||
return pc != nil && pc.Spec.TailscaleConfig != nil && pc.Spec.TailscaleConfig.AcceptRoutes
|
||||
}
|
||||
|
||||
// ptrObject is a type constraint for pointer types that implement
|
||||
@@ -689,7 +827,9 @@ type ptrObject[T any] interface {
|
||||
*T
|
||||
}
|
||||
|
||||
// hashBytes produces a hash for the provided bytes that is the same across
|
||||
type tailscaleConfigs map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha
|
||||
|
||||
// hashBytes produces a hash for the provided tailscaled config that is the same across
|
||||
// different invocations of this code. We do not use the
|
||||
// tailscale.com/deephash.Hash here because that produces a different hash for
|
||||
// the same value in different tailscale builds. The hash we are producing here
|
||||
@@ -698,10 +838,13 @@ type ptrObject[T any] interface {
|
||||
// thing that changed is operator version (the hash is also exposed to users via
|
||||
// an annotation and might be confusing if it changes without the config having
|
||||
// changed).
|
||||
func hashBytes(b []byte) (string, error) {
|
||||
h := sha256.New()
|
||||
_, err := h.Write(b)
|
||||
func tailscaledConfigHash(c tailscaleConfigs) (string, error) {
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error marshalling tailscaled configs: %w", err)
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err = h.Write(b); err != nil {
|
||||
return "", fmt.Errorf("error calculating hash: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.uber.org/zap"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
@@ -51,6 +52,10 @@ func Test_statefulSetNameBase(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Setup
|
||||
proxyClassAllOpts := &tsapi.ProxyClass{
|
||||
Spec: tsapi.ProxyClassSpec{
|
||||
@@ -66,6 +71,7 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
ImagePullSecrets: []corev1.LocalObjectReference{{Name: "docker-creds"}},
|
||||
NodeName: "some-node",
|
||||
NodeSelector: map[string]string{"beta.kubernetes.io/os": "linux"},
|
||||
Affinity: &corev1.Affinity{NodeAffinity: &corev1.NodeAffinity{RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{}}},
|
||||
Tolerations: []corev1.Toleration{{Key: "", Operator: "Exists"}},
|
||||
TailscaleContainer: &tsapi.Container{
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
@@ -75,6 +81,9 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1000m"), corev1.ResourceMemory: resource.MustParse("128Mi")},
|
||||
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("500m"), corev1.ResourceMemory: resource.MustParse("64Mi")},
|
||||
},
|
||||
Env: []tsapi.Env{{Name: "foo", Value: "bar"}, {Name: "TS_USERSPACE", Value: "true"}, {Name: "bar"}},
|
||||
ImagePullPolicy: "IfNotPresent",
|
||||
Image: "ghcr.io/my-repo/tailscale:v0.01testsomething",
|
||||
},
|
||||
TailscaleInitContainer: &tsapi.Container{
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
@@ -85,6 +94,9 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1000m"), corev1.ResourceMemory: resource.MustParse("128Mi")},
|
||||
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("500m"), corev1.ResourceMemory: resource.MustParse("64Mi")},
|
||||
},
|
||||
Env: []tsapi.Env{{Name: "foo", Value: "bar"}, {Name: "TS_USERSPACE", Value: "true"}, {Name: "bar"}},
|
||||
ImagePullPolicy: "IfNotPresent",
|
||||
Image: "ghcr.io/my-repo/tailscale:v0.01testsomething",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -102,6 +114,12 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
proxyClassMetrics := &tsapi.ProxyClass{
|
||||
Spec: tsapi.ProxyClassSpec{
|
||||
Metrics: &tsapi.Metrics{Enable: true},
|
||||
},
|
||||
}
|
||||
|
||||
var userspaceProxySS, nonUserspaceProxySS appsv1.StatefulSet
|
||||
if err := yaml.Unmarshal(userspaceProxyYaml, &userspaceProxySS); err != nil {
|
||||
t.Fatalf("unmarshaling userspace proxy template: %v", err)
|
||||
@@ -121,10 +139,12 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
env := []corev1.EnvVar{{Name: "TS_HOSTNAME", Value: "nginx"}}
|
||||
userspaceProxySS.Labels = labels
|
||||
userspaceProxySS.Annotations = annots
|
||||
userspaceProxySS.Spec.Template.Spec.Containers[0].Image = "tailscale/tailscale:v0.0.1"
|
||||
userspaceProxySS.Spec.Template.Spec.Containers[0].Env = env
|
||||
nonUserspaceProxySS.ObjectMeta.Labels = labels
|
||||
nonUserspaceProxySS.ObjectMeta.Annotations = annots
|
||||
nonUserspaceProxySS.Spec.Template.Spec.Containers[0].Env = env
|
||||
nonUserspaceProxySS.Spec.Template.Spec.InitContainers[0].Image = "tailscale/tailscale:v0.0.1"
|
||||
|
||||
// 1. Test that a ProxyClass with all fields set gets correctly applied
|
||||
// to a Statefulset built from non-userspace proxy template.
|
||||
@@ -137,13 +157,20 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
wantSS.Spec.Template.Spec.ImagePullSecrets = proxyClassAllOpts.Spec.StatefulSet.Pod.ImagePullSecrets
|
||||
wantSS.Spec.Template.Spec.NodeName = proxyClassAllOpts.Spec.StatefulSet.Pod.NodeName
|
||||
wantSS.Spec.Template.Spec.NodeSelector = proxyClassAllOpts.Spec.StatefulSet.Pod.NodeSelector
|
||||
wantSS.Spec.Template.Spec.Affinity = proxyClassAllOpts.Spec.StatefulSet.Pod.Affinity
|
||||
wantSS.Spec.Template.Spec.Tolerations = proxyClassAllOpts.Spec.StatefulSet.Pod.Tolerations
|
||||
wantSS.Spec.Template.Spec.Containers[0].SecurityContext = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.SecurityContext
|
||||
wantSS.Spec.Template.Spec.InitContainers[0].SecurityContext = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleInitContainer.SecurityContext
|
||||
wantSS.Spec.Template.Spec.Containers[0].Resources = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.Resources
|
||||
wantSS.Spec.Template.Spec.InitContainers[0].Resources = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleInitContainer.Resources
|
||||
wantSS.Spec.Template.Spec.InitContainers[0].Env = append(wantSS.Spec.Template.Spec.InitContainers[0].Env, []corev1.EnvVar{{Name: "foo", Value: "bar"}, {Name: "TS_USERSPACE", Value: "true"}, {Name: "bar"}}...)
|
||||
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env, []corev1.EnvVar{{Name: "foo", Value: "bar"}, {Name: "TS_USERSPACE", Value: "true"}, {Name: "bar"}}...)
|
||||
wantSS.Spec.Template.Spec.Containers[0].Image = "ghcr.io/my-repo/tailscale:v0.01testsomething"
|
||||
wantSS.Spec.Template.Spec.Containers[0].ImagePullPolicy = "IfNotPresent"
|
||||
wantSS.Spec.Template.Spec.InitContainers[0].Image = "ghcr.io/my-repo/tailscale:v0.01testsomething"
|
||||
wantSS.Spec.Template.Spec.InitContainers[0].ImagePullPolicy = "IfNotPresent"
|
||||
|
||||
gotSS := applyProxyClassToStatefulSet(proxyClassAllOpts, nonUserspaceProxySS.DeepCopy())
|
||||
gotSS := applyProxyClassToStatefulSet(proxyClassAllOpts, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
|
||||
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
|
||||
t.Fatalf("Unexpected result applying ProxyClass with all fields set to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff)
|
||||
}
|
||||
@@ -156,7 +183,7 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
wantSS.ObjectMeta.Annotations = mergeMapKeys(wantSS.ObjectMeta.Annotations, proxyClassJustLabels.Spec.StatefulSet.Annotations)
|
||||
wantSS.Spec.Template.Labels = proxyClassJustLabels.Spec.StatefulSet.Pod.Labels
|
||||
wantSS.Spec.Template.Annotations = proxyClassJustLabels.Spec.StatefulSet.Pod.Annotations
|
||||
gotSS = applyProxyClassToStatefulSet(proxyClassJustLabels, nonUserspaceProxySS.DeepCopy())
|
||||
gotSS = applyProxyClassToStatefulSet(proxyClassJustLabels, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
|
||||
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
|
||||
t.Fatalf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for non-userspace proxy (-got +want):\n%s", diff)
|
||||
}
|
||||
@@ -172,12 +199,16 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
wantSS.Spec.Template.Spec.ImagePullSecrets = proxyClassAllOpts.Spec.StatefulSet.Pod.ImagePullSecrets
|
||||
wantSS.Spec.Template.Spec.NodeName = proxyClassAllOpts.Spec.StatefulSet.Pod.NodeName
|
||||
wantSS.Spec.Template.Spec.NodeSelector = proxyClassAllOpts.Spec.StatefulSet.Pod.NodeSelector
|
||||
wantSS.Spec.Template.Spec.Affinity = proxyClassAllOpts.Spec.StatefulSet.Pod.Affinity
|
||||
wantSS.Spec.Template.Spec.Tolerations = proxyClassAllOpts.Spec.StatefulSet.Pod.Tolerations
|
||||
wantSS.Spec.Template.Spec.Containers[0].SecurityContext = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.SecurityContext
|
||||
wantSS.Spec.Template.Spec.Containers[0].Resources = proxyClassAllOpts.Spec.StatefulSet.Pod.TailscaleContainer.Resources
|
||||
gotSS = applyProxyClassToStatefulSet(proxyClassAllOpts, userspaceProxySS.DeepCopy())
|
||||
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env, []corev1.EnvVar{{Name: "foo", Value: "bar"}, {Name: "TS_USERSPACE", Value: "true"}, {Name: "bar"}}...)
|
||||
wantSS.Spec.Template.Spec.Containers[0].ImagePullPolicy = "IfNotPresent"
|
||||
wantSS.Spec.Template.Spec.Containers[0].Image = "ghcr.io/my-repo/tailscale:v0.01testsomething"
|
||||
gotSS = applyProxyClassToStatefulSet(proxyClassAllOpts, userspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
|
||||
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
|
||||
t.Fatalf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for a userspace proxy (-got +want):\n%s", diff)
|
||||
t.Fatalf("Unexpected result applying ProxyClass with all options to a StatefulSet for a userspace proxy (-got +want):\n%s", diff)
|
||||
}
|
||||
|
||||
// 4. Test that a ProxyClass with custom labels and annotations gets correctly applied
|
||||
@@ -187,10 +218,19 @@ func Test_applyProxyClassToStatefulSet(t *testing.T) {
|
||||
wantSS.ObjectMeta.Annotations = mergeMapKeys(wantSS.ObjectMeta.Annotations, proxyClassJustLabels.Spec.StatefulSet.Annotations)
|
||||
wantSS.Spec.Template.Labels = proxyClassJustLabels.Spec.StatefulSet.Pod.Labels
|
||||
wantSS.Spec.Template.Annotations = proxyClassJustLabels.Spec.StatefulSet.Pod.Annotations
|
||||
gotSS = applyProxyClassToStatefulSet(proxyClassJustLabels, userspaceProxySS.DeepCopy())
|
||||
gotSS = applyProxyClassToStatefulSet(proxyClassJustLabels, userspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
|
||||
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
|
||||
t.Fatalf("Unexpected result applying ProxyClass with custom labels and annotations to a StatefulSet for a userspace proxy (-got +want):\n%s", diff)
|
||||
}
|
||||
|
||||
// 5. Test that a ProxyClass with metrics enabled gets correctly applied to a StatefulSet.
|
||||
wantSS = nonUserspaceProxySS.DeepCopy()
|
||||
wantSS.Spec.Template.Spec.Containers[0].Env = append(wantSS.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{Name: "TS_TAILSCALED_EXTRA_ARGS", Value: "--debug=$(POD_IP):9001"})
|
||||
wantSS.Spec.Template.Spec.Containers[0].Ports = []corev1.ContainerPort{{Name: "metrics", Protocol: "TCP", ContainerPort: 9001, HostPort: 9001}}
|
||||
gotSS = applyProxyClassToStatefulSet(proxyClassMetrics, nonUserspaceProxySS.DeepCopy(), new(tailscaleSTSConfig), zl.Sugar())
|
||||
if diff := cmp.Diff(gotSS, wantSS); diff != "" {
|
||||
t.Fatalf("Unexpected result applying ProxyClass with metrics enabled to a StatefulSet (-got +want):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeMapKeys(a, b map[string]string) map[string]string {
|
||||
|
||||
@@ -22,10 +22,16 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/net/dns/resolvconffile"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
resolvConfPath = "/etc/resolv.conf"
|
||||
defaultClusterDomain = "cluster.local"
|
||||
)
|
||||
|
||||
type ServiceReconciler struct {
|
||||
client.Client
|
||||
ssr *tailscaleSTSReconciler
|
||||
@@ -42,6 +48,8 @@ type ServiceReconciler struct {
|
||||
managedEgressProxies set.Slice[types.UID]
|
||||
|
||||
recorder record.EventRecorder
|
||||
|
||||
tsNamespace string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -82,7 +90,7 @@ func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request
|
||||
} else if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get svc: %w", err)
|
||||
}
|
||||
targetIP := a.tailnetTargetAnnotation(svc)
|
||||
targetIP := tailnetTargetAnnotation(svc)
|
||||
targetFQDN := svc.Annotations[AnnotationTailnetTargetFQDN]
|
||||
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) && targetIP == "" && targetFQDN == "" {
|
||||
logger.Debugf("service is being deleted or is (no longer) referring to Tailscale ingress/egress, ensuring any created resources are cleaned up")
|
||||
@@ -153,7 +161,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
}
|
||||
if violations := validateService(svc); len(violations) > 0 {
|
||||
msg := fmt.Sprintf("unable to provision proxy resources: invalid Service: %s", strings.Join(violations, ", "))
|
||||
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDSERVCICE", msg)
|
||||
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDSERVICE", msg)
|
||||
a.logger.Error(msg)
|
||||
return nil
|
||||
}
|
||||
@@ -196,15 +204,19 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
Hostname: hostname,
|
||||
Tags: tags,
|
||||
ChildResourceLabels: crl,
|
||||
ProxyClass: proxyClass,
|
||||
ProxyClassName: proxyClass,
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
if a.shouldExpose(svc) {
|
||||
if a.shouldExposeClusterIP(svc) {
|
||||
sts.ClusterTargetIP = svc.Spec.ClusterIP
|
||||
a.managedIngressProxies.Add(svc.UID)
|
||||
gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len()))
|
||||
} else if ip := a.tailnetTargetAnnotation(svc); ip != "" {
|
||||
} else if a.shouldExposeDNSName(svc) {
|
||||
sts.ClusterTargetDNSName = svc.Spec.ExternalName
|
||||
a.managedIngressProxies.Add(svc.UID)
|
||||
gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len()))
|
||||
} else if ip := tailnetTargetAnnotation(svc); ip != "" {
|
||||
sts.TailnetTargetIP = ip
|
||||
a.managedEgressProxies.Add(svc.UID)
|
||||
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
|
||||
@@ -225,10 +237,8 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
}
|
||||
|
||||
if sts.TailnetTargetIP != "" || sts.TailnetTargetFQDN != "" {
|
||||
// TODO (irbekrm): cluster.local is the default DNS name, but
|
||||
// can be changed by users. Make this configurable or figure out
|
||||
// how to discover the DNS name from within operator
|
||||
headlessSvcName := hsvc.Name + "." + hsvc.Namespace + ".svc.cluster.local"
|
||||
clusterDomain := retrieveClusterDomain(a.tsNamespace, logger)
|
||||
headlessSvcName := hsvc.Name + "." + hsvc.Namespace + ".svc." + clusterDomain
|
||||
if svc.Spec.ExternalName != headlessSvcName || svc.Spec.Type != corev1.ServiceTypeExternalName {
|
||||
svc.Spec.ExternalName = headlessSvcName
|
||||
svc.Spec.Selector = nil
|
||||
@@ -240,7 +250,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
return nil
|
||||
}
|
||||
|
||||
if !a.hasLoadBalancerClass(svc) {
|
||||
if !isTailscaleLoadBalancerService(svc, a.isDefaultLoadBalancer) {
|
||||
logger.Debugf("service is not a LoadBalancer, so not updating ingress")
|
||||
return nil
|
||||
}
|
||||
@@ -286,7 +296,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
func validateService(svc *corev1.Service) []string {
|
||||
violations := make([]string, 0)
|
||||
if svc.Annotations[AnnotationTailnetTargetFQDN] != "" && svc.Annotations[AnnotationTailnetTargetIP] != "" {
|
||||
violations = append(violations, "only one of annotations %s and %s can be set", AnnotationTailnetTargetIP, AnnotationTailnetTargetFQDN)
|
||||
violations = append(violations, fmt.Sprintf("only one of annotations %s and %s can be set", AnnotationTailnetTargetIP, AnnotationTailnetTargetFQDN))
|
||||
}
|
||||
if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" {
|
||||
if !isMagicDNSName(fqdn) {
|
||||
@@ -297,25 +307,30 @@ func validateService(svc *corev1.Service) []string {
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) shouldExpose(svc *corev1.Service) bool {
|
||||
// Headless services can't be exposed, since there is no ClusterIP to
|
||||
// forward to.
|
||||
return a.shouldExposeClusterIP(svc) || a.shouldExposeDNSName(svc)
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) shouldExposeDNSName(svc *corev1.Service) bool {
|
||||
return hasExposeAnnotation(svc) && svc.Spec.Type == corev1.ServiceTypeExternalName && svc.Spec.ExternalName != ""
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) shouldExposeClusterIP(svc *corev1.Service) bool {
|
||||
if svc.Spec.ClusterIP == "" || svc.Spec.ClusterIP == "None" {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.hasLoadBalancerClass(svc) || a.hasExposeAnnotation(svc)
|
||||
return isTailscaleLoadBalancerService(svc, a.isDefaultLoadBalancer) || hasExposeAnnotation(svc)
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) hasLoadBalancerClass(svc *corev1.Service) bool {
|
||||
func isTailscaleLoadBalancerService(svc *corev1.Service, isDefaultLoadBalancer bool) bool {
|
||||
return svc != nil &&
|
||||
svc.Spec.Type == corev1.ServiceTypeLoadBalancer &&
|
||||
(svc.Spec.LoadBalancerClass != nil && *svc.Spec.LoadBalancerClass == "tailscale" ||
|
||||
svc.Spec.LoadBalancerClass == nil && a.isDefaultLoadBalancer)
|
||||
svc.Spec.LoadBalancerClass == nil && isDefaultLoadBalancer)
|
||||
}
|
||||
|
||||
// hasExposeAnnotation reports whether Service has the tailscale.com/expose
|
||||
// annotation set
|
||||
func (a *ServiceReconciler) hasExposeAnnotation(svc *corev1.Service) bool {
|
||||
func hasExposeAnnotation(svc *corev1.Service) bool {
|
||||
return svc != nil && svc.Annotations[AnnotationExpose] == "true"
|
||||
}
|
||||
|
||||
@@ -323,7 +338,7 @@ func (a *ServiceReconciler) hasExposeAnnotation(svc *corev1.Service) bool {
|
||||
// annotation or of the deprecated tailscale.com/ts-tailnet-target-ip
|
||||
// annotation. If neither is set, it returns an empty string. If both are set,
|
||||
// it returns the value of the new annotation.
|
||||
func (a *ServiceReconciler) tailnetTargetAnnotation(svc *corev1.Service) string {
|
||||
func tailnetTargetAnnotation(svc *corev1.Service) string {
|
||||
if svc == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -344,3 +359,51 @@ func proxyClassIsReady(ctx context.Context, name string, cl client.Client) (bool
|
||||
}
|
||||
return tsoperator.ProxyClassIsReady(proxyClass), nil
|
||||
}
|
||||
|
||||
// retrieveClusterDomain determines and retrieves cluster domain i.e
|
||||
// (cluster.local) in which this Pod is running by parsing search domains in
|
||||
// /etc/resolv.conf. If an error is encountered at any point during the process,
|
||||
// defaults cluster domain to 'cluster.local'.
|
||||
func retrieveClusterDomain(namespace string, logger *zap.SugaredLogger) string {
|
||||
logger.Infof("attempting to retrieve cluster domain..")
|
||||
conf, err := resolvconffile.ParseFile(resolvConfPath)
|
||||
if err != nil {
|
||||
// Vast majority of clusters use the cluster.local domain, so it
|
||||
// is probably better to fall back to that than error out.
|
||||
logger.Infof("[unexpected] error parsing /etc/resolv.conf to determine cluster domain, defaulting to 'cluster.local'.")
|
||||
return defaultClusterDomain
|
||||
}
|
||||
return clusterDomainFromResolverConf(conf, namespace, logger)
|
||||
}
|
||||
|
||||
// clusterDomainFromResolverConf attempts to retrieve cluster domain from the provided resolver config.
|
||||
// It expects the first three search domains in the resolver config to be be ['<namespace>.svc.<cluster-domain>, svc.<cluster-domain>, <cluster-domain>, ...]
|
||||
// If the first three domains match the expected structure, it returns the third.
|
||||
// If the domains don't match the expected structure or an error is encountered, it defaults to 'cluster.local' domain.
|
||||
func clusterDomainFromResolverConf(conf *resolvconffile.Config, namespace string, logger *zap.SugaredLogger) string {
|
||||
if len(conf.SearchDomains) < 3 {
|
||||
logger.Infof("[unexpected] resolver config contains only %d search domains, at least three expected.\nDefaulting cluster domain to 'cluster.local'.")
|
||||
return defaultClusterDomain
|
||||
}
|
||||
first := conf.SearchDomains[0]
|
||||
if !strings.HasPrefix(string(first), namespace+".svc") {
|
||||
logger.Infof("[unexpected] first search domain in resolver config is %s; expected %s.\nDefaulting cluster domain to 'cluster.local'.", first, namespace+".svc.<cluster-domain>")
|
||||
return defaultClusterDomain
|
||||
}
|
||||
second := conf.SearchDomains[1]
|
||||
if !strings.HasPrefix(string(second), "svc") {
|
||||
logger.Infof("[unexpected] second search domain in resolver config is %s; expected 'svc.<cluster-domain>'.\nDefaulting cluster domain to 'cluster.local'.", second)
|
||||
return defaultClusterDomain
|
||||
}
|
||||
// Trim the trailing dot for backwards compatibility purposes as the
|
||||
// cluster domain was previously hardcoded to 'cluster.local' without a
|
||||
// trailing dot.
|
||||
probablyClusterDomain := strings.TrimPrefix(second.WithoutTrailingDot(), "svc.")
|
||||
third := conf.SearchDomains[2]
|
||||
if !strings.EqualFold(third.WithoutTrailingDot(), probablyClusterDomain) {
|
||||
logger.Infof("[unexpected] expected resolver config to contain serch domains <namespace>.svc.<cluster-domain>, svc.<cluster-domain>, <cluster-domain>; got %s %s %s\n. Defaulting cluster domain to 'cluster.local'.", first, second, third)
|
||||
return defaultClusterDomain
|
||||
}
|
||||
logger.Infof("Cluster domain %q extracted from resolver config", probablyClusterDomain)
|
||||
return probablyClusterDomain
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.uber.org/zap"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"tailscale.com/client/tailscale"
|
||||
@@ -42,6 +44,7 @@ type configOpts struct {
|
||||
tailnetTargetIP string
|
||||
tailnetTargetFQDN string
|
||||
clusterTargetIP string
|
||||
clusterTargetDNS string
|
||||
subnetRoutes string
|
||||
isExitNode bool
|
||||
confFileHash string
|
||||
@@ -52,6 +55,10 @@ type configOpts struct {
|
||||
|
||||
func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.StatefulSet {
|
||||
t.Helper()
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tsContainer := corev1.Container{
|
||||
Name: "tailscale",
|
||||
Image: "tailscale/tailscale",
|
||||
@@ -60,6 +67,7 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
|
||||
{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"},
|
||||
{Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"},
|
||||
},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Capabilities: &corev1.Capabilities{
|
||||
@@ -82,12 +90,6 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: opts.secretName,
|
||||
Items: []corev1.KeyToPath{
|
||||
{
|
||||
Key: "tailscaled",
|
||||
Path: "tailscaled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -97,7 +99,9 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
|
||||
ReadOnly: true,
|
||||
MountPath: "/etc/tsconfig",
|
||||
}}
|
||||
annots["tailscale.com/operator-last-set-config-file-hash"] = opts.confFileHash
|
||||
if opts.confFileHash != "" {
|
||||
annots["tailscale.com/operator-last-set-config-file-hash"] = opts.confFileHash
|
||||
}
|
||||
if opts.firewallMode != "" {
|
||||
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{
|
||||
Name: "TS_DEBUG_FIREWALL_MODE",
|
||||
@@ -123,15 +127,19 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
|
||||
Value: opts.clusterTargetIP,
|
||||
})
|
||||
annots["tailscale.com/operator-last-set-cluster-ip"] = opts.clusterTargetIP
|
||||
} else if opts.clusterTargetDNS != "" {
|
||||
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{
|
||||
Name: "TS_EXPERIMENTAL_DEST_DNS_NAME",
|
||||
Value: opts.clusterTargetDNS,
|
||||
})
|
||||
annots["tailscale.com/operator-last-set-cluster-dns-name"] = opts.clusterTargetDNS
|
||||
}
|
||||
if opts.serveConfig != nil {
|
||||
tsContainer.Env = append(tsContainer.Env, corev1.EnvVar{
|
||||
Name: "TS_SERVE_CONFIG",
|
||||
Value: "/etc/tailscaled/serve-config",
|
||||
})
|
||||
volumes = append(volumes, corev1.Volume{
|
||||
Name: "serve-config", VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: opts.secretName, Items: []corev1.KeyToPath{{Path: "serve-config", Key: "serve-config"}}}},
|
||||
})
|
||||
volumes = append(volumes, corev1.Volume{Name: "serve-config", VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: opts.secretName, Items: []corev1.KeyToPath{{Key: "serve-config", Path: "serve-config"}}}}})
|
||||
tsContainer.VolumeMounts = append(tsContainer.VolumeMounts, corev1.VolumeMount{Name: "serve-config", ReadOnly: true, MountPath: "/etc/tailscaled"})
|
||||
}
|
||||
ss := &appsv1.StatefulSet{
|
||||
@@ -174,8 +182,8 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
|
||||
{
|
||||
Name: "sysctler",
|
||||
Image: "tailscale/tailscale",
|
||||
Command: []string{"/bin/sh"},
|
||||
Args: []string{"-c", "sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1"},
|
||||
Command: []string{"/bin/sh", "-c"},
|
||||
Args: []string{"sysctl -w net.ipv4.ip_forward=1 && if sysctl net.ipv6.conf.all.forwarding; then sysctl -w net.ipv6.conf.all.forwarding=1; fi"},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Privileged: ptr.To(true),
|
||||
},
|
||||
@@ -195,20 +203,26 @@ func expectedSTS(t *testing.T, cl client.Client, opts configOpts) *appsv1.Statef
|
||||
if err := cl.Get(context.Background(), types.NamespacedName{Name: opts.proxyClass}, proxyClass); err != nil {
|
||||
t.Fatalf("error getting ProxyClass: %v", err)
|
||||
}
|
||||
return applyProxyClassToStatefulSet(proxyClass, ss)
|
||||
return applyProxyClassToStatefulSet(proxyClass, ss, new(tailscaleSTSConfig), zl.Sugar())
|
||||
}
|
||||
return ss
|
||||
}
|
||||
|
||||
func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *appsv1.StatefulSet {
|
||||
t.Helper()
|
||||
zl, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tsContainer := corev1.Container{
|
||||
Name: "tailscale",
|
||||
Image: "tailscale/tailscale",
|
||||
Env: []corev1.EnvVar{
|
||||
{Name: "TS_USERSPACE", 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"},
|
||||
{Name: "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR", Value: "/etc/tsconfig"},
|
||||
{Name: "TS_SERVE_CONFIG", Value: "/etc/tailscaled/serve-config"},
|
||||
},
|
||||
ImagePullPolicy: "Always",
|
||||
@@ -223,20 +237,12 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
|
||||
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"}}}},
|
||||
},
|
||||
Secret: &corev1.SecretVolumeSource{SecretName: opts.secretName, Items: []corev1.KeyToPath{{Key: "serve-config", Path: "serve-config"}}}}},
|
||||
}
|
||||
ss := &appsv1.StatefulSet{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
@@ -269,7 +275,6 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
|
||||
"tailscale.com/parent-resource-type": opts.parentType,
|
||||
"app": "1234-UID",
|
||||
},
|
||||
Annotations: map[string]string{"tailscale.com/operator-last-set-config-file-hash": opts.confFileHash},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
ServiceAccountName: "proxies",
|
||||
@@ -280,6 +285,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 != "" {
|
||||
@@ -288,7 +297,7 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
|
||||
if err := cl.Get(context.Background(), types.NamespacedName{Name: opts.proxyClass}, proxyClass); err != nil {
|
||||
t.Fatalf("error getting ProxyClass: %v", err)
|
||||
}
|
||||
return applyProxyClassToStatefulSet(proxyClass, ss)
|
||||
return applyProxyClassToStatefulSet(proxyClass, ss, new(tailscaleSTSConfig), zl.Sugar())
|
||||
}
|
||||
return ss
|
||||
}
|
||||
@@ -319,7 +328,7 @@ func expectedHeadlessService(name string, parentType string) *corev1.Service {
|
||||
}
|
||||
}
|
||||
|
||||
func expectedSecret(t *testing.T, opts configOpts) *corev1.Secret {
|
||||
func expectedSecret(t *testing.T, cl client.Client, opts configOpts) *corev1.Secret {
|
||||
t.Helper()
|
||||
s := &corev1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
@@ -339,11 +348,22 @@ func expectedSecret(t *testing.T, opts configOpts) *corev1.Secret {
|
||||
mak.Set(&s.StringData, "serve-config", string(serveConfigBs))
|
||||
}
|
||||
conf := &ipn.ConfigVAlpha{
|
||||
Version: "alpha0",
|
||||
AcceptDNS: "false",
|
||||
Hostname: &opts.hostname,
|
||||
Locked: "false",
|
||||
AuthKey: ptr.To("secret-authkey"),
|
||||
Version: "alpha0",
|
||||
AcceptDNS: "false",
|
||||
Hostname: &opts.hostname,
|
||||
Locked: "false",
|
||||
AuthKey: ptr.To("secret-authkey"),
|
||||
AcceptRoutes: "false",
|
||||
}
|
||||
if opts.proxyClass != "" {
|
||||
t.Logf("applying configuration from ProxyClass %s", opts.proxyClass)
|
||||
proxyClass := new(tsapi.ProxyClass)
|
||||
if err := cl.Get(context.Background(), types.NamespacedName{Name: opts.proxyClass}, proxyClass); err != nil {
|
||||
t.Fatalf("error getting ProxyClass: %v", err)
|
||||
}
|
||||
if proxyClass.Spec.TailscaleConfig != nil && proxyClass.Spec.TailscaleConfig.AcceptRoutes {
|
||||
conf.AcceptRoutes = "true"
|
||||
}
|
||||
}
|
||||
var routes []netip.Prefix
|
||||
if opts.subnetRoutes != "" || opts.isExitNode {
|
||||
@@ -364,7 +384,17 @@ func expectedSecret(t *testing.T, opts configOpts) *corev1.Secret {
|
||||
if err != nil {
|
||||
t.Fatalf("error marshalling tailscaled config")
|
||||
}
|
||||
if opts.tailnetTargetFQDN != "" || opts.tailnetTargetIP != "" {
|
||||
conf.NoStatefulFiltering = "true"
|
||||
} else {
|
||||
conf.NoStatefulFiltering = "false"
|
||||
}
|
||||
bn, err := json.Marshal(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("error marshalling tailscaled config")
|
||||
}
|
||||
mak.Set(&s.StringData, "tailscaled", string(b))
|
||||
mak.Set(&s.StringData, "cap-95.hujson", string(bn))
|
||||
labels := map[string]string{
|
||||
"tailscale.com/managed": "true",
|
||||
"tailscale.com/parent-resource": "test",
|
||||
@@ -433,7 +463,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, use the
|
||||
// modify func to ensure that they are removed from the cluster object and the
|
||||
// object passed as 'want'. 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, modifier func(O)) {
|
||||
t.Helper()
|
||||
got := O(new(T))
|
||||
if err := client.Get(context.Background(), types.NamespacedName{
|
||||
@@ -447,6 +483,10 @@ 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 modifier != nil {
|
||||
modifier(want)
|
||||
modifier(got)
|
||||
}
|
||||
if diff := cmp.Diff(got, want); diff != "" {
|
||||
t.Fatalf("unexpected object (-got +want):\n%s", diff)
|
||||
}
|
||||
@@ -500,6 +540,34 @@ func expectRequeue(t *testing.T, sr reconcile.Reconciler, ns, name string) {
|
||||
}
|
||||
}
|
||||
|
||||
// expectEvents accepts a test recorder and a list of events, tests that expected
|
||||
// events are sent down the recorder's channel. Waits for 5s for each event.
|
||||
func expectEvents(t *testing.T, rec *record.FakeRecorder, wantsEvents []string) {
|
||||
t.Helper()
|
||||
// Events are not expected to arrive in order.
|
||||
seenEvents := make([]string, 0)
|
||||
for range len(wantsEvents) {
|
||||
timer := time.NewTimer(time.Second * 5)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case gotEvent := <-rec.Events:
|
||||
found := false
|
||||
for _, wantEvent := range wantsEvents {
|
||||
if wantEvent == gotEvent {
|
||||
found = true
|
||||
seenEvents = append(seenEvents, gotEvent)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("got unexpected event %q, expected events: %+#v", gotEvent, wantsEvents)
|
||||
}
|
||||
case <-timer.C:
|
||||
t.Errorf("timeout waiting for an event, wants events %#+v, got events %+#v", wantsEvents, seenEvents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTSClient struct {
|
||||
sync.Mutex
|
||||
keyRequests []tailscale.KeyCapabilities
|
||||
@@ -543,3 +611,41 @@ 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)
|
||||
}
|
||||
|
||||
func removeAuthKeyIfExistsModifier(t *testing.T) func(s *corev1.Secret) {
|
||||
return func(secret *corev1.Secret) {
|
||||
t.Helper()
|
||||
if len(secret.StringData["tailscaled"]) != 0 {
|
||||
conf := &ipn.ConfigVAlpha{}
|
||||
if err := json.Unmarshal([]byte(secret.StringData["tailscaled"]), conf); err != nil {
|
||||
t.Fatalf("error unmarshalling 'tailscaled' contents: %v", err)
|
||||
}
|
||||
conf.AuthKey = nil
|
||||
b, err := json.Marshal(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("error marshalling updated 'tailscaled' config: %v", err)
|
||||
}
|
||||
mak.Set(&secret.StringData, "tailscaled", string(b))
|
||||
}
|
||||
if len(secret.StringData["cap-95.hujson"]) != 0 {
|
||||
conf := &ipn.ConfigVAlpha{}
|
||||
if err := json.Unmarshal([]byte(secret.StringData["cap-95.hujson"]), conf); err != nil {
|
||||
t.Fatalf("error umarshalling 'cap-95.hujson' contents: %v", err)
|
||||
}
|
||||
conf.AuthKey = nil
|
||||
b, err := json.Marshal(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("error marshalling 'cap-95.huson' contents: %v", err)
|
||||
}
|
||||
mak.Set(&secret.StringData, "cap-95.hujson", string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
567
cmd/natc/natc.go
Normal file
567
cmd/natc/natc.go
Normal file
@@ -0,0 +1,567 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The natc command is a work-in-progress implementation of a NAT based
|
||||
// connector for Tailscale. It is intended to be used to route traffic to a
|
||||
// specific domain through a specific node.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"github.com/inetaf/tcpproxy"
|
||||
"github.com/peterbourgon/ff/v3"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
func main() {
|
||||
hostinfo.SetApp("natc")
|
||||
if !envknob.UseWIPCode() {
|
||||
log.Fatal("cmd/natc is a work in progress and has not been security reviewed;\nits use requires TAILSCALE_USE_WIP_CODE=1 be set in the environment for now.")
|
||||
}
|
||||
|
||||
// Parse flags
|
||||
fs := flag.NewFlagSet("natc", flag.ExitOnError)
|
||||
var (
|
||||
debugPort = fs.Int("debug-port", 8893, "Listening port for debug/metrics endpoint")
|
||||
hostname = fs.String("hostname", "", "Hostname to register the service under")
|
||||
siteID = fs.Uint("site-id", 1, "an integer site ID to use for the ULA prefix which allows for multiple proxies to act in a HA configuration")
|
||||
v4PfxStr = fs.String("v4-pfx", "100.64.1.0/24", "comma-separated list of IPv4 prefixes to advertise")
|
||||
verboseTSNet = fs.Bool("verbose-tsnet", false, "enable verbose logging in tsnet")
|
||||
printULA = fs.Bool("print-ula", false, "print the ULA prefix and exit")
|
||||
ignoreDstPfxStr = fs.String("ignore-destinations", "", "comma-separated list of prefixes to ignore")
|
||||
wgPort = fs.Uint("wg-port", 0, "udp port for wireguard and peer to peer traffic")
|
||||
)
|
||||
ff.Parse(fs, os.Args[1:], ff.WithEnvVarPrefix("TS_NATC"))
|
||||
|
||||
if *printULA {
|
||||
fmt.Println(ula(uint16(*siteID)))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if *siteID == 0 {
|
||||
log.Fatalf("site-id must be set")
|
||||
} else if *siteID > 0xffff {
|
||||
log.Fatalf("site-id must be in the range [0, 65535]")
|
||||
}
|
||||
|
||||
var ignoreDstTable *bart.Table[bool]
|
||||
for _, s := range strings.Split(*ignoreDstPfxStr, ",") {
|
||||
s := strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if ignoreDstTable == nil {
|
||||
ignoreDstTable = &bart.Table[bool]{}
|
||||
}
|
||||
pfx, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
log.Fatalf("unable to parse prefix: %v", err)
|
||||
}
|
||||
if pfx.Masked() != pfx {
|
||||
log.Fatalf("prefix %v is not normalized (bits are set outside the mask)", pfx)
|
||||
}
|
||||
ignoreDstTable.Insert(pfx, true)
|
||||
}
|
||||
var v4Prefixes []netip.Prefix
|
||||
for _, s := range strings.Split(*v4PfxStr, ",") {
|
||||
p := netip.MustParsePrefix(strings.TrimSpace(s))
|
||||
if p.Masked() != p {
|
||||
log.Fatalf("v4 prefix %v is not a masked prefix", p)
|
||||
}
|
||||
v4Prefixes = append(v4Prefixes, p)
|
||||
}
|
||||
if len(v4Prefixes) == 0 {
|
||||
log.Fatalf("no v4 prefixes specified")
|
||||
}
|
||||
dnsAddr := v4Prefixes[0].Addr()
|
||||
ts := &tsnet.Server{
|
||||
Hostname: *hostname,
|
||||
}
|
||||
if *wgPort != 0 {
|
||||
if *wgPort >= 1<<16 {
|
||||
log.Fatalf("wg-port must be in the range [0, 65535]")
|
||||
}
|
||||
ts.Port = uint16(*wgPort)
|
||||
}
|
||||
defer ts.Close()
|
||||
if *verboseTSNet {
|
||||
ts.Logf = log.Printf
|
||||
}
|
||||
|
||||
// Start special-purpose listeners: dns, http promotion, debug server
|
||||
if *debugPort != 0 {
|
||||
mux := http.NewServeMux()
|
||||
tsweb.Debugger(mux)
|
||||
dln, err := ts.Listen("tcp", fmt.Sprintf(":%d", *debugPort))
|
||||
if err != nil {
|
||||
log.Fatalf("failed listening on debug port: %v", err)
|
||||
}
|
||||
defer dln.Close()
|
||||
go func() {
|
||||
log.Fatalf("debug serve: %v", http.Serve(dln, mux))
|
||||
}()
|
||||
}
|
||||
lc, err := ts.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatalf("LocalClient() failed: %v", err)
|
||||
}
|
||||
if _, err := ts.Up(ctx); err != nil {
|
||||
log.Fatalf("ts.Up: %v", err)
|
||||
}
|
||||
|
||||
c := &connector{
|
||||
ts: ts,
|
||||
lc: lc,
|
||||
dnsAddr: dnsAddr,
|
||||
v4Ranges: v4Prefixes,
|
||||
v6ULA: ula(uint16(*siteID)),
|
||||
ignoreDsts: ignoreDstTable,
|
||||
}
|
||||
c.run(ctx)
|
||||
}
|
||||
|
||||
type connector struct {
|
||||
// ts is the tsnet.Server used to host the connector.
|
||||
ts *tsnet.Server
|
||||
// lc is the LocalClient used to interact with the tsnet.Server hosting this
|
||||
// connector.
|
||||
lc *tailscale.LocalClient
|
||||
|
||||
// dnsAddr is the IPv4 address to listen on for DNS requests. It is used to
|
||||
// prevent the app connector from assigning it to a domain.
|
||||
dnsAddr netip.Addr
|
||||
|
||||
// v4Ranges is the list of IPv4 ranges to advertise and assign addresses from.
|
||||
// These are masked prefixes.
|
||||
v4Ranges []netip.Prefix
|
||||
// v6ULA is the ULA prefix used by the app connector to assign IPv6 addresses.
|
||||
v6ULA netip.Prefix
|
||||
|
||||
perPeerMap syncs.Map[tailcfg.NodeID, *perPeerState]
|
||||
|
||||
// ignoreDsts is initialized at start up with the contents of --ignore-destinations (if none it is nil)
|
||||
// It is never mutated, only used for lookups.
|
||||
// Users who want to natc a DNS wildcard but not every address record in that domain can supply the
|
||||
// exceptions in --ignore-destinations. When we receive a dns request we will look up the fqdn
|
||||
// and if any of the ip addresses in response to the lookup match any 'ignore destinations' prefix we will
|
||||
// return a dns response that contains the ip addresses we discovered with the lookup (ie not the
|
||||
// natc behavior, which would return a dummy ip address pointing at natc).
|
||||
ignoreDsts *bart.Table[bool]
|
||||
}
|
||||
|
||||
// v6ULA is the ULA prefix used by the app connector to assign IPv6 addresses.
|
||||
// The 8th and 9th bytes are used to encode the site ID which allows for
|
||||
// multiple proxies to act in a HA configuration.
|
||||
// mnemonic: a99c = appc
|
||||
var v6ULA = netip.MustParsePrefix("fd7a:115c:a1e0:a99c::/64")
|
||||
|
||||
func ula(siteID uint16) netip.Prefix {
|
||||
as16 := v6ULA.Addr().As16()
|
||||
as16[8] = byte(siteID >> 8)
|
||||
as16[9] = byte(siteID)
|
||||
return netip.PrefixFrom(netip.AddrFrom16(as16), 64+16)
|
||||
}
|
||||
|
||||
// run runs the connector.
|
||||
//
|
||||
// The passed in context is only used for the initial setup. The connector runs
|
||||
// forever.
|
||||
func (c *connector) run(ctx context.Context) {
|
||||
if _, err := c.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
AdvertiseRoutesSet: true,
|
||||
Prefs: ipn.Prefs{
|
||||
AdvertiseRoutes: append(c.v4Ranges, c.v6ULA),
|
||||
},
|
||||
}); err != nil {
|
||||
log.Fatalf("failed to advertise routes: %v", err)
|
||||
}
|
||||
c.ts.RegisterFallbackTCPHandler(c.handleTCPFlow)
|
||||
c.serveDNS()
|
||||
}
|
||||
|
||||
func (c *connector) serveDNS() {
|
||||
pc, err := c.ts.ListenPacket("udp", net.JoinHostPort(c.dnsAddr.String(), "53"))
|
||||
if err != nil {
|
||||
log.Fatalf("failed listening on port 53: %v", err)
|
||||
}
|
||||
defer pc.Close()
|
||||
log.Printf("Listening for DNS on %s", pc.LocalAddr().String())
|
||||
for {
|
||||
buf := make([]byte, 1500)
|
||||
n, addr, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
log.Printf("serveDNS.ReadFrom failed: %v", err)
|
||||
continue
|
||||
}
|
||||
go c.handleDNS(pc, buf[:n], addr.(*net.UDPAddr))
|
||||
}
|
||||
}
|
||||
|
||||
func lookupDestinationIP(domain string) ([]netip.Addr, error) {
|
||||
netIPs, err := net.LookupIP(domain)
|
||||
if err != nil {
|
||||
var dnsError *net.DNSError
|
||||
if errors.As(err, &dnsError) && dnsError.IsNotFound {
|
||||
return nil, nil
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var addrs []netip.Addr
|
||||
for _, ip := range netIPs {
|
||||
a, ok := netip.AddrFromSlice(ip)
|
||||
if ok {
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// handleDNS handles a DNS request to the app connector.
|
||||
// It generates a response based on the request and the node that sent it.
|
||||
//
|
||||
// Each node is assigned a unique pair of IP addresses for each domain it
|
||||
// queries. This assignment is done lazily and is not persisted across restarts.
|
||||
// A per-peer assignment allows the connector to reuse a limited number of IP
|
||||
// addresses across multiple nodes and domains. It also allows for clear
|
||||
// failover behavior when an app connector is restarted.
|
||||
//
|
||||
// This assignment later allows the connector to determine where to forward
|
||||
// traffic based on the destination IP address.
|
||||
func (c *connector) handleDNS(pc net.PacketConn, buf []byte, remoteAddr *net.UDPAddr) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
who, err := c.lc.WhoIs(ctx, remoteAddr.String())
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: WhoIs failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
var msg dnsmessage.Message
|
||||
err = msg.Unpack(buf)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: dnsmessage unpack failed: %v\n ", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If there are destination ips that we don't want to route, we
|
||||
// have to do a dns lookup here to find the destination ip.
|
||||
if c.ignoreDsts != nil {
|
||||
if len(msg.Questions) > 0 {
|
||||
q := msg.Questions[0]
|
||||
switch q.Type {
|
||||
case dnsmessage.TypeAAAA, dnsmessage.TypeA:
|
||||
dstAddrs, err := lookupDestinationIP(q.Name.String())
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: lookup destination failed: %v\n ", err)
|
||||
return
|
||||
}
|
||||
if c.ignoreDestination(dstAddrs) {
|
||||
bs, err := dnsResponse(&msg, dstAddrs)
|
||||
// TODO (fran): treat as SERVFAIL
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: generate ignore response failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
_, err = pc.WriteTo(bs, remoteAddr)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: write failed: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// None of the destination IP addresses match an ignore destination prefix, do
|
||||
// the natc thing.
|
||||
|
||||
resp, err := c.generateDNSResponse(&msg, who.Node.ID)
|
||||
// TODO (fran): treat as SERVFAIL
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: connector handling failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
// TODO (fran): treat as NXDOMAIN
|
||||
if len(resp) == 0 {
|
||||
return
|
||||
}
|
||||
// This connector handled the DNS request
|
||||
_, err = pc.WriteTo(resp, remoteAddr)
|
||||
if err != nil {
|
||||
log.Printf("HandleDNS: write failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// tsMBox is the mailbox used in SOA records.
|
||||
// The convention is to replace the @ symbol with a dot.
|
||||
// So in this case, the mailbox is support.tailscale.com. with the trailing dot
|
||||
// to indicate that it is a fully qualified domain name.
|
||||
var tsMBox = dnsmessage.MustNewName("support.tailscale.com.")
|
||||
|
||||
// generateDNSResponse generates a DNS response for the given request. The from
|
||||
// argument is the NodeID of the node that sent the request.
|
||||
func (c *connector) generateDNSResponse(req *dnsmessage.Message, from tailcfg.NodeID) ([]byte, error) {
|
||||
pm, _ := c.perPeerMap.LoadOrStore(from, &perPeerState{c: c})
|
||||
var addrs []netip.Addr
|
||||
if len(req.Questions) > 0 {
|
||||
switch req.Questions[0].Type {
|
||||
case dnsmessage.TypeAAAA, dnsmessage.TypeA:
|
||||
var err error
|
||||
addrs, err = pm.ipForDomain(req.Questions[0].Name.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return dnsResponse(req, addrs)
|
||||
}
|
||||
|
||||
// dnsResponse makes a DNS response for the natc. If the dnsmessage is requesting TypeAAAA
|
||||
// or TypeA the provided addrs of the requested type will be used.
|
||||
func dnsResponse(req *dnsmessage.Message, addrs []netip.Addr) ([]byte, error) {
|
||||
b := dnsmessage.NewBuilder(nil,
|
||||
dnsmessage.Header{
|
||||
ID: req.Header.ID,
|
||||
Response: true,
|
||||
Authoritative: true,
|
||||
})
|
||||
b.EnableCompression()
|
||||
|
||||
if len(req.Questions) == 0 {
|
||||
return b.Finish()
|
||||
}
|
||||
q := req.Questions[0]
|
||||
if err := b.StartQuestions(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.Question(q); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.StartAnswers(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch q.Type {
|
||||
case dnsmessage.TypeAAAA, dnsmessage.TypeA:
|
||||
want6 := q.Type == dnsmessage.TypeAAAA
|
||||
for _, ip := range addrs {
|
||||
if want6 != ip.Is6() {
|
||||
continue
|
||||
}
|
||||
if want6 {
|
||||
if err := b.AAAAResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 5},
|
||||
dnsmessage.AAAAResource{AAAA: ip.As16()},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := b.AResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 5},
|
||||
dnsmessage.AResource{A: ip.As4()},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
case dnsmessage.TypeSOA:
|
||||
if err := b.SOAResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.SOAResource{NS: q.Name, MBox: tsMBox, Serial: 2023030600,
|
||||
Refresh: 120, Retry: 120, Expire: 120, MinTTL: 60},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case dnsmessage.TypeNS:
|
||||
if err := b.NSResource(
|
||||
dnsmessage.ResourceHeader{Name: q.Name, Class: q.Class, TTL: 120},
|
||||
dnsmessage.NSResource{NS: tsMBox},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return b.Finish()
|
||||
}
|
||||
|
||||
// handleTCPFlow handles a TCP flow from the given source to the given
|
||||
// destination. It uses the source address to determine the node that sent the
|
||||
// request and the destination address to determine the domain that the request
|
||||
// is for based on the IP address assigned to the destination in the DNS
|
||||
// response.
|
||||
func (c *connector) handleTCPFlow(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
who, err := c.lc.WhoIs(ctx, src.Addr().String())
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("HandleTCPFlow: WhoIs failed: %v\n", err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
from := who.Node.ID
|
||||
ps, ok := c.perPeerMap.Load(from)
|
||||
if !ok {
|
||||
log.Printf("handleTCPFlow: no perPeerState for %v", from)
|
||||
return nil, false
|
||||
}
|
||||
domain, ok := ps.domainForIP(dst.Addr())
|
||||
if !ok {
|
||||
log.Printf("handleTCPFlow: no domain for IP %v\n", dst.Addr())
|
||||
return nil, false
|
||||
}
|
||||
return func(conn net.Conn) {
|
||||
proxyTCPConn(conn, domain)
|
||||
}, true
|
||||
}
|
||||
|
||||
// ignoreDestination reports whether any of the provided dstAddrs match the prefixes configured
|
||||
// in --ignore-destinations
|
||||
func (c *connector) ignoreDestination(dstAddrs []netip.Addr) bool {
|
||||
for _, a := range dstAddrs {
|
||||
if _, ok := c.ignoreDsts.Get(a); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func proxyTCPConn(c net.Conn, dest string) {
|
||||
addrPortStr := c.LocalAddr().String()
|
||||
_, port, err := net.SplitHostPort(addrPortStr)
|
||||
if err != nil {
|
||||
log.Printf("tcpRoundRobinHandler.Handle: bogus addrPort %q", addrPortStr)
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
|
||||
p := &tcpproxy.Proxy{
|
||||
ListenFunc: func(net, laddr string) (net.Listener, error) {
|
||||
return netutil.NewOneConnListener(c, nil), nil
|
||||
},
|
||||
}
|
||||
p.AddRoute(addrPortStr, &tcpproxy.DialProxy{
|
||||
Addr: fmt.Sprintf("%s:%s", dest, port),
|
||||
})
|
||||
p.Start()
|
||||
}
|
||||
|
||||
// perPeerState holds the state for a single peer.
|
||||
type perPeerState struct {
|
||||
c *connector
|
||||
|
||||
mu sync.Mutex
|
||||
domainToAddr map[string][]netip.Addr
|
||||
addrToDomain *bart.Table[string]
|
||||
}
|
||||
|
||||
// domainForIP returns the domain name assigned to the given IP address and
|
||||
// whether it was found.
|
||||
func (ps *perPeerState) domainForIP(ip netip.Addr) (_ string, ok bool) {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
return ps.addrToDomain.Get(ip)
|
||||
}
|
||||
|
||||
// ipForDomain assigns a pair of unique IP addresses for the given domain and
|
||||
// returns them. The first address is an IPv4 address and the second is an IPv6
|
||||
// address. If the domain already has assigned addresses, it returns them.
|
||||
func (ps *perPeerState) ipForDomain(domain string) ([]netip.Addr, error) {
|
||||
fqdn, err := dnsname.ToFQDN(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domain = fqdn.WithoutTrailingDot()
|
||||
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
if addrs, ok := ps.domainToAddr[domain]; ok {
|
||||
return addrs, nil
|
||||
}
|
||||
addrs := ps.assignAddrsLocked(domain)
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// isIPUsedLocked reports whether the given IP address is already assigned to a
|
||||
// domain.
|
||||
// ps.mu must be held.
|
||||
func (ps *perPeerState) isIPUsedLocked(ip netip.Addr) bool {
|
||||
_, ok := ps.addrToDomain.Get(ip)
|
||||
return ok
|
||||
}
|
||||
|
||||
// unusedIPv4Locked returns an unused IPv4 address from the available ranges.
|
||||
func (ps *perPeerState) unusedIPv4Locked() netip.Addr {
|
||||
// TODO: skip ranges that have been exhausted
|
||||
for _, r := range ps.c.v4Ranges {
|
||||
ip := randV4(r)
|
||||
for r.Contains(ip) {
|
||||
if !ps.isIPUsedLocked(ip) && ip != ps.c.dnsAddr {
|
||||
return ip
|
||||
}
|
||||
ip = ip.Next()
|
||||
}
|
||||
}
|
||||
return netip.Addr{}
|
||||
}
|
||||
|
||||
// randV4 returns a random IPv4 address within the given prefix.
|
||||
func randV4(maskedPfx netip.Prefix) netip.Addr {
|
||||
bits := 32 - maskedPfx.Bits()
|
||||
randBits := rand.Uint32N(1 << uint(bits))
|
||||
|
||||
ip4 := maskedPfx.Addr().As4()
|
||||
pn := binary.BigEndian.Uint32(ip4[:])
|
||||
binary.BigEndian.PutUint32(ip4[:], randBits|pn)
|
||||
return netip.AddrFrom4(ip4)
|
||||
}
|
||||
|
||||
// assignAddrsLocked assigns a pair of unique IP addresses for the given domain
|
||||
// and returns them. The first address is an IPv4 address and the second is an
|
||||
// IPv6 address. It does not check if the domain already has assigned addresses.
|
||||
// ps.mu must be held.
|
||||
func (ps *perPeerState) assignAddrsLocked(domain string) []netip.Addr {
|
||||
if ps.addrToDomain == nil {
|
||||
ps.addrToDomain = &bart.Table[string]{}
|
||||
}
|
||||
v4 := ps.unusedIPv4Locked()
|
||||
as16 := ps.c.v6ULA.Addr().As16()
|
||||
as4 := v4.As4()
|
||||
copy(as16[12:], as4[:])
|
||||
v6 := netip.AddrFrom16(as16)
|
||||
addrs := []netip.Addr{v4, v6}
|
||||
mak.Set(&ps.domainToAddr, domain, addrs)
|
||||
for _, a := range addrs {
|
||||
ps.addrToDomain.Insert(netip.PrefixFrom(a, a.BitLen()), domain)
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
@@ -314,7 +314,7 @@ func mustMakeNamesByAddr() map[netip.Addr]string {
|
||||
seen := make(map[string]bool)
|
||||
namesByAddr := make(map[netip.Addr]string)
|
||||
retry:
|
||||
for i := 0; i < 10; i++ {
|
||||
for i := range 10 {
|
||||
clear(seen)
|
||||
clear(namesByAddr)
|
||||
for _, d := range m.Devices {
|
||||
@@ -354,7 +354,7 @@ func fieldPrefix(s string, n int) string {
|
||||
}
|
||||
|
||||
func appendRepeatByte(b []byte, c byte, n int) []byte {
|
||||
for i := 0; i < n; i++ {
|
||||
for range n {
|
||||
b = append(b, c)
|
||||
}
|
||||
return b
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"tailscale.com/metrics"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -58,8 +57,6 @@ func main() {
|
||||
ts := &tsnet.Server{
|
||||
Dir: *tailscaleDir,
|
||||
Hostname: *hostname,
|
||||
// Make the stdout logs a clean audit log of connections.
|
||||
Logf: logger.Discard,
|
||||
}
|
||||
|
||||
if os.Getenv("TS_AUTHKEY") == "" {
|
||||
|
||||
@@ -88,7 +88,7 @@ func main() {
|
||||
|
||||
go func() {
|
||||
// wait for tailscale to start before trying to fetch cert names
|
||||
for i := 0; i < 60; i++ {
|
||||
for range 60 {
|
||||
st, err := localClient.Status(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("error retrieving tailscale status; retrying: %v", err)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
@@ -47,7 +47,7 @@ func (h *tcpRoundRobinHandler) Handle(c net.Conn) {
|
||||
return netutil.NewOneConnListener(c, nil), nil
|
||||
}
|
||||
|
||||
dest := h.To[rand.Intn(len(h.To))]
|
||||
dest := h.To[rand.IntN(len(h.To))]
|
||||
dial := &tcpproxy.DialProxy{
|
||||
Addr: fmt.Sprintf("%s:%s", dest, port),
|
||||
DialContext: h.DialContext,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tstest/integration"
|
||||
"tailscale.com/tstest/integration/testcontrol"
|
||||
"tailscale.com/tstest/nettest"
|
||||
"tailscale.com/types/appctype"
|
||||
"tailscale.com/types/ipproto"
|
||||
"tailscale.com/types/key"
|
||||
@@ -98,8 +100,8 @@ func startNode(t *testing.T, ctx context.Context, controlURL, hostname string) (
|
||||
Store: new(mem.Store),
|
||||
Ephemeral: true,
|
||||
}
|
||||
if !*verboseNodes {
|
||||
s.Logf = logger.Discard
|
||||
if *verboseNodes {
|
||||
s.Logf = log.Printf
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
|
||||
@@ -111,6 +113,7 @@ func startNode(t *testing.T, ctx context.Context, controlURL, hostname string) (
|
||||
}
|
||||
|
||||
func TestSNIProxyWithNetmapConfig(t *testing.T) {
|
||||
nettest.SkipIfNoNetwork(t)
|
||||
c, controlURL := startControl(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -158,7 +161,7 @@ func TestSNIProxyWithNetmapConfig(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotConfigured := false
|
||||
for i := 0; i < 100; i++ {
|
||||
for range 100 {
|
||||
s, err := l.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -189,6 +192,7 @@ func TestSNIProxyWithNetmapConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSNIProxyWithFlagConfig(t *testing.T) {
|
||||
nettest.SkipIfNoNetwork(t)
|
||||
_, controlURL := startControl(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -20,7 +20,7 @@ func main() {
|
||||
}
|
||||
host := os.Args[1]
|
||||
|
||||
uaddr, err := net.ResolveUDPAddr("udp", host+":3478")
|
||||
uaddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, "3478"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
|
||||
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
|
||||
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
|
||||
github.com/google/uuid from tailscale.com/tsweb
|
||||
github.com/google/uuid from tailscale.com/util/fastuuid
|
||||
💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz
|
||||
github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus
|
||||
github.com/prometheus/client_model/go from github.com/prometheus/client_golang/prometheus+
|
||||
@@ -20,6 +20,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
google.golang.org/protobuf/internal/descfmt from google.golang.org/protobuf/internal/filedesc
|
||||
google.golang.org/protobuf/internal/descopts from google.golang.org/protobuf/internal/filedesc+
|
||||
google.golang.org/protobuf/internal/detrand from google.golang.org/protobuf/internal/descfmt+
|
||||
google.golang.org/protobuf/internal/editiondefaults from google.golang.org/protobuf/internal/filedesc
|
||||
google.golang.org/protobuf/internal/encoding/defval from google.golang.org/protobuf/internal/encoding/tag+
|
||||
google.golang.org/protobuf/internal/encoding/messageset from google.golang.org/protobuf/encoding/prototext+
|
||||
google.golang.org/protobuf/internal/encoding/tag from google.golang.org/protobuf/internal/impl
|
||||
@@ -65,6 +66,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
tailscale.com/util/ctxkey from tailscale.com/tsweb+
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/tailcfg
|
||||
tailscale.com/util/fastuuid from tailscale.com/tsweb
|
||||
tailscale.com/util/lineread from tailscale.com/version/distro
|
||||
tailscale.com/util/nocasemaps from tailscale.com/types/ipproto
|
||||
tailscale.com/util/slicesx from tailscale.com/tailcfg
|
||||
@@ -151,6 +153,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
math/big from crypto/dsa+
|
||||
math/bits from compress/flate+
|
||||
math/rand from math/big+
|
||||
math/rand/v2 from tailscale.com/util/fastuuid+
|
||||
mime from github.com/prometheus/common/expfmt+
|
||||
mime/multipart from net/http
|
||||
mime/quotedprintable from mime/multipart
|
||||
|
||||
142
cmd/stunstamp/api.go
Normal file
142
cmd/stunstamp/api.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
)
|
||||
|
||||
type api struct {
|
||||
db *db
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func newAPI(db *db) *api {
|
||||
a := &api{
|
||||
db: db,
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/query", a.query)
|
||||
a.mux = mux
|
||||
return a
|
||||
}
|
||||
|
||||
type apiResult struct {
|
||||
At int `json:"at"` // time.Time.Unix()
|
||||
RegionID int `json:"regionID"`
|
||||
Hostname string `json:"hostname"`
|
||||
Af int `json:"af"` // 4 or 6
|
||||
Addr string `json:"addr"`
|
||||
Source int `json:"source"` // timestampSourceUserspace (0) or timestampSourceKernel (1)
|
||||
StableConn bool `json:"stableConn"`
|
||||
DstPort int `json:"dstPort"`
|
||||
RttNS *int `json:"rttNS"`
|
||||
}
|
||||
|
||||
func getTimeBounds(vals url.Values) (from time.Time, to time.Time, err error) {
|
||||
lastForm, ok := vals["last"]
|
||||
if ok && len(lastForm) > 0 {
|
||||
dur, err := time.ParseDuration(lastForm[0])
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
return now.Add(-dur), now, nil
|
||||
}
|
||||
|
||||
fromForm, ok := vals["from"]
|
||||
if ok && len(fromForm) > 0 {
|
||||
fromUnixSec, err := strconv.Atoi(fromForm[0])
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
from = time.Unix(int64(fromUnixSec), 0)
|
||||
toForm, ok := vals["to"]
|
||||
if ok && len(toForm) > 0 {
|
||||
toUnixSec, err := strconv.Atoi(toForm[0])
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
to = time.Unix(int64(toUnixSec), 0)
|
||||
} else {
|
||||
return time.Time{}, time.Time{}, errors.New("from specified without to")
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
// no time bounds specified, default to last 1h
|
||||
now := time.Now()
|
||||
return now.Add(-time.Hour), now, nil
|
||||
}
|
||||
|
||||
func (a *api) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
a.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (a *api) query(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
from, to, err := getTimeBounds(r.Form)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
sb := sq.Select("at_unix", "region_id", "hostname", "af", "address", "timestamp_source", "stable_conn", "dst_port", "rtt_ns").From("rtt")
|
||||
sb = sb.Where(sq.And{
|
||||
sq.GtOrEq{"at_unix": from.Unix()},
|
||||
sq.LtOrEq{"at_unix": to.Unix()},
|
||||
})
|
||||
query, args, err := sb.ToSql()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := a.db.Query(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
results := make([]apiResult, 0)
|
||||
for rows.Next() {
|
||||
rtt := 0
|
||||
result := apiResult{
|
||||
RttNS: &rtt,
|
||||
}
|
||||
err = rows.Scan(&result.At, &result.RegionID, &result.Hostname, &result.Af, &result.Addr, &result.Source, &result.StableConn, &result.DstPort, &result.RttNS)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
http.Error(w, rows.Err().Error(), 500)
|
||||
return
|
||||
}
|
||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
err = json.NewEncoder(gz).Encode(&results)
|
||||
} else {
|
||||
err = json.NewEncoder(w).Encode(&results)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
825
cmd/stunstamp/stunstamp.go
Normal file
825
cmd/stunstamp/stunstamp.go
Normal file
@@ -0,0 +1,825 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The stunstamp binary measures STUN round-trip latency with DERPs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/golang/snappy"
|
||||
"github.com/prometheus/prometheus/prompb"
|
||||
"tailscale.com/logtail/backoff"
|
||||
"tailscale.com/net/stun"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
var (
|
||||
flagDERPMap = flag.String("derp-map", "https://login.tailscale.com/derpmap/default", "URL to DERP map")
|
||||
flagOut = flag.String("out", "", "output sqlite filename")
|
||||
flagInterval = flag.Duration("interval", time.Minute, "interval to probe at in time.ParseDuration() format")
|
||||
flagAPI = flag.String("api", "", "listen addr for HTTP API")
|
||||
flagIPv6 = flag.Bool("ipv6", false, "probe IPv6 addresses")
|
||||
flagRetention = flag.Duration("retention", time.Hour*24*7, "sqlite retention period in time.ParseDuration() format")
|
||||
flagRemoteWriteURL = flag.String("rw-url", "", "prometheus remote write URL")
|
||||
flagInstance = flag.String("instance", "", "instance label value; defaults to hostname if unspecified")
|
||||
flagDstPorts = flag.String("dst-ports", "", "comma-separated list of destination ports to monitor")
|
||||
)
|
||||
|
||||
const (
|
||||
minInterval = time.Second
|
||||
maxBufferDuration = time.Hour
|
||||
)
|
||||
|
||||
func getDERPMap(ctx context.Context, url string) (*tailcfg.DERPMap, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
dm := tailcfg.DERPMap{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&dm)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &dm, nil
|
||||
}
|
||||
|
||||
type timestampSource int
|
||||
|
||||
const (
|
||||
timestampSourceUserspace timestampSource = iota
|
||||
timestampSourceKernel
|
||||
)
|
||||
|
||||
func (t timestampSource) String() string {
|
||||
switch t {
|
||||
case timestampSourceUserspace:
|
||||
return "userspace"
|
||||
case timestampSourceKernel:
|
||||
return "kernel"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
type result struct {
|
||||
at time.Time
|
||||
meta nodeMeta
|
||||
timestampSource timestampSource
|
||||
connStability connStability
|
||||
dstPort int
|
||||
rtt *time.Duration // nil signifies failure, e.g. timeout
|
||||
}
|
||||
|
||||
func measureRTT(conn io.ReadWriteCloser, dst *net.UDPAddr) (rtt time.Duration, err error) {
|
||||
uconn, ok := conn.(*net.UDPConn)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unexpected conn type: %T", conn)
|
||||
}
|
||||
err = uconn.SetReadDeadline(time.Now().Add(time.Second * 2))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error setting read deadline: %w", err)
|
||||
}
|
||||
txID := stun.NewTxID()
|
||||
req := stun.Request(txID)
|
||||
txAt := time.Now()
|
||||
_, err = uconn.WriteToUDP(req, dst)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error writing to udp socket: %w", err)
|
||||
}
|
||||
b := make([]byte, 1460)
|
||||
for {
|
||||
n, err := uconn.Read(b)
|
||||
rxAt := time.Now()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error reading from udp socket: %w", err)
|
||||
}
|
||||
gotTxID, _, err := stun.ParseResponse(b[:n])
|
||||
if err != nil || gotTxID != txID {
|
||||
continue
|
||||
}
|
||||
return rxAt.Sub(txAt), nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func isTemporaryOrTimeoutErr(err error) bool {
|
||||
if errors.Is(err, os.ErrDeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
if err, ok := err.(interface{ Temporary() bool }); ok {
|
||||
return err.Temporary()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type nodeMeta struct {
|
||||
regionID int
|
||||
regionCode string
|
||||
hostname string
|
||||
addr netip.Addr
|
||||
}
|
||||
|
||||
type measureFn func(conn io.ReadWriteCloser, dst *net.UDPAddr) (rtt time.Duration, err error)
|
||||
|
||||
func probe(meta nodeMeta, conn io.ReadWriteCloser, fn measureFn, dstPort int) (*time.Duration, error) {
|
||||
ua := &net.UDPAddr{
|
||||
IP: net.IP(meta.addr.AsSlice()),
|
||||
Port: dstPort,
|
||||
}
|
||||
|
||||
time.Sleep(rand.N(200 * time.Millisecond)) // jitter across tx
|
||||
rtt, err := fn(conn, ua)
|
||||
if err != nil {
|
||||
if isTemporaryOrTimeoutErr(err) {
|
||||
log.Printf("temp error measuring RTT to %s(%s): %v", meta.hostname, ua.String(), err)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return &rtt, nil
|
||||
}
|
||||
|
||||
func nodeMetaFromDERPMap(dm *tailcfg.DERPMap, nodeMetaByAddr map[netip.Addr]nodeMeta, ipv6 bool) (stale []nodeMeta, err error) {
|
||||
// Parse the new derp map before making any state changes in nodeMetaByAddr.
|
||||
// If parse fails we just stick with the old state.
|
||||
updated := make(map[netip.Addr]nodeMeta)
|
||||
for regionID, region := range dm.Regions {
|
||||
for _, node := range region.Nodes {
|
||||
v4, err := netip.ParseAddr(node.IPv4)
|
||||
if err != nil || !v4.Is4() {
|
||||
return nil, fmt.Errorf("invalid ipv4 addr for node in derp map: %v", node.Name)
|
||||
}
|
||||
metas := make([]nodeMeta, 0, 2)
|
||||
metas = append(metas, nodeMeta{
|
||||
regionID: regionID,
|
||||
regionCode: region.RegionCode,
|
||||
hostname: node.HostName,
|
||||
addr: v4,
|
||||
})
|
||||
if ipv6 {
|
||||
v6, err := netip.ParseAddr(node.IPv6)
|
||||
if err != nil || !v6.Is6() {
|
||||
return nil, fmt.Errorf("invalid ipv6 addr for node in derp map: %v", node.Name)
|
||||
}
|
||||
metas = append(metas, metas[0])
|
||||
metas[1].addr = v6
|
||||
}
|
||||
for _, meta := range metas {
|
||||
updated[meta.addr] = meta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find nodeMeta that have changed
|
||||
for addr, updatedMeta := range updated {
|
||||
previousMeta, ok := nodeMetaByAddr[addr]
|
||||
if ok {
|
||||
if previousMeta == updatedMeta {
|
||||
continue
|
||||
}
|
||||
stale = append(stale, previousMeta)
|
||||
nodeMetaByAddr[addr] = updatedMeta
|
||||
} else {
|
||||
nodeMetaByAddr[addr] = updatedMeta
|
||||
}
|
||||
}
|
||||
|
||||
// Find nodeMeta that no longer exist
|
||||
for addr, potentialStale := range nodeMetaByAddr {
|
||||
_, ok := updated[addr]
|
||||
if !ok {
|
||||
stale = append(stale, potentialStale)
|
||||
}
|
||||
}
|
||||
|
||||
return stale, nil
|
||||
}
|
||||
|
||||
func getStableConns(stableConns map[netip.Addr]map[int][2]io.ReadWriteCloser, addr netip.Addr, dstPort int) ([2]io.ReadWriteCloser, error) {
|
||||
conns := [2]io.ReadWriteCloser{}
|
||||
byDstPort, ok := stableConns[addr]
|
||||
if ok {
|
||||
conns, ok = byDstPort[dstPort]
|
||||
if ok {
|
||||
return conns, nil
|
||||
}
|
||||
}
|
||||
if supportsKernelTS() {
|
||||
kconn, err := getConnKernelTimestamp()
|
||||
if err != nil {
|
||||
return conns, err
|
||||
}
|
||||
conns[timestampSourceKernel] = kconn
|
||||
}
|
||||
uconn, err := net.ListenUDP("udp", &net.UDPAddr{})
|
||||
if err != nil {
|
||||
if supportsKernelTS() {
|
||||
conns[timestampSourceKernel].Close()
|
||||
}
|
||||
return conns, err
|
||||
}
|
||||
conns[timestampSourceUserspace] = uconn
|
||||
if byDstPort == nil {
|
||||
byDstPort = make(map[int][2]io.ReadWriteCloser)
|
||||
}
|
||||
byDstPort[dstPort] = conns
|
||||
stableConns[addr] = byDstPort
|
||||
return conns, nil
|
||||
}
|
||||
|
||||
// probeNodes measures the round-trip time for STUN binding requests against the
|
||||
// DERP nodes described by nodeMetaByAddr while using/updating stableConns for
|
||||
// UDP sockets that should be recycled across runs. It returns the results or
|
||||
// an error if one occurs.
|
||||
func probeNodes(nodeMetaByAddr map[netip.Addr]nodeMeta, stableConns map[netip.Addr]map[int][2]io.ReadWriteCloser, dstPorts []int) ([]result, error) {
|
||||
wg := sync.WaitGroup{}
|
||||
results := make([]result, 0)
|
||||
resultsCh := make(chan result)
|
||||
errCh := make(chan error)
|
||||
doneCh := make(chan struct{})
|
||||
numProbes := 0
|
||||
at := time.Now()
|
||||
addrsToProbe := make(map[netip.Addr]bool)
|
||||
|
||||
doProbe := func(conn io.ReadWriteCloser, meta nodeMeta, source timestampSource, dstPort int) {
|
||||
defer wg.Done()
|
||||
r := result{
|
||||
at: at,
|
||||
meta: meta,
|
||||
timestampSource: source,
|
||||
dstPort: dstPort,
|
||||
}
|
||||
if conn == nil {
|
||||
var err error
|
||||
if source == timestampSourceKernel {
|
||||
conn, err = getConnKernelTimestamp()
|
||||
} else {
|
||||
conn, err = net.ListenUDP("udp", &net.UDPAddr{})
|
||||
}
|
||||
if err != nil {
|
||||
select {
|
||||
case <-doneCh:
|
||||
return
|
||||
case errCh <- err:
|
||||
return
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
} else {
|
||||
r.connStability = stableConn
|
||||
}
|
||||
fn := measureRTT
|
||||
if source == timestampSourceKernel {
|
||||
fn = measureRTTKernel
|
||||
}
|
||||
rtt, err := probe(meta, conn, fn, dstPort)
|
||||
if err != nil {
|
||||
select {
|
||||
case <-doneCh:
|
||||
return
|
||||
case errCh <- err:
|
||||
return
|
||||
}
|
||||
}
|
||||
r.rtt = rtt
|
||||
select {
|
||||
case <-doneCh:
|
||||
case resultsCh <- r:
|
||||
}
|
||||
}
|
||||
|
||||
for _, meta := range nodeMetaByAddr {
|
||||
addrsToProbe[meta.addr] = true
|
||||
for _, port := range dstPorts {
|
||||
stable, err := getStableConns(stableConns, meta.addr, port)
|
||||
if err != nil {
|
||||
close(doneCh)
|
||||
wg.Wait()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
numProbes += 2
|
||||
go doProbe(stable[timestampSourceUserspace], meta, timestampSourceUserspace, port)
|
||||
go doProbe(nil, meta, timestampSourceUserspace, port)
|
||||
if supportsKernelTS() {
|
||||
wg.Add(2)
|
||||
numProbes += 2
|
||||
go doProbe(stable[timestampSourceKernel], meta, timestampSourceKernel, port)
|
||||
go doProbe(nil, meta, timestampSourceKernel, port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup conns we no longer need
|
||||
for k, byDstPort := range stableConns {
|
||||
if !addrsToProbe[k] {
|
||||
for _, conns := range byDstPort {
|
||||
if conns[timestampSourceKernel] != nil {
|
||||
conns[timestampSourceKernel].Close()
|
||||
}
|
||||
conns[timestampSourceUserspace].Close()
|
||||
delete(stableConns, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-errCh:
|
||||
close(doneCh)
|
||||
wg.Wait()
|
||||
return nil, err
|
||||
case result := <-resultsCh:
|
||||
results = append(results, result)
|
||||
if len(results) == numProbes {
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type connStability bool
|
||||
|
||||
const (
|
||||
unstableConn connStability = false
|
||||
stableConn connStability = true
|
||||
)
|
||||
|
||||
func timeSeriesLabels(meta nodeMeta, instance string, source timestampSource, stability connStability, dstPort int) []prompb.Label {
|
||||
addressFamily := "ipv4"
|
||||
if meta.addr.Is6() {
|
||||
addressFamily = "ipv6"
|
||||
}
|
||||
labels := make([]prompb.Label, 0)
|
||||
labels = append(labels, prompb.Label{
|
||||
Name: "job",
|
||||
Value: "stunstamp-rw",
|
||||
})
|
||||
labels = append(labels, prompb.Label{
|
||||
Name: "instance",
|
||||
Value: instance,
|
||||
})
|
||||
labels = append(labels, prompb.Label{
|
||||
Name: "region_id",
|
||||
Value: fmt.Sprintf("%d", meta.regionID),
|
||||
})
|
||||
labels = append(labels, prompb.Label{
|
||||
Name: "region_code",
|
||||
Value: meta.regionCode,
|
||||
})
|
||||
labels = append(labels, prompb.Label{
|
||||
Name: "address_family",
|
||||
Value: addressFamily,
|
||||
})
|
||||
labels = append(labels, prompb.Label{
|
||||
Name: "hostname",
|
||||
Value: meta.hostname,
|
||||
})
|
||||
labels = append(labels, prompb.Label{
|
||||
Name: "dst_port",
|
||||
Value: strconv.Itoa(dstPort),
|
||||
})
|
||||
labels = append(labels, prompb.Label{
|
||||
Name: "__name__",
|
||||
Value: "stunstamp_derp_stun_rtt_ns",
|
||||
})
|
||||
labels = append(labels, prompb.Label{
|
||||
Name: "timestamp_source",
|
||||
Value: source.String(),
|
||||
})
|
||||
labels = append(labels, prompb.Label{
|
||||
Name: "stable_conn",
|
||||
Value: fmt.Sprintf("%v", stability),
|
||||
})
|
||||
slices.SortFunc(labels, func(a, b prompb.Label) int {
|
||||
// prometheus remote-write spec requires lexicographically sorted label names
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
})
|
||||
return labels
|
||||
}
|
||||
|
||||
const (
|
||||
// https://prometheus.io/docs/concepts/remote_write_spec/#stale-markers
|
||||
staleNaN uint64 = 0x7ff0000000000002
|
||||
)
|
||||
|
||||
func staleMarkersFromNodeMeta(stale []nodeMeta, instance string, dstPorts []int) []prompb.TimeSeries {
|
||||
staleMarkers := make([]prompb.TimeSeries, 0)
|
||||
now := time.Now()
|
||||
for _, s := range stale {
|
||||
for _, dstPort := range dstPorts {
|
||||
samples := []prompb.Sample{
|
||||
{
|
||||
Timestamp: now.UnixMilli(),
|
||||
Value: math.Float64frombits(staleNaN),
|
||||
},
|
||||
}
|
||||
staleMarkers = append(staleMarkers, prompb.TimeSeries{
|
||||
Labels: timeSeriesLabels(s, instance, timestampSourceUserspace, unstableConn, dstPort),
|
||||
Samples: samples,
|
||||
})
|
||||
staleMarkers = append(staleMarkers, prompb.TimeSeries{
|
||||
Labels: timeSeriesLabels(s, instance, timestampSourceUserspace, stableConn, dstPort),
|
||||
Samples: samples,
|
||||
})
|
||||
if supportsKernelTS() {
|
||||
staleMarkers = append(staleMarkers, prompb.TimeSeries{
|
||||
Labels: timeSeriesLabels(s, instance, timestampSourceKernel, unstableConn, dstPort),
|
||||
Samples: samples,
|
||||
})
|
||||
staleMarkers = append(staleMarkers, prompb.TimeSeries{
|
||||
Labels: timeSeriesLabels(s, instance, timestampSourceKernel, stableConn, dstPort),
|
||||
Samples: samples,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return staleMarkers
|
||||
}
|
||||
|
||||
func resultToPromTimeSeries(r result, instance string) prompb.TimeSeries {
|
||||
labels := timeSeriesLabels(r.meta, instance, r.timestampSource, r.connStability, r.dstPort)
|
||||
samples := make([]prompb.Sample, 1)
|
||||
samples[0].Timestamp = r.at.UnixMilli()
|
||||
if r.rtt != nil {
|
||||
samples[0].Value = float64(*r.rtt)
|
||||
} else {
|
||||
samples[0].Value = math.NaN()
|
||||
// TODO: timeout counter
|
||||
}
|
||||
ts := prompb.TimeSeries{
|
||||
Labels: labels,
|
||||
Samples: samples,
|
||||
}
|
||||
slices.SortFunc(ts.Labels, func(a, b prompb.Label) int {
|
||||
// prometheus remote-write spec requires lexicographically sorted label names
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
})
|
||||
return ts
|
||||
}
|
||||
|
||||
type remoteWriteClient struct {
|
||||
c *http.Client
|
||||
url string
|
||||
}
|
||||
|
||||
type recoverableErr struct {
|
||||
error
|
||||
}
|
||||
|
||||
func newRemoteWriteClient(url string) *remoteWriteClient {
|
||||
return &remoteWriteClient{
|
||||
c: &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
},
|
||||
url: url,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *remoteWriteClient) write(ctx context.Context, ts []prompb.TimeSeries) error {
|
||||
wr := &prompb.WriteRequest{
|
||||
Timeseries: ts,
|
||||
}
|
||||
b, err := wr.Marshal()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to marshal write request: %w", err)
|
||||
}
|
||||
compressed := snappy.Encode(nil, b)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", r.url, bytes.NewReader(compressed))
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create write request: %w", err)
|
||||
}
|
||||
req.Header.Add("Content-Encoding", "snappy")
|
||||
req.Header.Set("Content-Type", "application/x-protobuf")
|
||||
req.Header.Set("User-Agent", "stunstamp")
|
||||
req.Header.Set("X-Prometheus-Remote-Write-Version", "0.1.0")
|
||||
resp, err := r.c.Do(req)
|
||||
if err != nil {
|
||||
return recoverableErr{fmt.Errorf("error performing write request: %w", err)}
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
err = fmt.Errorf("remote server %s returned HTTP status %d", r.url, resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode/100 == 5 || resp.StatusCode == http.StatusTooManyRequests {
|
||||
return recoverableErr{err}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func remoteWriteTimeSeries(client *remoteWriteClient, tsCh chan []prompb.TimeSeries) {
|
||||
bo := backoff.NewBackoff("remote-write", log.Printf, time.Second*30)
|
||||
// writeErr may contribute to bo's backoff schedule across tsCh read ops,
|
||||
// i.e. if an unrecoverable error occurs for client.write(ctx, A), that
|
||||
// should be accounted against bo prior to attempting to
|
||||
// client.write(ctx, B).
|
||||
var writeErr error
|
||||
for ts := range tsCh {
|
||||
for {
|
||||
bo.BackOff(context.Background(), writeErr)
|
||||
reqCtx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
||||
writeErr = client.write(reqCtx, ts)
|
||||
cancel()
|
||||
var re recoverableErr
|
||||
recoverable := errors.As(writeErr, &re)
|
||||
if writeErr != nil {
|
||||
log.Printf("remote write error(recoverable=%v): %v", recoverable, writeErr)
|
||||
}
|
||||
if !recoverable {
|
||||
// a nil err is not recoverable
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if len(*flagDstPorts) == 0 {
|
||||
log.Fatal("dst-ports flag is unset")
|
||||
}
|
||||
dstPortsSplit := strings.Split(*flagDstPorts, ",")
|
||||
slices.Sort(dstPortsSplit)
|
||||
dstPortsSplit = slices.Compact(dstPortsSplit)
|
||||
dstPorts := make([]int, 0, len(dstPortsSplit))
|
||||
for _, d := range dstPortsSplit {
|
||||
i, err := strconv.ParseUint(d, 10, 16)
|
||||
if err != nil {
|
||||
log.Fatal("invalid dst-ports")
|
||||
}
|
||||
dstPorts = append(dstPorts, int(i))
|
||||
}
|
||||
if len(*flagDERPMap) < 1 {
|
||||
log.Fatal("derp-map flag is unset")
|
||||
}
|
||||
if len(*flagOut) < 1 {
|
||||
log.Fatal("out flag is unset")
|
||||
}
|
||||
if *flagInterval < minInterval || *flagInterval > maxBufferDuration {
|
||||
log.Fatalf("interval must be >= %s and <= %s", minInterval, maxBufferDuration)
|
||||
}
|
||||
if *flagRetention < *flagInterval {
|
||||
log.Fatal("retention must be >= interval")
|
||||
}
|
||||
if len(*flagRemoteWriteURL) < 1 {
|
||||
log.Fatal("rw-url flag is unset")
|
||||
}
|
||||
_, err := url.Parse(*flagRemoteWriteURL)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid rw-url flag value: %v", err)
|
||||
}
|
||||
if len(*flagInstance) < 1 {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get hostname: %v", err)
|
||||
}
|
||||
*flagInstance = hostname
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
dmCh := make(chan *tailcfg.DERPMap)
|
||||
|
||||
go func() {
|
||||
bo := backoff.NewBackoff("derp-map", log.Printf, time.Second*30)
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
dm, err := getDERPMap(ctx, *flagDERPMap)
|
||||
cancel()
|
||||
bo.BackOff(context.Background(), err)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dmCh <- dm
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
nodeMetaByAddr := make(map[netip.Addr]nodeMeta)
|
||||
select {
|
||||
case <-sigCh:
|
||||
return
|
||||
case dm := <-dmCh:
|
||||
_, err := nodeMetaFromDERPMap(dm, nodeMetaByAddr, *flagIPv6)
|
||||
if err != nil {
|
||||
log.Fatalf("error parsing derp map on startup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := newDB(*flagOut)
|
||||
if err != nil {
|
||||
log.Fatalf("error opening output file for writing: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("PRAGMA journal_mode=WAL")
|
||||
if err != nil {
|
||||
log.Fatalf("error enabling WAL mode: %v", err)
|
||||
}
|
||||
|
||||
// No indices or primary key. Keep it simple for now. Reads will be full
|
||||
// scans. We can AUTOINCREMENT rowid in the future and hold an in-memory
|
||||
// index to at_unix if needed as reads are almost always going to be
|
||||
// time-bound (e.g. WHERE at_unix >= ?). At the time of authorship we have
|
||||
// ~300 data points per-interval w/o ipv6 w/kernel timestamping resulting
|
||||
// in ~2.6m rows in 24h w/a 10s probe interval.
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS rtt(at_unix INT, region_id INT, hostname TEXT, af INT, address TEXT, timestamp_source INT, stable_conn INT, dst_port INT, rtt_ns INT)
|
||||
`)
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing db: %v", err)
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
httpErrCh := make(chan error, 1)
|
||||
var httpServer *http.Server
|
||||
if len(*flagAPI) > 0 {
|
||||
api := newAPI(db)
|
||||
httpServer = &http.Server{
|
||||
Addr: *flagAPI,
|
||||
Handler: api,
|
||||
ReadTimeout: time.Second * 60,
|
||||
WriteTimeout: time.Second * 60,
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
err := httpServer.ListenAndServe()
|
||||
httpErrCh <- err
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
|
||||
tsCh := make(chan []prompb.TimeSeries, maxBufferDuration / *flagInterval)
|
||||
remoteWriteDoneCh := make(chan struct{})
|
||||
rwc := newRemoteWriteClient(*flagRemoteWriteURL)
|
||||
go func() {
|
||||
remoteWriteTimeSeries(rwc, tsCh)
|
||||
close(remoteWriteDoneCh)
|
||||
}()
|
||||
|
||||
shutdown := func() {
|
||||
if httpServer != nil {
|
||||
httpServer.Close()
|
||||
}
|
||||
close(tsCh)
|
||||
select {
|
||||
case <-time.After(time.Second * 10): // give goroutine some time to flush
|
||||
case <-remoteWriteDoneCh:
|
||||
}
|
||||
|
||||
// send stale markers on shutdown
|
||||
staleMeta := make([]nodeMeta, 0, len(nodeMetaByAddr))
|
||||
for _, v := range nodeMetaByAddr {
|
||||
staleMeta = append(staleMeta, v)
|
||||
}
|
||||
staleMarkers := staleMarkersFromNodeMeta(staleMeta, *flagInstance, dstPorts)
|
||||
if len(staleMarkers) > 0 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
rwc.write(ctx, staleMarkers)
|
||||
cancel()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("stunstamp started")
|
||||
|
||||
// Re-using sockets means we get the same 5-tuple across runs. This results
|
||||
// in a higher probability of the packets traversing the same underlay path.
|
||||
// Comparison of stable and unstable 5-tuple results can shed light on
|
||||
// differences between paths where hashing (multipathing/load balancing)
|
||||
// comes into play.
|
||||
stableConns := make(map[netip.Addr]map[int][2]io.ReadWriteCloser)
|
||||
|
||||
derpMapTicker := time.NewTicker(time.Minute * 5)
|
||||
defer derpMapTicker.Stop()
|
||||
probeTicker := time.NewTicker(*flagInterval)
|
||||
defer probeTicker.Stop()
|
||||
cleanupTicker := time.NewTicker(time.Hour)
|
||||
defer cleanupTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-cleanupTicker.C:
|
||||
older := time.Now().Add(-*flagRetention)
|
||||
log.Printf("cleaning up measurements older than %v", older)
|
||||
_, err := db.Exec("DELETE FROM rtt WHERE at_unix < ?", older.Unix())
|
||||
if err != nil {
|
||||
log.Printf("error cleaning up old data: %v", err)
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
case <-probeTicker.C:
|
||||
results, err := probeNodes(nodeMetaByAddr, stableConns, dstPorts)
|
||||
if err != nil {
|
||||
log.Printf("unrecoverable error while probing: %v", err)
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
ts := make([]prompb.TimeSeries, 0, len(results))
|
||||
for _, r := range results {
|
||||
ts = append(ts, resultToPromTimeSeries(r, *flagInstance))
|
||||
}
|
||||
select {
|
||||
case tsCh <- ts:
|
||||
default:
|
||||
select {
|
||||
case <-tsCh:
|
||||
log.Println("prometheus remote-write buffer full, dropped measurements")
|
||||
default:
|
||||
tsCh <- ts
|
||||
}
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
log.Printf("error beginning sqlite tx: %v", err)
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
for _, result := range results {
|
||||
af := 4
|
||||
if result.meta.addr.Is6() {
|
||||
af = 6
|
||||
}
|
||||
_, err = tx.Exec("INSERT INTO rtt(at_unix, region_id, hostname, af, address, timestamp_source, stable_conn, dst_port, rtt_ns) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
result.at.Unix(), result.meta.regionID, result.meta.hostname, af, result.meta.addr.String(), result.timestampSource, result.connStability, result.dstPort, result.rtt)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("error adding result to tx: %v", err)
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
}
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
log.Printf("error committing tx: %v", err)
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
case dm := <-dmCh:
|
||||
staleMeta, err := nodeMetaFromDERPMap(dm, nodeMetaByAddr, *flagIPv6)
|
||||
if err != nil {
|
||||
log.Printf("error parsing DERP map, continuing with stale map: %v", err)
|
||||
continue
|
||||
}
|
||||
staleMarkers := staleMarkersFromNodeMeta(staleMeta, *flagInstance, dstPorts)
|
||||
if len(staleMarkers) < 1 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case tsCh <- staleMarkers:
|
||||
default:
|
||||
select {
|
||||
case <-tsCh:
|
||||
log.Println("prometheus remote-write buffer full, dropped measurements")
|
||||
default:
|
||||
tsCh <- staleMarkers
|
||||
}
|
||||
}
|
||||
case <-derpMapTicker.C:
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
updatedDM, err := getDERPMap(ctx, *flagDERPMap)
|
||||
if err != nil {
|
||||
dmCh <- updatedDM
|
||||
}
|
||||
}()
|
||||
case err := <-httpErrCh:
|
||||
log.Printf("http server error: %v", err)
|
||||
shutdown()
|
||||
return
|
||||
case <-sigCh:
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
26
cmd/stunstamp/stunstamp_db_default.go
Normal file
26
cmd/stunstamp/stunstamp_db_default.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !(windows && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type db struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func newDB(path string) (*db, error) {
|
||||
d, err := sql.Open("sqlite", *flagOut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &db{
|
||||
DB: d,
|
||||
}, nil
|
||||
}
|
||||
17
cmd/stunstamp/stunstamp_db_windows_386.go
Normal file
17
cmd/stunstamp/stunstamp_db_windows_386.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type db struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func newDB(path string) (*db, error) {
|
||||
return nil, errors.New("unsupported platform")
|
||||
}
|
||||
25
cmd/stunstamp/stunstamp_default.go
Normal file
25
cmd/stunstamp/stunstamp_default.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getConnKernelTimestamp() (io.ReadWriteCloser, error) {
|
||||
return nil, errors.New("unimplemented")
|
||||
}
|
||||
|
||||
func measureRTTKernel(conn io.ReadWriteCloser, dst *net.UDPAddr) (rtt time.Duration, err error) {
|
||||
return 0, errors.New("unimplemented")
|
||||
}
|
||||
|
||||
func supportsKernelTS() bool {
|
||||
return false
|
||||
}
|
||||
143
cmd/stunstamp/stunstamp_linux.go
Normal file
143
cmd/stunstamp/stunstamp_linux.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/mdlayher/socket"
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/net/stun"
|
||||
)
|
||||
|
||||
const (
|
||||
flags = unix.SOF_TIMESTAMPING_TX_SOFTWARE | // tx timestamp generation in device driver
|
||||
unix.SOF_TIMESTAMPING_RX_SOFTWARE | // rx timestamp generation in the kernel
|
||||
unix.SOF_TIMESTAMPING_SOFTWARE // report software timestamps
|
||||
)
|
||||
|
||||
func getConnKernelTimestamp() (io.ReadWriteCloser, error) {
|
||||
sconn, err := socket.Socket(unix.AF_INET6, unix.SOCK_DGRAM, unix.IPPROTO_UDP, "udp", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sa := unix.SockaddrInet6{}
|
||||
err = sconn.Bind(&sa)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = sconn.SetsockoptInt(unix.SOL_SOCKET, unix.SO_TIMESTAMPING_NEW, flags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sconn, nil
|
||||
}
|
||||
|
||||
func parseTimestampFromCmsgs(oob []byte) (time.Time, error) {
|
||||
msgs, err := unix.ParseSocketControlMessage(oob)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("error parsing oob as cmsgs: %w", err)
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
if msg.Header.Level == unix.SOL_SOCKET && msg.Header.Type == unix.SO_TIMESTAMPING_NEW && len(msg.Data) >= 16 {
|
||||
sec := int64(binary.NativeEndian.Uint64(msg.Data[:8]))
|
||||
ns := int64(binary.NativeEndian.Uint64(msg.Data[8:16]))
|
||||
return time.Unix(sec, ns), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errors.New("failed to parse timestamp from cmsgs")
|
||||
}
|
||||
|
||||
func measureRTTKernel(conn io.ReadWriteCloser, dst *net.UDPAddr) (rtt time.Duration, err error) {
|
||||
sconn, ok := conn.(*socket.Conn)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("conn of unexpected type: %T", conn)
|
||||
}
|
||||
|
||||
var to unix.Sockaddr
|
||||
to4 := dst.IP.To4()
|
||||
if to4 != nil {
|
||||
to = &unix.SockaddrInet4{
|
||||
Port: dst.Port,
|
||||
}
|
||||
copy(to.(*unix.SockaddrInet4).Addr[:], to4)
|
||||
} else {
|
||||
to = &unix.SockaddrInet6{
|
||||
Port: dst.Port,
|
||||
}
|
||||
copy(to.(*unix.SockaddrInet6).Addr[:], dst.IP)
|
||||
}
|
||||
|
||||
txID := stun.NewTxID()
|
||||
req := stun.Request(txID)
|
||||
|
||||
err = sconn.Sendto(context.Background(), req, 0, to)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sendto error: %v", err) // don't wrap
|
||||
}
|
||||
|
||||
txCtx, txCancel := context.WithTimeout(context.Background(), time.Second*2)
|
||||
defer txCancel()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
oob := make([]byte, 1024)
|
||||
var txAt time.Time
|
||||
|
||||
for {
|
||||
n, oobn, _, _, err := sconn.Recvmsg(txCtx, buf, oob, unix.MSG_ERRQUEUE)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("recvmsg (MSG_ERRQUEUE) error: %v", err) // don't wrap
|
||||
}
|
||||
|
||||
buf = buf[:n]
|
||||
if n < len(req) || !bytes.Equal(req, buf[len(buf)-len(req):]) {
|
||||
// Spin until we find the message we sent. We get the full packet
|
||||
// looped including eth header so match against the tail.
|
||||
continue
|
||||
}
|
||||
txAt, err = parseTimestampFromCmsgs(oob[:oobn])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get tx timestamp: %v", err) // don't wrap
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
rxCtx, rxCancel := context.WithTimeout(context.Background(), time.Second*2)
|
||||
defer rxCancel()
|
||||
|
||||
for {
|
||||
n, oobn, _, _, err := sconn.Recvmsg(rxCtx, buf, oob, 0)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("recvmsg error: %w", err) // wrap for timeout-related error unwrapping
|
||||
}
|
||||
|
||||
gotTxID, _, err := stun.ParseResponse(buf[:n])
|
||||
if err != nil || gotTxID != txID {
|
||||
// Spin until we find the txID we sent. We may end up reading
|
||||
// extremely late arriving responses from previous intervals. As
|
||||
// such, we can't be certain if we're parsing the "current"
|
||||
// response, so spin for parse errors too.
|
||||
continue
|
||||
}
|
||||
|
||||
rxAt, err := parseTimestampFromCmsgs(oob[:oobn])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get rx timestamp: %v", err) // don't wrap
|
||||
}
|
||||
|
||||
return rxAt.Sub(txAt), nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func supportsKernelTS() bool {
|
||||
return true
|
||||
}
|
||||
@@ -17,7 +17,7 @@ var bugReportCmd = &ffcli.Command{
|
||||
Name: "bugreport",
|
||||
Exec: runBugReport,
|
||||
ShortHelp: "Print a shareable identifier to help diagnose issues",
|
||||
ShortUsage: "bugreport [note]",
|
||||
ShortUsage: "tailscale bugreport [note]",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("bugreport")
|
||||
fs.BoolVar(&bugReportArgs.diagnose, "diagnose", false, "run additional in-depth checks")
|
||||
|
||||
@@ -28,7 +28,7 @@ var certCmd = &ffcli.Command{
|
||||
Name: "cert",
|
||||
Exec: runCert,
|
||||
ShortHelp: "Get TLS certs",
|
||||
ShortUsage: "cert [flags] <domain>",
|
||||
ShortUsage: "tailscale cert [flags] <domain>",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("cert")
|
||||
fs.StringVar(&certArgs.certFile, "cert-file", "", "output cert file or \"-\" for stdout; defaults to DOMAIN.crt if --cert-file and --key-file are both unset")
|
||||
|
||||
@@ -14,13 +14,15 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/mattn/go-colorable"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/cmd/tailscale/cli/ffcomplete"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/version/distro"
|
||||
@@ -76,7 +78,9 @@ func CleanUpArgs(args []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
var localClient tailscale.LocalClient
|
||||
var localClient = tailscale.LocalClient{
|
||||
Socket: paths.DefaultTailscaledSocket(),
|
||||
}
|
||||
|
||||
// Run runs the CLI. The args do not include the binary name.
|
||||
func Run(args []string) (err error) {
|
||||
@@ -93,8 +97,68 @@ func Run(args []string) (err error) {
|
||||
})
|
||||
})
|
||||
|
||||
rootCmd := newRootCmd()
|
||||
if err := rootCmd.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
if noexec := (ffcli.NoExecError{}); errors.As(err, &noexec) {
|
||||
// When the user enters an unknown subcommand, ffcli tries to run
|
||||
// the closest valid parent subcommand with everything else as args,
|
||||
// returning NoExecError if it doesn't have an Exec function.
|
||||
cmd := noexec.Command
|
||||
args := cmd.FlagSet.Args()
|
||||
if len(cmd.Subcommands) > 0 {
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("%s: unknown subcommand: %s", fullCmd(rootCmd, cmd), args[0])
|
||||
}
|
||||
subs := make([]string, 0, len(cmd.Subcommands))
|
||||
for _, sub := range cmd.Subcommands {
|
||||
subs = append(subs, sub.Name)
|
||||
}
|
||||
return fmt.Errorf("%s: missing subcommand: %s", fullCmd(rootCmd, cmd), strings.Join(subs, ", "))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if envknob.Bool("TS_DUMP_HELP") {
|
||||
walkCommands(rootCmd, func(w cmdWalk) bool {
|
||||
fmt.Println("===")
|
||||
// UsageFuncs are typically called during Command.Run which ensures
|
||||
// FlagSet is not nil.
|
||||
c := w.Command
|
||||
if c.FlagSet == nil {
|
||||
c.FlagSet = flag.NewFlagSet(c.Name, flag.ContinueOnError)
|
||||
}
|
||||
if c.UsageFunc != nil {
|
||||
fmt.Println(c.UsageFunc(c))
|
||||
} else {
|
||||
fmt.Println(ffcli.DefaultUsageFunc(c))
|
||||
}
|
||||
return true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = rootCmd.Run(context.Background())
|
||||
if tailscale.IsAccessDeniedError(err) && os.Getuid() != 0 && runtime.GOOS != "windows" {
|
||||
return fmt.Errorf("%v\n\nUse 'sudo tailscale %s' or 'tailscale up --operator=$USER' to not require root.", err, strings.Join(args, " "))
|
||||
}
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func newRootCmd() *ffcli.Command {
|
||||
rootfs := newFlagSet("tailscale")
|
||||
rootfs.StringVar(&rootArgs.socket, "socket", paths.DefaultTailscaledSocket(), "path to tailscaled socket")
|
||||
rootfs.Func("socket", "path to tailscaled socket", func(s string) error {
|
||||
localClient.Socket = s
|
||||
localClient.UseSocketOnly = true
|
||||
return nil
|
||||
})
|
||||
rootfs.Lookup("socket").DefValue = localClient.Socket
|
||||
|
||||
rootCmd := &ffcli.Command{
|
||||
Name: "tailscale",
|
||||
@@ -129,59 +193,35 @@ change in the future.
|
||||
certCmd,
|
||||
netlockCmd,
|
||||
licensesCmd,
|
||||
exitNodeCmd,
|
||||
exitNodeCmd(),
|
||||
updateCmd,
|
||||
whoisCmd,
|
||||
},
|
||||
FlagSet: rootfs,
|
||||
Exec: func(context.Context, []string) error { return flag.ErrHelp },
|
||||
UsageFunc: usageFunc,
|
||||
}
|
||||
if envknob.UseWIPCode() {
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands,
|
||||
debugCmd,
|
||||
driveCmd,
|
||||
idTokenCmd,
|
||||
)
|
||||
},
|
||||
FlagSet: rootfs,
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("tailscale: unknown subcommand: %s", args[0])
|
||||
}
|
||||
return flag.ErrHelp
|
||||
},
|
||||
}
|
||||
|
||||
// Don't advertise these commands, but they're still explicitly available.
|
||||
switch {
|
||||
case slices.Contains(args, "debug"):
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, debugCmd)
|
||||
case slices.Contains(args, "share"):
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, shareCmd)
|
||||
}
|
||||
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)
|
||||
}
|
||||
|
||||
for _, c := range rootCmd.Subcommands {
|
||||
if c.UsageFunc == nil {
|
||||
c.UsageFunc = usageFunc
|
||||
}
|
||||
}
|
||||
|
||||
if err := rootCmd.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
localClient.Socket = rootArgs.socket
|
||||
rootfs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "socket" {
|
||||
localClient.UseSocketOnly = true
|
||||
walkCommands(rootCmd, func(w cmdWalk) bool {
|
||||
if w.UsageFunc == nil {
|
||||
w.UsageFunc = usageFunc
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
err = rootCmd.Run(context.Background())
|
||||
if tailscale.IsAccessDeniedError(err) && os.Getuid() != 0 && runtime.GOOS != "windows" {
|
||||
return fmt.Errorf("%v\n\nUse 'sudo tailscale %s' or 'tailscale up --operator=$USER' to not require root.", err, strings.Join(args, " "))
|
||||
}
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
ffcomplete.Inject(rootCmd, func(c *ffcli.Command) { c.LongHelp = hidden + c.LongHelp }, usageFunc)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func fatalf(format string, a ...any) {
|
||||
@@ -196,8 +236,57 @@ func fatalf(format string, a ...any) {
|
||||
// Fatalf, if non-nil, is used instead of log.Fatalf.
|
||||
var Fatalf func(format string, a ...any)
|
||||
|
||||
var rootArgs struct {
|
||||
socket string
|
||||
type cmdWalk struct {
|
||||
*ffcli.Command
|
||||
parents []*ffcli.Command
|
||||
}
|
||||
|
||||
func (w cmdWalk) Path() string {
|
||||
if len(w.parents) == 0 {
|
||||
return w.Name
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, p := range w.parents {
|
||||
sb.WriteString(p.Name)
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
sb.WriteString(w.Name)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// walkCommands calls f for root and all of its nested subcommands until f
|
||||
// returns false or all have been visited.
|
||||
func walkCommands(root *ffcli.Command, f func(w cmdWalk) (more bool)) {
|
||||
var walk func(cmd *ffcli.Command, parents []*ffcli.Command, f func(cmdWalk) bool) bool
|
||||
walk = func(cmd *ffcli.Command, parents []*ffcli.Command, f func(cmdWalk) bool) bool {
|
||||
if !f(cmdWalk{cmd, parents}) {
|
||||
return false
|
||||
}
|
||||
parents = append(parents, cmd)
|
||||
for _, sub := range cmd.Subcommands {
|
||||
if !walk(sub, parents, f) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
walk(root, nil, f)
|
||||
}
|
||||
|
||||
// fullCmd returns the full "tailscale ... cmd" invocation for a subcommand.
|
||||
func fullCmd(root, cmd *ffcli.Command) (full string) {
|
||||
walkCommands(root, func(w cmdWalk) bool {
|
||||
if w.Command == cmd {
|
||||
full = w.Path()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if full == "" {
|
||||
return cmd.Name
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
// usageFuncNoDefaultValues is like usageFunc but doesn't print default values.
|
||||
@@ -209,25 +298,36 @@ func usageFunc(c *ffcli.Command) string {
|
||||
return usageFuncOpt(c, true)
|
||||
}
|
||||
|
||||
// hidden is the prefix that hides subcommands and flags from --help output when
|
||||
// found at the start of the subcommand's LongHelp or flag's Usage.
|
||||
const hidden = "HIDDEN: "
|
||||
|
||||
func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
|
||||
var b strings.Builder
|
||||
|
||||
if c.ShortHelp != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", c.ShortHelp)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "USAGE\n")
|
||||
if c.ShortUsage != "" {
|
||||
fmt.Fprintf(&b, " %s\n", c.ShortUsage)
|
||||
fmt.Fprintf(&b, " %s\n", strings.ReplaceAll(c.ShortUsage, "\n", "\n "))
|
||||
} else {
|
||||
fmt.Fprintf(&b, " %s\n", c.Name)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n")
|
||||
|
||||
if c.LongHelp != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", c.LongHelp)
|
||||
if help := strings.TrimPrefix(c.LongHelp, hidden); help != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", help)
|
||||
}
|
||||
|
||||
if len(c.Subcommands) > 0 {
|
||||
fmt.Fprintf(&b, "SUBCOMMANDS\n")
|
||||
tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0)
|
||||
for _, subcommand := range c.Subcommands {
|
||||
if strings.HasPrefix(subcommand.LongHelp, hidden) {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(tw, " %s\t%s\n", subcommand.Name, subcommand.ShortHelp)
|
||||
}
|
||||
tw.Flush()
|
||||
@@ -240,7 +340,7 @@ func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
|
||||
c.FlagSet.VisitAll(func(f *flag.Flag) {
|
||||
var s string
|
||||
name, usage := flag.UnquoteUsage(f)
|
||||
if strings.HasPrefix(usage, "HIDDEN: ") {
|
||||
if strings.HasPrefix(usage, hidden) {
|
||||
return
|
||||
}
|
||||
if isBoolFlag(f) {
|
||||
@@ -287,3 +387,17 @@ func countFlags(fs *flag.FlagSet) (n int) {
|
||||
fs.VisitAll(func(*flag.Flag) { n++ })
|
||||
return n
|
||||
}
|
||||
|
||||
// colorableOutput returns a colorable writer if stdout is a terminal (not, say,
|
||||
// redirected to a file or pipe), the Stdout writer is os.Stdout (we're not
|
||||
// embedding the CLI in wasm or a mobile app), and NO_COLOR is not set (see
|
||||
// https://no-color.org/). If any of those is not the case, ok is false
|
||||
// and w is Stdout.
|
||||
func colorableOutput() (w io.Writer, ok bool) {
|
||||
if Stdout != os.Stdout ||
|
||||
os.Getenv("NO_COLOR") != "" ||
|
||||
!isatty.IsTerminal(os.Stdout.Fd()) {
|
||||
return Stdout, false
|
||||
}
|
||||
return colorable.NewColorableStdout(), true
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
qt "github.com/frankban/quicktest"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/health/healthmsg"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
@@ -23,11 +24,116 @@ import (
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/types/persist"
|
||||
"tailscale.com/types/preftype"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
func TestPanicIfAnyEnvCheckedInInit(t *testing.T) {
|
||||
envknob.PanicIfAnyEnvCheckedInInit()
|
||||
}
|
||||
|
||||
func TestShortUsage(t *testing.T) {
|
||||
t.Setenv("TAILSCALE_USE_WIP_CODE", "1")
|
||||
if !envknob.UseWIPCode() {
|
||||
t.Fatal("expected envknob.UseWIPCode() to be true")
|
||||
}
|
||||
|
||||
walkCommands(newRootCmd(), func(w cmdWalk) bool {
|
||||
c, parents := w.Command, w.parents
|
||||
|
||||
// Words that we expect to be in the usage.
|
||||
words := make([]string, len(parents)+1)
|
||||
for i, parent := range parents {
|
||||
words[i] = parent.Name
|
||||
}
|
||||
words[len(parents)] = c.Name
|
||||
|
||||
// Check the ShortHelp starts with a capital letter.
|
||||
if prefix, help := trimPrefixes(c.ShortHelp, "HIDDEN: ", "[ALPHA] ", "[BETA] "); help != "" {
|
||||
if 'a' <= help[0] && help[0] <= 'z' {
|
||||
if len(help) > 20 {
|
||||
help = help[:20] + "…"
|
||||
}
|
||||
caphelp := string(help[0]-'a'+'A') + help[1:]
|
||||
t.Errorf("command: %s: ShortHelp %q should start with a capital letter %q", strings.Join(words, " "), prefix+help, prefix+caphelp)
|
||||
}
|
||||
}
|
||||
|
||||
// Check all words appear in the usage.
|
||||
usage := c.ShortUsage
|
||||
for _, word := range words {
|
||||
var ok bool
|
||||
usage, ok = cutWord(usage, word)
|
||||
if !ok {
|
||||
full := strings.Join(words, " ")
|
||||
t.Errorf("command: %s: usage %q should contain the full path %q", full, c.ShortUsage, full)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func trimPrefixes(full string, prefixes ...string) (trimmed, remaining string) {
|
||||
s := full
|
||||
start:
|
||||
for _, p := range prefixes {
|
||||
var ok bool
|
||||
s, ok = strings.CutPrefix(s, p)
|
||||
if ok {
|
||||
goto start
|
||||
}
|
||||
}
|
||||
return full[:len(full)-len(s)], s
|
||||
}
|
||||
|
||||
// cutWord("tailscale debug scale 123", "scale") returns (" 123", true).
|
||||
func cutWord(s, w string) (after string, ok bool) {
|
||||
var p string
|
||||
for {
|
||||
p, s, ok = strings.Cut(s, w)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if p != "" && isWordChar(p[len(p)-1]) {
|
||||
continue
|
||||
}
|
||||
if s != "" && isWordChar(s[0]) {
|
||||
continue
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
|
||||
func isWordChar(r byte) bool {
|
||||
return r == '_' ||
|
||||
('0' <= r && r <= '9') ||
|
||||
('A' <= r && r <= 'Z') ||
|
||||
('a' <= r && r <= 'z')
|
||||
}
|
||||
|
||||
func TestCutWord(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
word string
|
||||
out string
|
||||
ok bool
|
||||
}{
|
||||
{"tailscale debug", "debug", "", true},
|
||||
{"tailscale debug", "bug", "", false},
|
||||
{"tailscale debug", "tail", "", false},
|
||||
{"tailscale debug scaley scale 123", "scale", " 123", true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
out, ok := cutWord(test.in, test.word)
|
||||
if out != test.out || ok != test.ok {
|
||||
t.Errorf("cutWord(%q, %q) = (%q, %t), wanted (%q, %t)", test.in, test.word, out, ok, test.out, test.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// geese is a collection of gooses. It need not be complete.
|
||||
// But it should include anything handled specially (e.g. linux, windows)
|
||||
// and at least one thing that's not (darwin, freebsd).
|
||||
@@ -71,9 +177,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "bare_up_means_up",
|
||||
flags: []string{},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: false,
|
||||
Hostname: "foo",
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: false,
|
||||
Hostname: "foo",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
@@ -81,12 +188,12 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "losing_hostname",
|
||||
flags: []string{"--accept-dns"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: false,
|
||||
Hostname: "foo",
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AllowSingleHosts: true,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: false,
|
||||
Hostname: "foo",
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: accidentalUpPrefix + " --accept-dns --hostname=foo",
|
||||
},
|
||||
@@ -94,11 +201,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "hostname_changing_explicitly",
|
||||
flags: []string{"--hostname=bar"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AllowSingleHosts: true,
|
||||
Hostname: "foo",
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
Hostname: "foo",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
@@ -106,11 +213,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "hostname_changing_empty_explicitly",
|
||||
flags: []string{"--hostname="},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AllowSingleHosts: true,
|
||||
Hostname: "foo",
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
Hostname: "foo",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
@@ -126,11 +233,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "implicit_operator_change",
|
||||
flags: []string{"--hostname=foo"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
OperatorUser: "alice",
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
OperatorUser: "alice",
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
curUser: "eve",
|
||||
want: accidentalUpPrefix + " --hostname=foo --operator=alice",
|
||||
@@ -139,11 +246,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "implicit_operator_matches_shell_user",
|
||||
flags: []string{"--hostname=foo"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
OperatorUser: "alice",
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
OperatorUser: "alice",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
curUser: "alice",
|
||||
want: "",
|
||||
@@ -152,15 +259,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "error_advertised_routes_exit_node_removed",
|
||||
flags: []string{"--advertise-routes=10.0.42.0/24"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.42.0/24"),
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: accidentalUpPrefix + " --advertise-routes=10.0.42.0/24 --advertise-exit-node",
|
||||
},
|
||||
@@ -168,15 +275,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "advertised_routes_exit_node_removed_explicit",
|
||||
flags: []string{"--advertise-routes=10.0.42.0/24", "--advertise-exit-node=false"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.42.0/24"),
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
@@ -184,15 +291,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "advertised_routes_includes_the_0_routes", // but no --advertise-exit-node
|
||||
flags: []string{"--advertise-routes=11.1.43.0/24,0.0.0.0/0,::/0"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.42.0/24"),
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
@@ -200,10 +307,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "advertise_exit_node", // Issue 1859
|
||||
flags: []string{"--advertise-exit-node"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
@@ -211,14 +318,14 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "advertise_exit_node_over_existing_routes",
|
||||
flags: []string{"--advertise-exit-node"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("1.2.0.0/16"),
|
||||
},
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16",
|
||||
},
|
||||
@@ -226,15 +333,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "advertise_exit_node_over_existing_routes_and_exit_node",
|
||||
flags: []string{"--advertise-exit-node"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
netip.MustParsePrefix("1.2.0.0/16"),
|
||||
},
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16",
|
||||
},
|
||||
@@ -242,12 +349,12 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "exit_node_clearing", // Issue 1777
|
||||
flags: []string{"--exit-node="},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
|
||||
ExitNodeID: "fooID",
|
||||
ExitNodeID: "fooID",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
@@ -255,59 +362,59 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "remove_all_implicit",
|
||||
flags: []string{"--force-reauth"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
WantRunning: true,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
RouteAll: true,
|
||||
AllowSingleHosts: false,
|
||||
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
|
||||
CorpDNS: false,
|
||||
ShieldsUp: true,
|
||||
AdvertiseTags: []string{"tag:foo", "tag:bar"},
|
||||
Hostname: "myhostname",
|
||||
ForceDaemon: true,
|
||||
WantRunning: true,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
RouteAll: true,
|
||||
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
|
||||
CorpDNS: false,
|
||||
ShieldsUp: true,
|
||||
AdvertiseTags: []string{"tag:foo", "tag:bar"},
|
||||
Hostname: "myhostname",
|
||||
ForceDaemon: true,
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/16"),
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
NetfilterMode: preftype.NetfilterNoDivert,
|
||||
OperatorUser: "alice",
|
||||
NetfilterMode: preftype.NetfilterNoDivert,
|
||||
OperatorUser: "alice",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
curUser: "eve",
|
||||
want: accidentalUpPrefix + " --force-reauth --accept-dns=false --accept-routes --advertise-exit-node --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --host-routes=false --hostname=myhostname --netfilter-mode=nodivert --operator=alice --shields-up",
|
||||
want: accidentalUpPrefix + " --force-reauth --accept-dns=false --accept-routes --advertise-exit-node --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --hostname=myhostname --netfilter-mode=nodivert --operator=alice --shields-up",
|
||||
},
|
||||
{
|
||||
name: "remove_all_implicit_except_hostname",
|
||||
flags: []string{"--hostname=newhostname"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
WantRunning: true,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
RouteAll: true,
|
||||
AllowSingleHosts: false,
|
||||
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
|
||||
CorpDNS: false,
|
||||
ShieldsUp: true,
|
||||
AdvertiseTags: []string{"tag:foo", "tag:bar"},
|
||||
Hostname: "myhostname",
|
||||
ForceDaemon: true,
|
||||
WantRunning: true,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
RouteAll: true,
|
||||
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
|
||||
CorpDNS: false,
|
||||
ShieldsUp: true,
|
||||
AdvertiseTags: []string{"tag:foo", "tag:bar"},
|
||||
Hostname: "myhostname",
|
||||
ForceDaemon: true,
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/16"),
|
||||
},
|
||||
NetfilterMode: preftype.NetfilterNoDivert,
|
||||
OperatorUser: "alice",
|
||||
NetfilterMode: preftype.NetfilterNoDivert,
|
||||
OperatorUser: "alice",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
curUser: "eve",
|
||||
want: accidentalUpPrefix + " --hostname=newhostname --accept-dns=false --accept-routes --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --host-routes=false --netfilter-mode=nodivert --operator=alice --shields-up",
|
||||
want: accidentalUpPrefix + " --hostname=newhostname --accept-dns=false --accept-routes --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --netfilter-mode=nodivert --operator=alice --shields-up",
|
||||
},
|
||||
{
|
||||
name: "loggedout_is_implicit",
|
||||
flags: []string{"--hostname=foo"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
LoggedOut: true,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
LoggedOut: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: "", // not an error. LoggedOut is implicit.
|
||||
},
|
||||
@@ -317,10 +424,9 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "make_windows_exit_node",
|
||||
flags: []string{"--advertise-exit-node"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
RouteAll: true,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
RouteAll: true,
|
||||
|
||||
// And assume this no-op accidental pre-1.8 value:
|
||||
NoSNAT: true,
|
||||
@@ -332,8 +438,7 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "ignore_netfilter_change_non_linux",
|
||||
flags: []string{"--accept-dns"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
|
||||
NetfilterMode: preftype.NetfilterNoDivert, // we never had this bug, but pretend it got set non-zero on Windows somehow
|
||||
},
|
||||
@@ -344,15 +449,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "operator_losing_routes_step1", // https://twitter.com/EXPbits/status/1390418145047887877
|
||||
flags: []string{"--operator=expbits"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
netip.MustParsePrefix("1.2.0.0/16"),
|
||||
},
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: accidentalUpPrefix + " --operator=expbits --advertise-exit-node --advertise-routes=1.2.0.0/16",
|
||||
},
|
||||
@@ -360,15 +465,15 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "operator_losing_routes_step2", // https://twitter.com/EXPbits/status/1390418145047887877
|
||||
flags: []string{"--operator=expbits", "--advertise-routes=1.2.0.0/16"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
netip.MustParsePrefix("1.2.0.0/16"),
|
||||
},
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: accidentalUpPrefix + " --advertise-routes=1.2.0.0/16 --operator=expbits --advertise-exit-node",
|
||||
},
|
||||
@@ -376,13 +481,13 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "errors_preserve_explicit_flags",
|
||||
flags: []string{"--reset", "--force-reauth=false", "--authkey=secretrand"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: false,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AllowSingleHosts: true,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: false,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
|
||||
Hostname: "foo",
|
||||
Hostname: "foo",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: accidentalUpPrefix + " --auth-key=secretrand --force-reauth=false --reset --hostname=foo",
|
||||
},
|
||||
@@ -390,12 +495,12 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "error_exit_node_omit_with_ip_pref",
|
||||
flags: []string{"--hostname=foo"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
|
||||
ExitNodeIP: netip.MustParseAddr("100.64.5.4"),
|
||||
ExitNodeIP: netip.MustParseAddr("100.64.5.4"),
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.4",
|
||||
},
|
||||
@@ -404,12 +509,12 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
flags: []string{"--hostname=foo"},
|
||||
curExitNodeIP: netip.MustParseAddr("100.64.5.7"),
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
|
||||
ExitNodeID: "some_stable_id",
|
||||
ExitNodeID: "some_stable_id",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.7",
|
||||
},
|
||||
@@ -418,13 +523,13 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
flags: []string{"--hostname=foo"},
|
||||
curExitNodeIP: netip.MustParseAddr("100.2.3.4"),
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
|
||||
ExitNodeAllowLANAccess: true,
|
||||
ExitNodeID: "some_stable_id",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: accidentalUpPrefix + " --hostname=foo --exit-node-allow-lan-access --exit-node=100.2.3.4",
|
||||
},
|
||||
@@ -432,10 +537,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "ignore_login_server_synonym",
|
||||
flags: []string{"--login-server=https://controlplane.tailscale.com"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: "", // not an error
|
||||
},
|
||||
@@ -443,10 +548,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "ignore_login_server_synonym_on_other_change",
|
||||
flags: []string{"--netfilter-mode=off"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: false,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: false,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
want: accidentalUpPrefix + " --netfilter-mode=off --accept-dns=false",
|
||||
},
|
||||
@@ -456,11 +561,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "synology_permit_omit_accept_routes",
|
||||
flags: []string{"--hostname=foo"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
AllowSingleHosts: true,
|
||||
RouteAll: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
RouteAll: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
goos: "linux",
|
||||
distro: distro.Synology,
|
||||
@@ -472,11 +577,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "not_synology_dont_permit_omit_accept_routes",
|
||||
flags: []string{"--hostname=foo"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
AllowSingleHosts: true,
|
||||
RouteAll: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
RouteAll: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
goos: "linux",
|
||||
distro: "", // not Synology
|
||||
@@ -486,11 +591,11 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
name: "profile_name_ignored_in_up",
|
||||
flags: []string{"--hostname=foo"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
AllowSingleHosts: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ProfileName: "foo",
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ProfileName: "foo",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
goos: "linux",
|
||||
want: "",
|
||||
@@ -550,12 +655,12 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
||||
goos: "linux",
|
||||
args: upArgsFromOSArgs("linux"),
|
||||
want: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: true,
|
||||
NoSNAT: false,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
CorpDNS: true,
|
||||
AllowSingleHosts: true,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: true,
|
||||
NoSNAT: false,
|
||||
NoStatefulFiltering: "true",
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
CorpDNS: true,
|
||||
AutoUpdate: ipn.AutoUpdatePrefs{
|
||||
Check: true,
|
||||
},
|
||||
@@ -566,12 +671,13 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
||||
goos: "windows",
|
||||
args: upArgsFromOSArgs("windows"),
|
||||
want: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: true,
|
||||
CorpDNS: true,
|
||||
AllowSingleHosts: true,
|
||||
RouteAll: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: true,
|
||||
CorpDNS: true,
|
||||
RouteAll: true,
|
||||
NoSNAT: false,
|
||||
NoStatefulFiltering: "true",
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AutoUpdate: ipn.AutoUpdatePrefs{
|
||||
Check: true,
|
||||
},
|
||||
@@ -581,15 +687,15 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
||||
name: "advertise_default_route",
|
||||
args: upArgsFromOSArgs("linux", "--advertise-exit-node"),
|
||||
want: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: true,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
WantRunning: true,
|
||||
CorpDNS: true,
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: "true",
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AutoUpdate: ipn.AutoUpdatePrefs{
|
||||
Check: true,
|
||||
},
|
||||
@@ -676,9 +782,10 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
||||
},
|
||||
wantWarn: "netfilter=nodivert; add iptables calls to ts-* chains manually.",
|
||||
want: &ipn.Prefs{
|
||||
WantRunning: true,
|
||||
NetfilterMode: preftype.NetfilterNoDivert,
|
||||
NoSNAT: true,
|
||||
WantRunning: true,
|
||||
NetfilterMode: preftype.NetfilterNoDivert,
|
||||
NoSNAT: true,
|
||||
NoStatefulFiltering: "true",
|
||||
AutoUpdate: ipn.AutoUpdatePrefs{
|
||||
Check: true,
|
||||
},
|
||||
@@ -692,9 +799,10 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
||||
},
|
||||
wantWarn: "netfilter=off; configure iptables yourself.",
|
||||
want: &ipn.Prefs{
|
||||
WantRunning: true,
|
||||
NetfilterMode: preftype.NetfilterOff,
|
||||
NoSNAT: true,
|
||||
WantRunning: true,
|
||||
NetfilterMode: preftype.NetfilterOff,
|
||||
NoSNAT: true,
|
||||
NoStatefulFiltering: "true",
|
||||
AutoUpdate: ipn.AutoUpdatePrefs{
|
||||
Check: true,
|
||||
},
|
||||
@@ -708,8 +816,9 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
||||
netfilterMode: "off",
|
||||
},
|
||||
want: &ipn.Prefs{
|
||||
WantRunning: true,
|
||||
NoSNAT: true,
|
||||
WantRunning: true,
|
||||
NoSNAT: true,
|
||||
NoStatefulFiltering: "true",
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("fd7a:115c:a1e0:b1a::bb:10.0.0.0/112"),
|
||||
},
|
||||
@@ -726,8 +835,9 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
||||
netfilterMode: "off",
|
||||
},
|
||||
want: &ipn.Prefs{
|
||||
WantRunning: true,
|
||||
NoSNAT: true,
|
||||
WantRunning: true,
|
||||
NoSNAT: true,
|
||||
NoStatefulFiltering: "true",
|
||||
AdvertiseRoutes: []netip.Prefix{
|
||||
netip.MustParsePrefix("fd7a:115c:a1e0:b1a::aabb:10.0.0.0/112"),
|
||||
},
|
||||
@@ -803,12 +913,15 @@ func TestPrefFlagMapping(t *testing.T) {
|
||||
}
|
||||
|
||||
prefType := reflect.TypeFor[ipn.Prefs]()
|
||||
for i := 0; i < prefType.NumField(); i++ {
|
||||
for i := range prefType.NumField() {
|
||||
prefName := prefType.Field(i).Name
|
||||
if prefHasFlag[prefName] {
|
||||
continue
|
||||
}
|
||||
switch prefName {
|
||||
case "AllowSingleHosts":
|
||||
// Fake pref for downgrade compat. See #12058.
|
||||
continue
|
||||
case "WantRunning", "Persist", "LoggedOut":
|
||||
// All explicitly handled (ignored) by checkForAccidentalSettingReverts.
|
||||
continue
|
||||
@@ -829,6 +942,14 @@ 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 "DriveShares":
|
||||
// Handled by the tailscale share subcommand, we don't want a CLI
|
||||
// flag for this.
|
||||
continue
|
||||
case "InternalExitNodePrior":
|
||||
// Used internally by LocalBackend as part of exit node usage toggling.
|
||||
// No CLI flag for this.
|
||||
continue
|
||||
}
|
||||
t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName)
|
||||
}
|
||||
@@ -908,7 +1029,6 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
AdvertiseRoutesSet: true,
|
||||
AdvertiseTagsSet: true,
|
||||
AllowSingleHostsSet: true,
|
||||
AppConnectorSet: true,
|
||||
ControlURLSet: true,
|
||||
CorpDNSSet: true,
|
||||
@@ -918,6 +1038,7 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
HostnameSet: true,
|
||||
NetfilterModeSet: true,
|
||||
NoSNATSet: true,
|
||||
NoStatefulFilteringSet: true,
|
||||
OperatorUserSet: true,
|
||||
RouteAllSet: true,
|
||||
RunSSHSet: true,
|
||||
@@ -940,11 +1061,11 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
name: "change_login_server",
|
||||
flags: []string{"--login-server=https://localhost:1000"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
wantSimpleUp: true,
|
||||
@@ -955,11 +1076,11 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
name: "change_tags",
|
||||
flags: []string{"--advertise-tags=tag:foo"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
},
|
||||
@@ -968,11 +1089,11 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
name: "explicit_empty_operator",
|
||||
flags: []string{"--operator="},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
AllowSingleHosts: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
OperatorUser: "somebody",
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
OperatorUser: "somebody",
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
env: upCheckEnv{user: "somebody", backendState: "Running"},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
@@ -989,11 +1110,11 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
name: "enable_ssh",
|
||||
flags: []string{"--ssh"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
@@ -1010,12 +1131,12 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
name: "disable_ssh",
|
||||
flags: []string{"--ssh=false"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
RunSSH: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
CorpDNS: true,
|
||||
RunSSH: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
@@ -1035,12 +1156,12 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{"--ssh=false"},
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
RunSSH: true,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
RunSSH: true,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
@@ -1059,11 +1180,11 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{"--ssh=true"},
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
@@ -1082,11 +1203,11 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{"--ssh=true", "--accept-risk=lose-ssh"},
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
@@ -1104,12 +1225,12 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{"--ssh=false", "--accept-risk=lose-ssh"},
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
RunSSH: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{UserProfile: tailcfg.UserProfile{LoginName: "crawshaw.github"}},
|
||||
CorpDNS: true,
|
||||
RunSSH: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
@@ -1127,10 +1248,10 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{"--force-reauth"},
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
wantErrSubtr: "aborted, no changes made",
|
||||
@@ -1140,10 +1261,10 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
flags: []string{"--force-reauth", "--accept-risk=lose-ssh"},
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
wantJustEditMP: nil,
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
@@ -1152,10 +1273,10 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
name: "advertise_connector",
|
||||
flags: []string{"--advertise-connector"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
AppConnectorSet: true,
|
||||
@@ -1172,13 +1293,13 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
name: "no_advertise_connector",
|
||||
flags: []string{"--advertise-connector=false"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
AppConnector: ipn.AppConnectorPrefs{
|
||||
Advertise: true,
|
||||
},
|
||||
NoStatefulFiltering: opt.NewBool(true),
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
AppConnectorSet: true,
|
||||
|
||||
@@ -27,7 +27,7 @@ func init() {
|
||||
var configureKubeconfigCmd = &ffcli.Command{
|
||||
Name: "kubeconfig",
|
||||
ShortHelp: "[ALPHA] Connect to a Kubernetes cluster using a Tailscale Auth Proxy",
|
||||
ShortUsage: "kubeconfig <hostname-or-fqdn>",
|
||||
ShortUsage: "tailscale configure kubeconfig <hostname-or-fqdn>",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
Run this command to configure kubectl to connect to a Kubernetes cluster over Tailscale.
|
||||
|
||||
@@ -43,7 +43,20 @@ See: https://tailscale.com/s/k8s-auth-proxy
|
||||
}
|
||||
|
||||
// kubeconfigPath returns the path to the kubeconfig file for the current user.
|
||||
func kubeconfigPath() string {
|
||||
func kubeconfigPath() (string, error) {
|
||||
if kubeconfig := os.Getenv("KUBECONFIG"); kubeconfig != "" {
|
||||
if version.IsSandboxedMacOS() {
|
||||
return "", errors.New("$KUBECONFIG is incompatible with the App Store version")
|
||||
}
|
||||
var out string
|
||||
for _, out = range filepath.SplitList(kubeconfig) {
|
||||
if info, err := os.Stat(out); !os.IsNotExist(err) && !info.IsDir() {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var dir string
|
||||
if version.IsSandboxedMacOS() {
|
||||
// The HOME environment variable in macOS sandboxed apps is set to
|
||||
@@ -55,7 +68,7 @@ func kubeconfigPath() string {
|
||||
} else {
|
||||
dir = homedir.HomeDir()
|
||||
}
|
||||
return filepath.Join(dir, ".kube", "config")
|
||||
return filepath.Join(dir, ".kube", "config"), nil
|
||||
}
|
||||
|
||||
func runConfigureKubeconfig(ctx context.Context, args []string) error {
|
||||
@@ -76,7 +89,11 @@ func runConfigureKubeconfig(ctx context.Context, args []string) error {
|
||||
return fmt.Errorf("no peer found with hostname %q", hostOrFQDN)
|
||||
}
|
||||
targetFQDN = strings.TrimSuffix(targetFQDN, ".")
|
||||
if err := setKubeconfigForPeer(targetFQDN, kubeconfigPath()); err != nil {
|
||||
var kubeconfig string
|
||||
if kubeconfig, err = kubeconfigPath(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = setKubeconfigForPeer(targetFQDN, kubeconfig); err != nil {
|
||||
return err
|
||||
}
|
||||
printf("kubeconfig configured for %q\n", hostOrFQDN)
|
||||
@@ -119,7 +136,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 +147,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 +161,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`,
|
||||
|
||||
220
cmd/tailscale/cli/configure-synology-cert.go
Normal file
220
cmd/tailscale/cli/configure-synology-cert.go
Normal file
@@ -0,0 +1,220 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
var synologyConfigureCertCmd = &ffcli.Command{
|
||||
Name: "synology-cert",
|
||||
Exec: runConfigureSynologyCert,
|
||||
ShortHelp: "Configure Synology with a TLS certificate for your tailnet",
|
||||
ShortUsage: "synology-cert [--domain <domain>]",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
This command is intended to run periodically as root on a Synology device to
|
||||
create or refresh the TLS certificate for the tailnet domain.
|
||||
|
||||
See: https://tailscale.com/kb/1153/enabling-https
|
||||
`),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("synology-cert")
|
||||
fs.StringVar(&synologyConfigureCertArgs.domain, "domain", "", "Tailnet domain to create or refresh certificates for. Ignored if only one domain exists.")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
|
||||
var synologyConfigureCertArgs struct {
|
||||
domain string
|
||||
}
|
||||
|
||||
func runConfigureSynologyCert(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return errors.New("unknown arguments")
|
||||
}
|
||||
if runtime.GOOS != "linux" || distro.Get() != distro.Synology {
|
||||
return errors.New("only implemented on Synology")
|
||||
}
|
||||
if uid := os.Getuid(); uid != 0 {
|
||||
return fmt.Errorf("must be run as root, not %q (%v)", os.Getenv("USER"), uid)
|
||||
}
|
||||
hi := hostinfo.New()
|
||||
isDSM6 := strings.HasPrefix(hi.DistroVersion, "6.")
|
||||
isDSM7 := strings.HasPrefix(hi.DistroVersion, "7.")
|
||||
if !isDSM6 && !isDSM7 {
|
||||
return fmt.Errorf("unsupported DSM version %q", hi.DistroVersion)
|
||||
}
|
||||
|
||||
domain := synologyConfigureCertArgs.domain
|
||||
if st, err := localClient.Status(ctx); err == nil {
|
||||
if st.BackendState != ipn.Running.String() {
|
||||
return fmt.Errorf("Tailscale is not running.")
|
||||
} else if len(st.CertDomains) == 0 {
|
||||
return fmt.Errorf("TLS certificate support is not enabled/configured for your tailnet.")
|
||||
} else if len(st.CertDomains) == 1 {
|
||||
if domain != "" && domain != st.CertDomains[0] {
|
||||
log.Printf("Ignoring supplied domain %q, TLS certificate will be created for %q.\n", domain, st.CertDomains[0])
|
||||
}
|
||||
domain = st.CertDomains[0]
|
||||
} else {
|
||||
var found bool
|
||||
for _, d := range st.CertDomains {
|
||||
if d == domain {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("Domain %q was not one of the valid domain options: %q.", domain, st.CertDomains)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for an existing certificate, and replace it if it already exists
|
||||
var id string
|
||||
certs, err := listCerts(ctx, synowebapiCommand{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range certs {
|
||||
if c.Subject.CommonName == domain {
|
||||
id = c.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
certPEM, keyPEM, err := localClient.CertPair(ctx, domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Certs have to be written to file for the upload command to work.
|
||||
tmpDir, err := os.MkdirTemp("", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
keyFile := path.Join(tmpDir, "key.pem")
|
||||
os.WriteFile(keyFile, keyPEM, 0600)
|
||||
certFile := path.Join(tmpDir, "cert.pem")
|
||||
os.WriteFile(certFile, certPEM, 0600)
|
||||
|
||||
if err := uploadCert(ctx, synowebapiCommand{}, certFile, keyFile, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type subject struct {
|
||||
CommonName string `json:"common_name"`
|
||||
}
|
||||
|
||||
type certificateInfo struct {
|
||||
ID string `json:"id"`
|
||||
Desc string `json:"desc"`
|
||||
Subject subject `json:"subject"`
|
||||
}
|
||||
|
||||
// listCerts fetches a list of the certificates that DSM knows about
|
||||
func listCerts(ctx context.Context, c synoAPICaller) ([]certificateInfo, error) {
|
||||
rawData, err := c.Call(ctx, "SYNO.Core.Certificate.CRT", "list", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Certificates []certificateInfo `json:"certificates"`
|
||||
}
|
||||
if err := json.Unmarshal(rawData, &payload); err != nil {
|
||||
return nil, fmt.Errorf("decoding certificate list response payload: %w", err)
|
||||
}
|
||||
|
||||
return payload.Certificates, nil
|
||||
}
|
||||
|
||||
// uploadCert creates or replaces a certificate. If id is given, it will attempt to replace the certificate with that ID.
|
||||
func uploadCert(ctx context.Context, c synoAPICaller, certFile, keyFile string, id string) error {
|
||||
params := map[string]string{
|
||||
"key_tmp": keyFile,
|
||||
"cert_tmp": certFile,
|
||||
"desc": "Tailnet Certificate",
|
||||
}
|
||||
if id != "" {
|
||||
params["id"] = id
|
||||
}
|
||||
|
||||
rawData, err := c.Call(ctx, "SYNO.Core.Certificate", "import", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
NewID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(rawData, &payload); err != nil {
|
||||
return fmt.Errorf("decoding certificate upload response payload: %w", err)
|
||||
}
|
||||
log.Printf("Tailnet Certificate uploaded with ID %q.", payload.NewID)
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
type synoAPICaller interface {
|
||||
Call(context.Context, string, string, map[string]string) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Error *apiError `json:"error,omitempty"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Code int64 `json:"code"`
|
||||
Errors string `json:"errors"`
|
||||
}
|
||||
|
||||
// synowebapiCommand implements synoAPICaller using the /usr/syno/bin/synowebapi binary. Must be run as root.
|
||||
type synowebapiCommand struct{}
|
||||
|
||||
func (s synowebapiCommand) Call(ctx context.Context, api, method string, params map[string]string) (json.RawMessage, error) {
|
||||
args := []string{"--exec", fmt.Sprintf("api=%s", api), fmt.Sprintf("method=%s", method)}
|
||||
|
||||
for k, v := range params {
|
||||
args = append(args, fmt.Sprintf("%s=%q", k, v))
|
||||
}
|
||||
|
||||
out, err := exec.CommandContext(ctx, "/usr/syno/bin/synowebapi", args...).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("calling %q method of %q API: %v, %s", method, api, err, out)
|
||||
}
|
||||
|
||||
var payload apiResponse
|
||||
if err := json.Unmarshal(out, &payload); err != nil {
|
||||
return nil, fmt.Errorf("decoding response json from %q method of %q API: %w", method, api, err)
|
||||
}
|
||||
|
||||
if payload.Error != nil {
|
||||
return nil, fmt.Errorf("error response from %q method of %q API: %v", method, api, payload.Error)
|
||||
}
|
||||
|
||||
return payload.Data, nil
|
||||
}
|
||||
140
cmd/tailscale/cli/configure-synology-cert_test.go
Normal file
140
cmd/tailscale/cli/configure-synology-cert_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeAPICaller struct {
|
||||
Data json.RawMessage
|
||||
Error error
|
||||
}
|
||||
|
||||
func (c fakeAPICaller) Call(_ context.Context, _, _ string, _ map[string]string) (json.RawMessage, error) {
|
||||
return c.Data, c.Error
|
||||
}
|
||||
|
||||
func Test_listCerts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
caller synoAPICaller
|
||||
want []certificateInfo
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "normal response",
|
||||
caller: fakeAPICaller{
|
||||
Data: json.RawMessage(`{
|
||||
"certificates" : [
|
||||
{
|
||||
"desc" : "Tailnet Certificate",
|
||||
"id" : "cG2XBt",
|
||||
"is_broken" : false,
|
||||
"is_default" : false,
|
||||
"issuer" : {
|
||||
"common_name" : "R3",
|
||||
"country" : "US",
|
||||
"organization" : "Let's Encrypt"
|
||||
},
|
||||
"key_types" : "ECC",
|
||||
"renewable" : false,
|
||||
"services" : [
|
||||
{
|
||||
"display_name" : "DSM Desktop Service",
|
||||
"display_name_i18n" : "common:web_desktop",
|
||||
"isPkg" : false,
|
||||
"multiple_cert" : true,
|
||||
"owner" : "root",
|
||||
"service" : "default",
|
||||
"subscriber" : "system",
|
||||
"user_setable" : true
|
||||
}
|
||||
],
|
||||
"signature_algorithm" : "sha256WithRSAEncryption",
|
||||
"subject" : {
|
||||
"common_name" : "foo.tailscale.ts.net",
|
||||
"sub_alt_name" : [ "foo.tailscale.ts.net" ]
|
||||
},
|
||||
"user_deletable" : true,
|
||||
"valid_from" : "Sep 26 11:39:43 2023 GMT",
|
||||
"valid_till" : "Dec 25 11:39:42 2023 GMT"
|
||||
},
|
||||
{
|
||||
"desc" : "",
|
||||
"id" : "sgmnpb",
|
||||
"is_broken" : false,
|
||||
"is_default" : false,
|
||||
"issuer" : {
|
||||
"city" : "Taipei",
|
||||
"common_name" : "Synology Inc. CA",
|
||||
"country" : "TW",
|
||||
"organization" : "Synology Inc."
|
||||
},
|
||||
"key_types" : "",
|
||||
"renewable" : false,
|
||||
"self_signed_cacrt_info" : {
|
||||
"issuer" : {
|
||||
"city" : "Taipei",
|
||||
"common_name" : "Synology Inc. CA",
|
||||
"country" : "TW",
|
||||
"organization" : "Synology Inc."
|
||||
},
|
||||
"subject" : {
|
||||
"city" : "Taipei",
|
||||
"common_name" : "Synology Inc. CA",
|
||||
"country" : "TW",
|
||||
"organization" : "Synology Inc."
|
||||
}
|
||||
},
|
||||
"services" : [],
|
||||
"signature_algorithm" : "sha256WithRSAEncryption",
|
||||
"subject" : {
|
||||
"city" : "Taipei",
|
||||
"common_name" : "synology.com",
|
||||
"country" : "TW",
|
||||
"organization" : "Synology Inc.",
|
||||
"sub_alt_name" : []
|
||||
},
|
||||
"user_deletable" : true,
|
||||
"valid_from" : "May 27 00:23:19 2019 GMT",
|
||||
"valid_till" : "Feb 11 00:23:19 2039 GMT"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
Error: nil,
|
||||
},
|
||||
want: []certificateInfo{
|
||||
{Desc: "Tailnet Certificate", ID: "cG2XBt", Subject: subject{CommonName: "foo.tailscale.ts.net"}},
|
||||
{Desc: "", ID: "sgmnpb", Subject: subject{CommonName: "synology.com"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "call error",
|
||||
caller: fakeAPICaller{nil, fmt.Errorf("caller failed")},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "payload decode error",
|
||||
caller: fakeAPICaller{json.RawMessage("This isn't JSON!"), nil},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := listCerts(context.Background(), tt.caller)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("listCerts() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("listCerts() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,11 @@ import (
|
||||
// used to configure Synology devices, but is now a compatibility alias to
|
||||
// "tailscale configure synology".
|
||||
var configureHostCmd = &ffcli.Command{
|
||||
Name: "configure-host",
|
||||
Exec: runConfigureSynology,
|
||||
ShortHelp: synologyConfigureCmd.ShortHelp,
|
||||
LongHelp: synologyConfigureCmd.LongHelp,
|
||||
Name: "configure-host",
|
||||
Exec: runConfigureSynology,
|
||||
ShortUsage: "tailscale configure-host\n" + synologyConfigureCmd.ShortUsage,
|
||||
ShortHelp: synologyConfigureCmd.ShortHelp,
|
||||
LongHelp: hidden + synologyConfigureCmd.LongHelp,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("configure-host")
|
||||
return fs
|
||||
@@ -33,9 +34,10 @@ var configureHostCmd = &ffcli.Command{
|
||||
}
|
||||
|
||||
var synologyConfigureCmd = &ffcli.Command{
|
||||
Name: "synology",
|
||||
Exec: runConfigureSynology,
|
||||
ShortHelp: "Configure Synology to enable outbound connections",
|
||||
Name: "synology",
|
||||
Exec: runConfigureSynology,
|
||||
ShortUsage: "tailscale configure synology",
|
||||
ShortHelp: "Configure Synology to enable outbound connections",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
This command is intended to run at boot as root on a Synology device to
|
||||
create the /dev/net/tun device and give the tailscaled binary permission
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -14,8 +13,9 @@ import (
|
||||
)
|
||||
|
||||
var configureCmd = &ffcli.Command{
|
||||
Name: "configure",
|
||||
ShortHelp: "[ALPHA] Configure the host to enable more Tailscale features",
|
||||
Name: "configure",
|
||||
ShortUsage: "tailscale configure <subcommand>",
|
||||
ShortHelp: "[ALPHA] Configure the host to enable more Tailscale features",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
The 'configure' set of commands are intended to provide a way to enable different
|
||||
services on the host to use Tailscale in more ways.
|
||||
@@ -25,14 +25,12 @@ services on the host to use Tailscale in more ways.
|
||||
return fs
|
||||
})(),
|
||||
Subcommands: configureSubcommands(),
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
return flag.ErrHelp
|
||||
},
|
||||
}
|
||||
|
||||
func configureSubcommands() (out []*ffcli.Command) {
|
||||
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
|
||||
out = append(out, synologyConfigureCmd)
|
||||
out = append(out, synologyConfigureCertCmd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -45,9 +45,11 @@ import (
|
||||
)
|
||||
|
||||
var debugCmd = &ffcli.Command{
|
||||
Name: "debug",
|
||||
Exec: runDebug,
|
||||
LongHelp: `"tailscale debug" contains misc debug facilities; it is not a stable interface.`,
|
||||
Name: "debug",
|
||||
Exec: runDebug,
|
||||
ShortUsage: "tailscale debug <debug-flags | subcommand>",
|
||||
ShortHelp: "Debug commands",
|
||||
LongHelp: hidden + `"tailscale debug" contains misc debug facilities; it is not a stable interface.`,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("debug")
|
||||
fs.StringVar(&debugArgs.file, "file", "", "get, delete:NAME, or NAME")
|
||||
@@ -58,15 +60,16 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "derp-map",
|
||||
Exec: runDERPMap,
|
||||
ShortHelp: "print DERP map",
|
||||
Name: "derp-map",
|
||||
ShortUsage: "tailscale debug derp-map",
|
||||
Exec: runDERPMap,
|
||||
ShortHelp: "Print DERP map",
|
||||
},
|
||||
{
|
||||
Name: "component-logs",
|
||||
Exec: runDebugComponentLogs,
|
||||
ShortHelp: "enable/disable debug logs for a component",
|
||||
ShortUsage: "tailscale debug component-logs [" + strings.Join(ipn.DebuggableComponents, "|") + "]",
|
||||
Exec: runDebugComponentLogs,
|
||||
ShortHelp: "Enable/disable debug logs for a component",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("component-logs")
|
||||
fs.DurationVar(&debugComponentLogsArgs.forDur, "for", time.Hour, "how long to enable debug logs for; zero or negative means to disable")
|
||||
@@ -74,14 +77,16 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "daemon-goroutines",
|
||||
Exec: runDaemonGoroutines,
|
||||
ShortHelp: "print tailscaled's goroutines",
|
||||
Name: "daemon-goroutines",
|
||||
ShortUsage: "tailscale debug daemon-goroutines",
|
||||
Exec: runDaemonGoroutines,
|
||||
ShortHelp: "Print tailscaled's goroutines",
|
||||
},
|
||||
{
|
||||
Name: "daemon-logs",
|
||||
Exec: runDaemonLogs,
|
||||
ShortHelp: "watch tailscaled's server logs",
|
||||
Name: "daemon-logs",
|
||||
ShortUsage: "tailscale debug daemon-logs",
|
||||
Exec: runDaemonLogs,
|
||||
ShortHelp: "Watch tailscaled's server logs",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("daemon-logs")
|
||||
fs.IntVar(&daemonLogsArgs.verbose, "verbose", 0, "verbosity level")
|
||||
@@ -90,9 +95,10 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "metrics",
|
||||
Exec: runDaemonMetrics,
|
||||
ShortHelp: "print tailscaled's metrics",
|
||||
Name: "metrics",
|
||||
ShortUsage: "tailscale debug metrics",
|
||||
Exec: runDaemonMetrics,
|
||||
ShortHelp: "Print tailscaled's metrics",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("metrics")
|
||||
fs.BoolVar(&metricsArgs.watch, "watch", false, "print JSON dump of delta values")
|
||||
@@ -100,80 +106,95 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "env",
|
||||
Exec: runEnv,
|
||||
ShortHelp: "print cmd/tailscale environment",
|
||||
Name: "env",
|
||||
ShortUsage: "tailscale debug env",
|
||||
Exec: runEnv,
|
||||
ShortHelp: "Print cmd/tailscale environment",
|
||||
},
|
||||
{
|
||||
Name: "stat",
|
||||
Exec: runStat,
|
||||
ShortHelp: "stat a file",
|
||||
Name: "stat",
|
||||
ShortUsage: "tailscale debug stat <files...>",
|
||||
Exec: runStat,
|
||||
ShortHelp: "Stat a file",
|
||||
},
|
||||
{
|
||||
Name: "hostinfo",
|
||||
Exec: runHostinfo,
|
||||
ShortHelp: "print hostinfo",
|
||||
Name: "hostinfo",
|
||||
ShortUsage: "tailscale debug hostinfo",
|
||||
Exec: runHostinfo,
|
||||
ShortHelp: "Print hostinfo",
|
||||
},
|
||||
{
|
||||
Name: "local-creds",
|
||||
Exec: runLocalCreds,
|
||||
ShortHelp: "print how to access Tailscale LocalAPI",
|
||||
Name: "local-creds",
|
||||
ShortUsage: "tailscale debug local-creds",
|
||||
Exec: runLocalCreds,
|
||||
ShortHelp: "Print how to access Tailscale LocalAPI",
|
||||
},
|
||||
{
|
||||
Name: "restun",
|
||||
Exec: localAPIAction("restun"),
|
||||
ShortHelp: "force a magicsock restun",
|
||||
Name: "restun",
|
||||
ShortUsage: "tailscale debug restun",
|
||||
Exec: localAPIAction("restun"),
|
||||
ShortHelp: "Force a magicsock restun",
|
||||
},
|
||||
{
|
||||
Name: "rebind",
|
||||
Exec: localAPIAction("rebind"),
|
||||
ShortHelp: "force a magicsock rebind",
|
||||
Name: "rebind",
|
||||
ShortUsage: "tailscale debug rebind",
|
||||
Exec: localAPIAction("rebind"),
|
||||
ShortHelp: "Force a magicsock rebind",
|
||||
},
|
||||
{
|
||||
Name: "derp-set-on-demand",
|
||||
Exec: localAPIAction("derp-set-homeless"),
|
||||
ShortHelp: "enable DERP on-demand mode (breaks reachability)",
|
||||
Name: "derp-set-on-demand",
|
||||
ShortUsage: "tailscale debug derp-set-on-demand",
|
||||
Exec: localAPIAction("derp-set-homeless"),
|
||||
ShortHelp: "Enable DERP on-demand mode (breaks reachability)",
|
||||
},
|
||||
{
|
||||
Name: "derp-unset-on-demand",
|
||||
Exec: localAPIAction("derp-unset-homeless"),
|
||||
ShortHelp: "disable DERP on-demand mode",
|
||||
Name: "derp-unset-on-demand",
|
||||
ShortUsage: "tailscale debug derp-unset-on-demand",
|
||||
Exec: localAPIAction("derp-unset-homeless"),
|
||||
ShortHelp: "Disable DERP on-demand mode",
|
||||
},
|
||||
{
|
||||
Name: "break-tcp-conns",
|
||||
Exec: localAPIAction("break-tcp-conns"),
|
||||
ShortHelp: "break any open TCP connections from the daemon",
|
||||
Name: "break-tcp-conns",
|
||||
ShortUsage: "tailscale debug break-tcp-conns",
|
||||
Exec: localAPIAction("break-tcp-conns"),
|
||||
ShortHelp: "Break any open TCP connections from the daemon",
|
||||
},
|
||||
{
|
||||
Name: "break-derp-conns",
|
||||
Exec: localAPIAction("break-derp-conns"),
|
||||
ShortHelp: "break any open DERP connections from the daemon",
|
||||
Name: "break-derp-conns",
|
||||
ShortUsage: "tailscale debug break-derp-conns",
|
||||
Exec: localAPIAction("break-derp-conns"),
|
||||
ShortHelp: "Break any open DERP connections from the daemon",
|
||||
},
|
||||
{
|
||||
Name: "pick-new-derp",
|
||||
Exec: localAPIAction("pick-new-derp"),
|
||||
ShortHelp: "switch to some other random DERP home region for a short time",
|
||||
Name: "pick-new-derp",
|
||||
ShortUsage: "tailscale debug pick-new-derp",
|
||||
Exec: localAPIAction("pick-new-derp"),
|
||||
ShortHelp: "Switch to some other random DERP home region for a short time",
|
||||
},
|
||||
{
|
||||
Name: "force-netmap-update",
|
||||
Exec: localAPIAction("force-netmap-update"),
|
||||
ShortHelp: "force a full no-op netmap update (for load testing)",
|
||||
Name: "force-netmap-update",
|
||||
ShortUsage: "tailscale debug force-netmap-update",
|
||||
Exec: localAPIAction("force-netmap-update"),
|
||||
ShortHelp: "Force a full no-op netmap update (for load testing)",
|
||||
},
|
||||
{
|
||||
// TODO(bradfitz,maisem): eventually promote this out of debug
|
||||
Name: "reload-config",
|
||||
Exec: reloadConfig,
|
||||
ShortHelp: "reload config",
|
||||
Name: "reload-config",
|
||||
ShortUsage: "tailscale debug reload-config",
|
||||
Exec: reloadConfig,
|
||||
ShortHelp: "Reload config",
|
||||
},
|
||||
{
|
||||
Name: "control-knobs",
|
||||
Exec: debugControlKnobs,
|
||||
ShortHelp: "see current control knobs",
|
||||
Name: "control-knobs",
|
||||
ShortUsage: "tailscale debug control-knobs",
|
||||
Exec: debugControlKnobs,
|
||||
ShortHelp: "See current control knobs",
|
||||
},
|
||||
{
|
||||
Name: "prefs",
|
||||
Exec: runPrefs,
|
||||
ShortHelp: "print prefs",
|
||||
Name: "prefs",
|
||||
ShortUsage: "tailscale debug prefs",
|
||||
Exec: runPrefs,
|
||||
ShortHelp: "Print prefs",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("prefs")
|
||||
fs.BoolVar(&prefsArgs.pretty, "pretty", false, "If true, pretty-print output")
|
||||
@@ -181,9 +202,10 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "watch-ipn",
|
||||
Exec: runWatchIPN,
|
||||
ShortHelp: "subscribe to IPN message bus",
|
||||
Name: "watch-ipn",
|
||||
ShortUsage: "tailscale debug watch-ipn",
|
||||
Exec: runWatchIPN,
|
||||
ShortHelp: "Subscribe to IPN message bus",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("watch-ipn")
|
||||
fs.BoolVar(&watchIPNArgs.netmap, "netmap", true, "include netmap in messages")
|
||||
@@ -194,9 +216,10 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "netmap",
|
||||
Exec: runNetmap,
|
||||
ShortHelp: "print the current network map",
|
||||
Name: "netmap",
|
||||
ShortUsage: "tailscale debug netmap",
|
||||
Exec: runNetmap,
|
||||
ShortHelp: "Print the current network map",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("netmap")
|
||||
fs.BoolVar(&netmapArgs.showPrivateKey, "show-private-key", false, "include node private key in printed netmap")
|
||||
@@ -204,14 +227,17 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "via",
|
||||
Name: "via",
|
||||
ShortUsage: "tailscale debug via <site-id> <v4-cidr>\n" +
|
||||
"tailscale debug via <v6-route>",
|
||||
Exec: runVia,
|
||||
ShortHelp: "convert between site-specific IPv4 CIDRs and IPv6 'via' routes",
|
||||
ShortHelp: "Convert between site-specific IPv4 CIDRs and IPv6 'via' routes",
|
||||
},
|
||||
{
|
||||
Name: "ts2021",
|
||||
Exec: runTS2021,
|
||||
ShortHelp: "debug ts2021 protocol connectivity",
|
||||
Name: "ts2021",
|
||||
ShortUsage: "tailscale debug ts2021",
|
||||
Exec: runTS2021,
|
||||
ShortHelp: "Debug ts2021 protocol connectivity",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("ts2021")
|
||||
fs.StringVar(&ts2021Args.host, "host", "controlplane.tailscale.com", "hostname of control plane")
|
||||
@@ -221,9 +247,10 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "set-expire",
|
||||
Exec: runSetExpire,
|
||||
ShortHelp: "manipulate node key expiry for testing",
|
||||
Name: "set-expire",
|
||||
ShortUsage: "tailscale debug set-expire --in=1m",
|
||||
Exec: runSetExpire,
|
||||
ShortHelp: "Manipulate node key expiry for testing",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("set-expire")
|
||||
fs.DurationVar(&setExpireArgs.in, "in", 0, "if non-zero, set node key to expire this duration from now")
|
||||
@@ -231,9 +258,10 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "dev-store-set",
|
||||
Exec: runDevStoreSet,
|
||||
ShortHelp: "set a key/value pair during development",
|
||||
Name: "dev-store-set",
|
||||
ShortUsage: "tailscale debug dev-store-set",
|
||||
Exec: runDevStoreSet,
|
||||
ShortHelp: "Set a key/value pair during development",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("store-set")
|
||||
fs.BoolVar(&devStoreSetArgs.danger, "danger", false, "accept danger")
|
||||
@@ -241,14 +269,16 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "derp",
|
||||
Exec: runDebugDERP,
|
||||
ShortHelp: "test a DERP configuration",
|
||||
Name: "derp",
|
||||
ShortUsage: "tailscale debug derp",
|
||||
Exec: runDebugDERP,
|
||||
ShortHelp: "Test a DERP configuration",
|
||||
},
|
||||
{
|
||||
Name: "capture",
|
||||
Exec: runCapture,
|
||||
ShortHelp: "streams pcaps for debugging",
|
||||
Name: "capture",
|
||||
ShortUsage: "tailscale debug capture",
|
||||
Exec: runCapture,
|
||||
ShortHelp: "Streams pcaps for debugging",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("capture")
|
||||
fs.StringVar(&captureArgs.outFile, "o", "", "path to stream the pcap (or - for stdout), leave empty to start wireshark")
|
||||
@@ -256,9 +286,10 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "portmap",
|
||||
Exec: debugPortmap,
|
||||
ShortHelp: "run portmap debugging",
|
||||
Name: "portmap",
|
||||
ShortUsage: "tailscale debug portmap",
|
||||
Exec: debugPortmap,
|
||||
ShortHelp: "Run portmap debugging",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("portmap")
|
||||
fs.DurationVar(&debugPortmapArgs.duration, "duration", 5*time.Second, "timeout for port mapping")
|
||||
@@ -270,14 +301,16 @@ var debugCmd = &ffcli.Command{
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "peer-endpoint-changes",
|
||||
Exec: runPeerEndpointChanges,
|
||||
ShortHelp: "prints debug information about a peer's endpoint changes",
|
||||
Name: "peer-endpoint-changes",
|
||||
ShortUsage: "tailscale debug peer-endpoint-changes <hostname-or-IP>",
|
||||
Exec: runPeerEndpointChanges,
|
||||
ShortHelp: "Prints debug information about a peer's endpoint changes",
|
||||
},
|
||||
{
|
||||
Name: "dial-types",
|
||||
Exec: runDebugDialTypes,
|
||||
ShortHelp: "prints debug information about connecting to a given host or IP",
|
||||
Name: "dial-types",
|
||||
ShortUsage: "tailscale debug dial-types <hostname-or-IP> <port>",
|
||||
Exec: runDebugDialTypes,
|
||||
ShortHelp: "Prints debug information about connecting to a given host or IP",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("dial-types")
|
||||
fs.StringVar(&debugDialTypesArgs.network, "network", "tcp", `network type to dial ("tcp", "udp", etc.)`)
|
||||
@@ -314,7 +347,7 @@ func outName(dst string) string {
|
||||
|
||||
func runDebug(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return errors.New("unknown arguments")
|
||||
return fmt.Errorf("tailscale debug: unknown subcommand: %s", args[0])
|
||||
}
|
||||
var usedFlag bool
|
||||
if out := debugArgs.cpuFile; out != "" {
|
||||
@@ -369,7 +402,7 @@ func runDebug(ctx context.Context, args []string) error {
|
||||
// to subcommands.
|
||||
return nil
|
||||
}
|
||||
return errors.New("see 'tailscale debug --help")
|
||||
return errors.New("tailscale debug: subcommand or flag required")
|
||||
}
|
||||
|
||||
func runLocalCreds(ctx context.Context, args []string) error {
|
||||
@@ -453,7 +486,7 @@ func runWatchIPN(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
defer watcher.Close()
|
||||
fmt.Fprintf(os.Stderr, "Connected.\n")
|
||||
fmt.Fprintf(Stderr, "Connected.\n")
|
||||
for seen := 0; watchIPNArgs.count == 0 || seen < watchIPNArgs.count; seen++ {
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
@@ -563,7 +596,7 @@ func runStat(ctx context.Context, args []string) error {
|
||||
func runHostinfo(ctx context.Context, args []string) error {
|
||||
hi := hostinfo.New()
|
||||
j, _ := json.MarshalIndent(hi, "", " ")
|
||||
os.Stdout.Write(j)
|
||||
Stdout.Write(j)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -716,7 +749,7 @@ var ts2021Args struct {
|
||||
}
|
||||
|
||||
func runTS2021(ctx context.Context, args []string) error {
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetOutput(Stdout)
|
||||
log.SetFlags(log.Ltime | log.Lmicroseconds)
|
||||
|
||||
keysURL := "https://" + ts2021Args.host + "/key?v=" + strconv.Itoa(ts2021Args.version)
|
||||
@@ -810,7 +843,7 @@ var debugComponentLogsArgs struct {
|
||||
|
||||
func runDebugComponentLogs(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: debug component-logs [" + strings.Join(ipn.DebuggableComponents, "|") + "]")
|
||||
return errors.New("usage: tailscale debug component-logs [" + strings.Join(ipn.DebuggableComponents, "|") + "]")
|
||||
}
|
||||
component := args[0]
|
||||
dur := debugComponentLogsArgs.forDur
|
||||
@@ -833,7 +866,7 @@ var devStoreSetArgs struct {
|
||||
|
||||
func runDevStoreSet(ctx context.Context, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: dev-store-set --danger <key> <value>")
|
||||
return errors.New("usage: tailscale debug dev-store-set --danger <key> <value>")
|
||||
}
|
||||
if !devStoreSetArgs.danger {
|
||||
return errors.New("this command is dangerous; use --danger to proceed")
|
||||
@@ -851,7 +884,7 @@ func runDevStoreSet(ctx context.Context, args []string) error {
|
||||
|
||||
func runDebugDERP(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: debug derp <region>")
|
||||
return errors.New("usage: tailscale debug derp <region>")
|
||||
}
|
||||
st, err := localClient.DebugDERPRegion(ctx, args[0])
|
||||
if err != nil {
|
||||
@@ -867,7 +900,7 @@ var setExpireArgs struct {
|
||||
|
||||
func runSetExpire(ctx context.Context, args []string) error {
|
||||
if len(args) != 0 || setExpireArgs.in == 0 {
|
||||
return errors.New("usage --in=<duration>")
|
||||
return errors.New("usage: tailscale debug set-expire --in=<duration>")
|
||||
}
|
||||
return localClient.DebugSetExpireIn(ctx, setExpireArgs.in)
|
||||
}
|
||||
@@ -885,7 +918,7 @@ func runCapture(ctx context.Context, args []string) error {
|
||||
|
||||
switch captureArgs.outFile {
|
||||
case "-":
|
||||
fmt.Fprintln(os.Stderr, "Press Ctrl-C to stop the capture.")
|
||||
fmt.Fprintln(Stderr, "Press Ctrl-C to stop the capture.")
|
||||
_, err = io.Copy(os.Stdout, stream)
|
||||
return err
|
||||
case "":
|
||||
@@ -911,7 +944,7 @@ func runCapture(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
fmt.Fprintln(os.Stderr, "Press Ctrl-C to stop the capture.")
|
||||
fmt.Fprintln(Stderr, "Press Ctrl-C to stop the capture.")
|
||||
_, err = io.Copy(f, stream)
|
||||
return err
|
||||
}
|
||||
@@ -966,7 +999,7 @@ func runPeerEndpointChanges(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
if len(args) != 1 || args[0] == "" {
|
||||
return errors.New("usage: peer-status <hostname-or-IP>")
|
||||
return errors.New("usage: tailscale debug peer-endpoint-changes <hostname-or-IP>")
|
||||
}
|
||||
var ip string
|
||||
|
||||
@@ -1042,7 +1075,7 @@ func runDebugDialTypes(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
if len(args) != 2 || args[0] == "" || args[1] == "" {
|
||||
return errors.New("usage: dial-types <hostname-or-IP> <port>")
|
||||
return errors.New("usage: tailscale debug dial-types <hostname-or-IP> <port>")
|
||||
}
|
||||
|
||||
port, err := strconv.ParseUint(args[1], 10, 16)
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
var downCmd = &ffcli.Command{
|
||||
Name: "down",
|
||||
ShortUsage: "down",
|
||||
ShortUsage: "tailscale down",
|
||||
ShortHelp: "Disconnect from Tailscale",
|
||||
|
||||
Exec: runDown,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user