Compare commits
419 Commits
tom/iptabl
...
will/enfor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9fa174e11 | ||
|
|
b45b948776 | ||
|
|
1ef4be2f86 | ||
|
|
aeb80bf8cb | ||
|
|
6708f9a93f | ||
|
|
ed1fae6c73 | ||
|
|
0f7da5c7dc | ||
|
|
f053f16460 | ||
|
|
8d84178884 | ||
|
|
aeac4bc8e2 | ||
|
|
18c7c3981a | ||
|
|
41dd49391f | ||
|
|
0480a925c1 | ||
|
|
b190c1667b | ||
|
|
5c9203669a | ||
|
|
a0ef51f570 | ||
|
|
b94b91c168 | ||
|
|
575fd5f22b | ||
|
|
33520920c3 | ||
|
|
41e1d336cc | ||
|
|
bdd8ce6692 | ||
|
|
d1e1c025b0 | ||
|
|
538f431d5d | ||
|
|
aac3d5bdd1 | ||
|
|
039ea51ca6 | ||
|
|
a26f23d949 | ||
|
|
063eeefdca | ||
|
|
92fa0313d0 | ||
|
|
f52a6d1b8c | ||
|
|
2847dd2aef | ||
|
|
e2f8b84170 | ||
|
|
2eb0687969 | ||
|
|
3a168cc1ff | ||
|
|
2a991a3541 | ||
|
|
a011320370 | ||
|
|
f1ad26f694 | ||
|
|
f40bb199f5 | ||
|
|
3c27632ffe | ||
|
|
dd50dcd067 | ||
|
|
f18dde6ad1 | ||
|
|
a13753ae1e | ||
|
|
b5299d7d0e | ||
|
|
a97369f097 | ||
|
|
5f6d63936f | ||
|
|
0af61f7c40 | ||
|
|
cec48743fb | ||
|
|
1b8c13e18a | ||
|
|
f3519f7b29 | ||
|
|
ec1e67b1ab | ||
|
|
eff62b7b1b | ||
|
|
1de64e89cd | ||
|
|
b3da5de10f | ||
|
|
b0736fe6f7 | ||
|
|
2f4fca65a1 | ||
|
|
e9c851b04b | ||
|
|
296e712591 | ||
|
|
1e78fc462c | ||
|
|
1f4669a380 | ||
|
|
22238d897b | ||
|
|
1b56acf513 | ||
|
|
513780f4f8 | ||
|
|
4caca8619e | ||
|
|
4fc8538e2f | ||
|
|
49b0ce8180 | ||
|
|
976e88d430 | ||
|
|
97319a1970 | ||
|
|
2d653230ef | ||
|
|
6ea2d01626 | ||
|
|
d3878ecd62 | ||
|
|
b08f37d069 | ||
|
|
6d48a54b3d | ||
|
|
235309adc4 | ||
|
|
751f866f01 | ||
|
|
fe81ee62d7 | ||
|
|
e0cadc5496 | ||
|
|
1950e56478 | ||
|
|
f81351fdef | ||
|
|
42855d219b | ||
|
|
0cc65b4bbe | ||
|
|
3271daf7a3 | ||
|
|
fb392e34b5 | ||
|
|
96e1582298 | ||
|
|
a255a08ea6 | ||
|
|
13bee8e91c | ||
|
|
7c285fe7ee | ||
|
|
3114eacbb8 | ||
|
|
90bd74fc05 | ||
|
|
3f8e185003 | ||
|
|
b1a6d8e2b1 | ||
|
|
001f482aca | ||
|
|
b87cb2c4a5 | ||
|
|
8e85227059 | ||
|
|
26d1fc867e | ||
|
|
0544d6ed04 | ||
|
|
b5ac9172fd | ||
|
|
9e70daad6f | ||
|
|
29bc021dcd | ||
|
|
74e892cbc2 | ||
|
|
cbc89830c4 | ||
|
|
08e110ebc5 | ||
|
|
66b4a363bd | ||
|
|
e0cd9e9dec | ||
|
|
6aab4fb696 | ||
|
|
d585cbf02a | ||
|
|
4c31183781 | ||
|
|
c60e444696 | ||
|
|
ae18cd02c1 | ||
|
|
6cc0036b40 | ||
|
|
329a0a8406 | ||
|
|
f00a49667d | ||
|
|
4d330bac14 | ||
|
|
c9d6a9cb4d | ||
|
|
56dfdbe190 | ||
|
|
f4a522fd67 | ||
|
|
13cadeabcd | ||
|
|
69e4b8a359 | ||
|
|
9bf3ef4167 | ||
|
|
e3a66e4d2f | ||
|
|
7b5866ac0a | ||
|
|
446057d613 | ||
|
|
7a07bc654b | ||
|
|
9a05cdd2b5 | ||
|
|
d7bfef12cf | ||
|
|
9dfb0916c2 | ||
|
|
65f3dab4c6 | ||
|
|
73b8968404 | ||
|
|
32a4ff3e5f | ||
|
|
6beb3184d5 | ||
|
|
1a94c309ea | ||
|
|
4797bacb7c | ||
|
|
2111357568 | ||
|
|
b683921b87 | ||
|
|
4bccc02413 | ||
|
|
4de643b714 | ||
|
|
25e26c16ee | ||
|
|
c35dcd427f | ||
|
|
df5e40f731 | ||
|
|
79472a4a6e | ||
|
|
2daf0f146c | ||
|
|
acf5839dd2 | ||
|
|
e85613aa2d | ||
|
|
cba1312dab | ||
|
|
abfdcd0f70 | ||
|
|
6d8320a6e9 | ||
|
|
9be8d15979 | ||
|
|
847a8cf917 | ||
|
|
5e703bdb55 | ||
|
|
6acc27a92f | ||
|
|
5bb7e0307c | ||
|
|
bf2d3cd074 | ||
|
|
e0669555dd | ||
|
|
be7556aece | ||
|
|
c2d7940ec0 | ||
|
|
036334e913 | ||
|
|
db2cc393af | ||
|
|
21ef7e5c35 | ||
|
|
da8def8e13 | ||
|
|
bb2cba0cd1 | ||
|
|
6afe26575c | ||
|
|
c3a5489e72 | ||
|
|
76904b82e7 | ||
|
|
0759d78f12 | ||
|
|
a413fa4f85 | ||
|
|
d57cba8655 | ||
|
|
e55ae53169 | ||
|
|
3367136d9e | ||
|
|
18fa1a0ad7 | ||
|
|
2327c6b05f | ||
|
|
e975cb6b05 | ||
|
|
0af57fce4c | ||
|
|
7d6775b082 | ||
|
|
910db02652 | ||
|
|
8c790207a0 | ||
|
|
a0ed2c2eb5 | ||
|
|
06b55ab50f | ||
|
|
988c1f0ac7 | ||
|
|
a7f7e79245 | ||
|
|
f4ff26f577 | ||
|
|
60f77ba515 | ||
|
|
1440742a1c | ||
|
|
2be951a582 | ||
|
|
e2519813b1 | ||
|
|
42f7ef631e | ||
|
|
d98305c537 | ||
|
|
3d8eda5b72 | ||
|
|
5677ed1e85 | ||
|
|
798dba14eb | ||
|
|
ea24895e08 | ||
|
|
7ad636f5b7 | ||
|
|
3336d08d59 | ||
|
|
7b6cd4e659 | ||
|
|
231b88cc51 | ||
|
|
e25ab75795 | ||
|
|
193afe19cb | ||
|
|
120bfc97ce | ||
|
|
4e6e3bd13d | ||
|
|
cfef47ddcc | ||
|
|
dfe67afb4a | ||
|
|
b2035a1dca | ||
|
|
48ddb3af2a | ||
|
|
a3602c28bd | ||
|
|
81fd259133 | ||
|
|
5e9e57ecf5 | ||
|
|
c21a3c4733 | ||
|
|
a44687e71f | ||
|
|
4021ae6b9d | ||
|
|
8c09ae9032 | ||
|
|
944f43f1c8 | ||
|
|
95f3dd1346 | ||
|
|
19b5586573 | ||
|
|
a471681e28 | ||
|
|
d60f7fe33f | ||
|
|
2a9ba28def | ||
|
|
bff202a290 | ||
|
|
35bee36549 | ||
|
|
527741d41f | ||
|
|
a1a2c165e9 | ||
|
|
dafc822654 | ||
|
|
9f39c3b10f | ||
|
|
a2d15924fb | ||
|
|
0957bc5af2 | ||
|
|
20324eeebc | ||
|
|
ba459aeef5 | ||
|
|
660abd7309 | ||
|
|
9beb07b4ff | ||
|
|
575c599410 | ||
|
|
036f70b7b4 | ||
|
|
4597ec1037 | ||
|
|
70dde89c34 | ||
|
|
774fa72d32 | ||
|
|
f36ddd9275 | ||
|
|
3697609aaa | ||
|
|
7149155b80 | ||
|
|
def089f9c9 | ||
|
|
46ce80758d | ||
|
|
67597bfc9e | ||
|
|
7b745a1a50 | ||
|
|
74693793be | ||
|
|
42d9e7171c | ||
|
|
bd47e28638 | ||
|
|
adec726fee | ||
|
|
74637f2c15 | ||
|
|
95f630ced0 | ||
|
|
d13c9cdfb4 | ||
|
|
deac82231c | ||
|
|
69f61dcad8 | ||
|
|
f39847aa52 | ||
|
|
afce773aae | ||
|
|
18c61afeb9 | ||
|
|
d0b7a44840 | ||
|
|
e966f024b0 | ||
|
|
223126fe5b | ||
|
|
d499afac78 | ||
|
|
9c2ad7086c | ||
|
|
9d04ffc782 | ||
|
|
d00b095f14 | ||
|
|
9475801ebe | ||
|
|
37da617380 | ||
|
|
7741e9feb0 | ||
|
|
246274b8e9 | ||
|
|
03ecf335f7 | ||
|
|
14100c0985 | ||
|
|
45b7e8c23c | ||
|
|
630bcb5b67 | ||
|
|
e8a11f6181 | ||
|
|
86c5bddce2 | ||
|
|
9116e92718 | ||
|
|
9ee3df02ee | ||
|
|
3a33895f1b | ||
|
|
a4e707bcf0 | ||
|
|
b55761246b | ||
|
|
af966391c7 | ||
|
|
19dfdeb1bb | ||
|
|
4eed2883db | ||
|
|
c32f9f5865 | ||
|
|
64ea60aaa3 | ||
|
|
a04f1ff9e6 | ||
|
|
63ad49890f | ||
|
|
899b4cae10 | ||
|
|
a515fc517b | ||
|
|
227777154a | ||
|
|
26af329fde | ||
|
|
39d03b6b63 | ||
|
|
3555a49518 | ||
|
|
539c073cf0 | ||
|
|
a315336287 | ||
|
|
d05dd41bc1 | ||
|
|
9a264dac01 | ||
|
|
b2855cfd86 | ||
|
|
4ec6d41682 | ||
|
|
a1a43ed266 | ||
|
|
db863bf00f | ||
|
|
f9120eee57 | ||
|
|
49bae7fd5c | ||
|
|
5363a90272 | ||
|
|
b49eb7d55c | ||
|
|
1b4e4cc1e8 | ||
|
|
79755d3ce5 | ||
|
|
1b9ed9f365 | ||
|
|
e7519adc18 | ||
|
|
e24de8a617 | ||
|
|
c070d39287 | ||
|
|
51d488673a | ||
|
|
680f8d9793 | ||
|
|
614a24763b | ||
|
|
0475ed4a7e | ||
|
|
7df85c6031 | ||
|
|
718914b697 | ||
|
|
529e893f70 | ||
|
|
8a187159b2 | ||
|
|
b2994568fe | ||
|
|
f172fc42f7 | ||
|
|
8fe04b035c | ||
|
|
d29ec4d7a4 | ||
|
|
4de1601ef4 | ||
|
|
91b5c50b43 | ||
|
|
ecf6cdd830 | ||
|
|
f16b77de5d | ||
|
|
c8a3d02989 | ||
|
|
6d76764f37 | ||
|
|
b84ec521bf | ||
|
|
82f5f438e0 | ||
|
|
92ad56ddcb | ||
|
|
84e8f25c21 | ||
|
|
dd045a3767 | ||
|
|
a73c423c8a | ||
|
|
3af0d4d0f2 | ||
|
|
c321363d2c | ||
|
|
24ebf161e8 | ||
|
|
a37ee8483f | ||
|
|
7714261566 | ||
|
|
8602061f32 | ||
|
|
73db56af52 | ||
|
|
01ebef0f4f | ||
|
|
62bc1052a2 | ||
|
|
2243dbccb7 | ||
|
|
b1bd96f114 | ||
|
|
fde20f3403 | ||
|
|
7ffd2fe005 | ||
|
|
2934c5114c | ||
|
|
bdf3d2a63f | ||
|
|
c5ce355756 | ||
|
|
7e0ffc17fd | ||
|
|
17348915fa | ||
|
|
1841d0bf98 | ||
|
|
5c69961a57 | ||
|
|
e5636997c5 | ||
|
|
445c8a4671 | ||
|
|
d7c0410ea8 | ||
|
|
4102a687e3 | ||
|
|
5fc8843c4c | ||
|
|
8343b243e7 | ||
|
|
a7efc7bd17 | ||
|
|
d4811f11a0 | ||
|
|
e73657d7aa | ||
|
|
bb7be74756 | ||
|
|
c581ce7b00 | ||
|
|
420d841292 | ||
|
|
58ffe928af | ||
|
|
ab591906c8 | ||
|
|
9214b293e3 | ||
|
|
44f13d32d7 | ||
|
|
18159431ab | ||
|
|
a5fab23e8f | ||
|
|
4b996ad5e3 | ||
|
|
ebd1637e50 | ||
|
|
e97d5634bf | ||
|
|
f981b1d9da | ||
|
|
9bdf0cd8cd | ||
|
|
7686446c60 | ||
|
|
b1867457a6 | ||
|
|
e3beb4429f | ||
|
|
8a0cb4ef20 | ||
|
|
fb4e23506f | ||
|
|
6d04184325 | ||
|
|
8c72aabbdf | ||
|
|
f7cb535693 | ||
|
|
146f51ce76 | ||
|
|
c66e15772f | ||
|
|
e1bdbfe710 | ||
|
|
acc7baac6d | ||
|
|
91794f6498 | ||
|
|
2c447de6cc | ||
|
|
021bedfb89 | ||
|
|
d988c9f098 | ||
|
|
0607832397 | ||
|
|
565dbc599a | ||
|
|
aadf63da1d | ||
|
|
d5781f61a9 | ||
|
|
a7a0baf6b9 | ||
|
|
e9b98dd2e1 | ||
|
|
b9b0bf65a0 | ||
|
|
c6162c2a94 | ||
|
|
aa5e494aba | ||
|
|
ff13c66f55 | ||
|
|
ed248b04a7 | ||
|
|
8158dd2edc | ||
|
|
6632504f45 | ||
|
|
054ef4de56 | ||
|
|
d045462dfb | ||
|
|
d8eb111ac8 | ||
|
|
832031d54b | ||
|
|
42f1d92ae0 | ||
|
|
41bb47de0e | ||
|
|
3562b5bdfa | ||
|
|
5c42990c2f | ||
|
|
65c24b6334 | ||
|
|
4bda41e701 | ||
|
|
9b71008ef2 | ||
|
|
5623ef0271 | ||
|
|
486eecc063 | ||
|
|
b830c9975f | ||
|
|
4a82b317b7 | ||
|
|
f0347e841f | ||
|
|
027111fb5a | ||
|
|
1ce0e558a7 | ||
|
|
74674b110d | ||
|
|
33ee2c058e | ||
|
|
d34dd43562 |
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,12 +1,12 @@
|
||||
name: Bug report
|
||||
description: File a bug report
|
||||
description: File a bug report. If you need help, contact support instead
|
||||
labels: [needs-triage, bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please check if your bug is [already filed](https://github.com/tailscale/tailscale/issues).
|
||||
Have an urgent issue? Let us know by emailing us at <support@tailscale.com>.
|
||||
Need help with your tailnet? [Contact support](https://tailscale.com/contact/support) instead.
|
||||
Otherwise, please check if your bug is [already filed](https://github.com/tailscale/tailscale/issues) before filing a new one.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -5,4 +5,4 @@ contact_links:
|
||||
about: Contact us for support
|
||||
- name: Troubleshooting
|
||||
url: https://tailscale.com/kb/1023/troubleshooting
|
||||
about: Troubleshoot common issues
|
||||
about: See the troubleshooting guide for help addressing common issues
|
||||
2
.github/workflows/cifuzz.yml
vendored
2
.github/workflows/cifuzz.yml
vendored
@@ -2,7 +2,7 @@ name: CIFuzz
|
||||
on: [pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
55
.github/workflows/cross-android.yml
vendored
Normal file
55
.github/workflows/cross-android.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Android-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
id: go
|
||||
|
||||
- name: Android smoke build
|
||||
# Super minimal Android build that doesn't even use CGO and doesn't build everything that's needed
|
||||
# and is only arm64. But it's a smoke build: it's not meant to catch everything. But it'll catch
|
||||
# some Android breakages early.
|
||||
# TODO(bradfitz): better; see https://github.com/tailscale/tailscale/issues/4482
|
||||
env:
|
||||
GOOS: android
|
||||
GOARCH: arm64
|
||||
run: 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
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"attachments": [{
|
||||
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
|
||||
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
|
||||
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
|
||||
"color": "danger"
|
||||
}]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
if: failure() && github.event_name == 'push'
|
||||
1
.github/workflows/cross-darwin.yml
vendored
1
.github/workflows/cross-darwin.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
|
||||
1
.github/workflows/cross-freebsd.yml
vendored
1
.github/workflows/cross-freebsd.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
|
||||
1
.github/workflows/cross-openbsd.yml
vendored
1
.github/workflows/cross-openbsd.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
|
||||
3
.github/workflows/cross-wasm.yml
vendored
3
.github/workflows/cross-wasm.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
@@ -39,7 +40,7 @@ jobs:
|
||||
# that depend on it.
|
||||
run: |
|
||||
./tool/go run ./cmd/tsconnect --fast-compression build
|
||||
./tool/go run ./cmd/tsconnect build-pkg
|
||||
./tool/go run ./cmd/tsconnect --fast-compression build-pkg
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
|
||||
1
.github/workflows/cross-windows.yml
vendored
1
.github/workflows/cross-windows.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
|
||||
1
.github/workflows/depaware.yml
vendored
1
.github/workflows/depaware.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
|
||||
2
.github/workflows/go-licenses.yml
vendored
2
.github/workflows/go-licenses.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@18f90432bedd2afd6a825469ffd38aa24712a91d #v4.1.1
|
||||
uses: peter-evans/create-pull-request@ad43dccb4d726ca8514126628bec209b8354b6dd #v4.1.4
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
author: License Updater <noreply@tailscale.com>
|
||||
|
||||
1
.github/workflows/license.yml
vendored
1
.github/workflows/license.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
|
||||
1
.github/workflows/linux-race.yml
vendored
1
.github/workflows/linux-race.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
|
||||
1
.github/workflows/linux.yml
vendored
1
.github/workflows/linux.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
|
||||
1
.github/workflows/linux32.yml
vendored
1
.github/workflows/linux32.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
|
||||
1
.github/workflows/static-analysis.yml
vendored
1
.github/workflows/static-analysis.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
|
||||
3
.github/workflows/vm.yml
vendored
3
.github/workflows/vm.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
@@ -13,7 +14,7 @@ jobs:
|
||||
ubuntu2004-LTS-cloud-base:
|
||||
runs-on: [ self-hosted, linux, vm ]
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
if: "(github.repository == 'tailscale/tailscale') && !contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Set GOPATH
|
||||
|
||||
1
.github/workflows/windows.yml
vendored
1
.github/workflows/windows.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- 'release-branch/*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ cmd/tailscaled/tailscaled
|
||||
# direnv config, this may be different for other people so it's probably safer
|
||||
# to make this nonspecific.
|
||||
.envrc
|
||||
|
||||
# vscode project specific settings
|
||||
.vscode/
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
# $ docker exec tailscaled tailscale status
|
||||
|
||||
|
||||
FROM golang:1.18-alpine AS build-env
|
||||
FROM golang:1.19-alpine AS build-env
|
||||
|
||||
WORKDIR /go/src/tailscale
|
||||
|
||||
@@ -66,10 +66,12 @@ RUN GOARCH=$TARGETARCH go install -ldflags="\
|
||||
-X tailscale.com/version.Long=$VERSION_LONG \
|
||||
-X tailscale.com/version.Short=$VERSION_SHORT \
|
||||
-X tailscale.com/version.GitCommit=$VERSION_GIT_HASH" \
|
||||
-v ./cmd/tailscale ./cmd/tailscaled
|
||||
-v ./cmd/tailscale ./cmd/tailscaled ./cmd/containerboot
|
||||
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
|
||||
COPY --from=build-env /go/bin/* /usr/local/bin/
|
||||
COPY --from=build-env /go/src/tailscale/docs/k8s/run.sh /usr/local/bin/
|
||||
# For compat with the previous run.sh, although ideally you should be
|
||||
# using build_docker.sh which sets an entrypoint for the image.
|
||||
RUN ln -s /usr/local/bin/containerboot /tailscale/run.sh
|
||||
|
||||
6
Makefile
6
Makefile
@@ -54,3 +54,9 @@ pushspk: spk
|
||||
echo "Pushing SPK to root@${SYNO_HOST} (env var SYNO_HOST) ..."
|
||||
scp tailscale.spk root@${SYNO_HOST}:
|
||||
ssh root@${SYNO_HOST} /usr/syno/bin/synopkg install tailscale.spk
|
||||
|
||||
publishdevimage:
|
||||
@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)
|
||||
TAGS=latest REPOS=${REPO} PUSH=true ./build_docker.sh
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.31.0
|
||||
1.33.0
|
||||
|
||||
23
api.md
23
api.md
@@ -335,11 +335,12 @@ The response is 2xx on success. The response body is currently an empty JSON
|
||||
object.
|
||||
|
||||
## Tailnet
|
||||
A tailnet is the name of your Tailscale network.
|
||||
You can find it in the top left corner of the [Admin Panel](https://login.tailscale.com/admin) beside the Tailscale logo.
|
||||
|
||||
A tailnet is your private network, composed of all the devices on it and their configuration. For more information on tailnets, see [our user-facing documentation](https://tailscale.com/kb/1136/tailnet/).
|
||||
|
||||
`alice@example.com` belongs to the `example.com` tailnet and would use the following format for API calls:
|
||||
When making API requests, your tailnet is identified by the organization name. You can find it on the [Settings page](https://login.tailscale.com/admin/settings) of the admin console.
|
||||
|
||||
For example, if `alice@example.com` belongs to the `example.com` tailnet, they would use the following format for API calls:
|
||||
|
||||
```
|
||||
GET /api/v2/tailnet/example.com/...
|
||||
@@ -355,9 +356,14 @@ GET /api/v2/tailnet/alice@gmail.com/...
|
||||
curl https://api.tailscale.com/api/v2/tailnet/alice@gmail.com/...
|
||||
```
|
||||
|
||||
Tailnets are a top-level resource. ACL is an example of a resource that is tied to a top-level tailnet.
|
||||
Alternatively, you can specify the value "-" to refer to the default tailnet of
|
||||
the authenticated user making the API call. For example:
|
||||
```
|
||||
GET /api/v2/tailnet/-/...
|
||||
curl https://api.tailscale.com/api/v2/tailnet/-/...
|
||||
```
|
||||
|
||||
For more information on Tailscale networks/tailnets, click [here](https://tailscale.com/kb/1064/invite-team-members).
|
||||
Tailnets are a top-level resource. ACL is an example of a resource that is tied to a top-level tailnet.
|
||||
|
||||
### ACL
|
||||
|
||||
@@ -813,6 +819,10 @@ Supply the tailnet in the path.
|
||||
|
||||
###### POST Body
|
||||
`capabilities` - A mapping of resources to permissible actions.
|
||||
|
||||
`expirySeconds` - (Optional) How long the key is valid for in seconds.
|
||||
Defaults to 90d.
|
||||
|
||||
```
|
||||
{
|
||||
"capabilities": {
|
||||
@@ -826,7 +836,8 @@ Supply the tailnet in the path.
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expirySeconds": 1440
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
package atomicfile // import "tailscale.com/atomicfile"
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -18,7 +17,7 @@ import (
|
||||
// WriteFile writes data to filename+some suffix, then renames it
|
||||
// into filename. The perm argument is ignored on Windows.
|
||||
func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
|
||||
f, err := ioutil.TempFile(filepath.Dir(filename), filepath.Base(filename)+".tmp")
|
||||
f, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ while [ "$#" -gt 1 ]; do
|
||||
--extra-small)
|
||||
shift
|
||||
ldflags="$ldflags -w -s"
|
||||
tags="${tags:+$tags,}ts_omit_aws"
|
||||
tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap"
|
||||
;;
|
||||
--box)
|
||||
shift
|
||||
|
||||
@@ -35,14 +35,14 @@ BASE="${BASE:-${DEFAULT_BASE}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="\
|
||||
tailscale.com/cmd/tailscale:/usr/local/bin/tailscale, \
|
||||
tailscale.com/cmd/tailscaled:/usr/local/bin/tailscaled" \
|
||||
tailscale.com/cmd/tailscaled:/usr/local/bin/tailscaled, \
|
||||
tailscale.com/cmd/containerboot:/usr/local/bin/containerboot" \
|
||||
--ldflags="\
|
||||
-X tailscale.com/version.Long=${VERSION_LONG} \
|
||||
-X tailscale.com/version.Short=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.GitCommit=${VERSION_GIT_HASH}" \
|
||||
--files="docs/k8s/run.sh:/tailscale/run.sh" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
/bin/sh /tailscale/run.sh
|
||||
/usr/local/bin/containerboot
|
||||
|
||||
@@ -24,11 +24,17 @@ func New(socket string) (*BIRDClient, error) {
|
||||
return newWithTimeout(socket, responseTimeout)
|
||||
}
|
||||
|
||||
func newWithTimeout(socket string, timeout time.Duration) (*BIRDClient, error) {
|
||||
func newWithTimeout(socket string, timeout time.Duration) (_ *BIRDClient, err error) {
|
||||
conn, err := net.Dial("unix", socket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to BIRD: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
b := &BIRDClient{
|
||||
socket: socket,
|
||||
conn: conn,
|
||||
|
||||
@@ -106,10 +106,10 @@ func TestChirp(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.EnableProtocol("rando"); err == nil {
|
||||
t.Fatalf("enabling %q succeded", "rando")
|
||||
t.Fatalf("enabling %q succeeded", "rando")
|
||||
}
|
||||
if err := c.DisableProtocol("rando"); err == nil {
|
||||
t.Fatalf("disabling %q succeded", "rando")
|
||||
t.Fatalf("disabling %q succeeded", "rando")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
@@ -459,7 +458,7 @@ func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (test
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("control api responsed with %d status code", resp.StatusCode)
|
||||
return nil, fmt.Errorf("control api responded with %d status code", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The test ran without fail
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package apitype contains types for the Tailscale local API and control plane API.
|
||||
// Package apitype contains types for the Tailscale LocalAPI and control plane API.
|
||||
package apitype
|
||||
|
||||
import "tailscale.com/tailcfg"
|
||||
|
||||
// LocalAPIHost is the Host header value used by the LocalAPI.
|
||||
const LocalAPIHost = "local-tailscaled.sock"
|
||||
|
||||
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
|
||||
type WhoIsResponse struct {
|
||||
Node *tailcfg.Node
|
||||
@@ -21,7 +24,7 @@ type WhoIsResponse struct {
|
||||
type FileTarget struct {
|
||||
Node *tailcfg.Node
|
||||
|
||||
// PeerAPI is the http://ip:port URL base of the node's peer API,
|
||||
// PeerAPI is the http://ip:port URL base of the node's PeerAPI,
|
||||
// without any path (not even a single slash).
|
||||
PeerAPIURL string
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ type DNSConfig struct {
|
||||
Domains []string `json:"domains"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
Proxied bool `json:"proxied"`
|
||||
PerDomain bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
type DNSResolver struct {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
@@ -37,13 +35,14 @@ import (
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// defaultLocalClient is the default LocalClient when using the legacy
|
||||
// package-level functions.
|
||||
var defaultLocalClient LocalClient
|
||||
|
||||
// LocalClient is a client to Tailscale's "local API", communicating with the
|
||||
// LocalClient is a client to Tailscale's "LocalAPI", communicating with the
|
||||
// Tailscale daemon on the local machine. Its API is not necessarily stable and
|
||||
// subject to changes between releases. Some API calls have stricter
|
||||
// compatibility guarantees, once they've been widely adopted. See method docs
|
||||
@@ -137,7 +136,7 @@ func (lc *LocalClient) doLocalRequestNiceError(req *http.Request) (*http.Respons
|
||||
onVersionMismatch(ipn.IPCVersion(), server)
|
||||
}
|
||||
if res.StatusCode == 403 {
|
||||
all, _ := ioutil.ReadAll(res.Body)
|
||||
all, _ := io.ReadAll(res.Body)
|
||||
return nil, &AccessDeniedError{errors.New(errorMessageFromBody(all))}
|
||||
}
|
||||
return res, nil
|
||||
@@ -198,7 +197,10 @@ func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
|
||||
}
|
||||
|
||||
func (lc *LocalClient) send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, "http://local-tailscaled.sock"+path, body)
|
||||
if jr, ok := body.(jsonReader); ok && jr.err != nil {
|
||||
return nil, jr.err // fail early if there was a JSON marshaling error
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, "http://"+apitype.LocalAPIHost+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -207,7 +209,7 @@ func (lc *LocalClient) send(ctx context.Context, method, path string, wantStatus
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
slurp, err := ioutil.ReadAll(res.Body)
|
||||
slurp, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -229,20 +231,21 @@ func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, erro
|
||||
return defaultLocalClient.WhoIs(ctx, remoteAddr)
|
||||
}
|
||||
|
||||
func decodeJSON[T any](b []byte) (ret T, err error) {
|
||||
if err := json.Unmarshal(b, &ret); err != nil {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("failed to unmarshal JSON into %T: %w", ret, err)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||||
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := new(apitype.WhoIsResponse)
|
||||
if err := json.Unmarshal(body, r); err != nil {
|
||||
if max := 200; len(body) > max {
|
||||
body = append(body[:max], "..."...)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse JSON WhoIsResponse from %q", body)
|
||||
}
|
||||
return r, nil
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
}
|
||||
|
||||
// Goroutines returns a dump of the Tailscale daemon's current goroutines.
|
||||
@@ -256,8 +259,8 @@ func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/metrics")
|
||||
}
|
||||
|
||||
// Profile returns a pprof profile of the Tailscale daemon.
|
||||
func (lc *LocalClient) Profile(ctx context.Context, pprofType string, sec int) ([]byte, error) {
|
||||
// Pprof returns a pprof profile of the Tailscale daemon.
|
||||
func (lc *LocalClient) Pprof(ctx context.Context, pprofType string, sec int) ([]byte, error) {
|
||||
var secArg string
|
||||
if sec < 0 || sec > 300 {
|
||||
return nil, errors.New("duration out of range")
|
||||
@@ -265,18 +268,80 @@ func (lc *LocalClient) Profile(ctx context.Context, pprofType string, sec int) (
|
||||
if sec != 0 || pprofType == "profile" {
|
||||
secArg = fmt.Sprint(sec)
|
||||
}
|
||||
return lc.get200(ctx, fmt.Sprintf("/localapi/v0/profile?name=%s&seconds=%v", url.QueryEscape(pprofType), secArg))
|
||||
return lc.get200(ctx, fmt.Sprintf("/localapi/v0/pprof?name=%s&seconds=%v", url.QueryEscape(pprofType), secArg))
|
||||
}
|
||||
|
||||
// BugReport logs and returns a log marker that can be shared by the user with support.
|
||||
func (lc *LocalClient) BugReport(ctx context.Context, note string) (string, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/bugreport?note="+url.QueryEscape(note), 200, nil)
|
||||
// BugReportOpts contains options to pass to the Tailscale daemon when
|
||||
// generating a bug report.
|
||||
type BugReportOpts struct {
|
||||
// Note contains an optional user-provided note to add to the logs.
|
||||
Note string
|
||||
|
||||
// Diagnose specifies whether to print additional diagnostic information to
|
||||
// the logs when generating this bugreport.
|
||||
Diagnose bool
|
||||
|
||||
// Record specifies, if non-nil, whether to perform a bugreport
|
||||
// "recording"–generating an initial log marker, then waiting for
|
||||
// this channel to be closed before finishing the request, which
|
||||
// generates another log marker.
|
||||
Record <-chan struct{}
|
||||
}
|
||||
|
||||
// BugReportWithOpts logs and returns a log marker that can be shared by the
|
||||
// user with support.
|
||||
//
|
||||
// The opts type specifies options to pass to the Tailscale daemon when
|
||||
// generating this bug report.
|
||||
func (lc *LocalClient) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (string, error) {
|
||||
qparams := make(url.Values)
|
||||
if opts.Note != "" {
|
||||
qparams.Set("note", opts.Note)
|
||||
}
|
||||
if opts.Diagnose {
|
||||
qparams.Set("diagnose", "true")
|
||||
}
|
||||
if opts.Record != nil {
|
||||
qparams.Set("record", "true")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var requestBody io.Reader
|
||||
if opts.Record != nil {
|
||||
pr, pw := io.Pipe()
|
||||
requestBody = pr
|
||||
|
||||
// This goroutine waits for the 'Record' channel to be closed,
|
||||
// and then closes the write end of our pipe to unblock the
|
||||
// reader.
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
select {
|
||||
case <-opts.Record:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// lc.send might block if opts.Record != nil; see above.
|
||||
uri := fmt.Sprintf("/localapi/v0/bugreport?%s", qparams.Encode())
|
||||
body, err := lc.send(ctx, "POST", uri, 200, requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
// BugReport logs and returns a log marker that can be shared by the user with support.
|
||||
//
|
||||
// This is the same as calling BugReportWithOpts and only specifying the Note
|
||||
// field.
|
||||
func (lc *LocalClient) BugReport(ctx context.Context, note string) (string, error) {
|
||||
return lc.BugReportWithOpts(ctx, BugReportOpts{Note: note})
|
||||
}
|
||||
|
||||
// DebugAction invokes a debug action, such as "rebind" or "restun".
|
||||
// These are development tools and subject to change or removal over time.
|
||||
func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
|
||||
@@ -287,6 +352,41 @@ func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDevStoreKeyValue set a statestore key/value. It's only meant for development.
|
||||
// The schema (including when keys are re-read) is not a stable interface.
|
||||
func (lc *LocalClient) SetDevStoreKeyValue(ctx context.Context, key, value string) error {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/dev-set-state-store?"+(url.Values{
|
||||
"key": {key},
|
||||
"value": {value},
|
||||
}).Encode(), 200, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetComponentDebugLogging sets component's debug logging enabled for
|
||||
// the provided duration. If the duration is in the past, the debug logging
|
||||
// is disabled.
|
||||
func (lc *LocalClient) SetComponentDebugLogging(ctx context.Context, component string, d time.Duration) error {
|
||||
body, err := lc.send(ctx, "POST",
|
||||
fmt.Sprintf("/localapi/v0/component-debug-logging?component=%s&secs=%d",
|
||||
url.QueryEscape(component), int64(d.Seconds())), 200, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
var res struct {
|
||||
Error string
|
||||
}
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Error != "" {
|
||||
return errors.New(res.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the Tailscale daemon's status.
|
||||
func Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return defaultLocalClient.Status(ctx)
|
||||
@@ -312,11 +412,7 @@ func (lc *LocalClient) status(ctx context.Context, queryString string) (*ipnstat
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := new(ipnstate.Status)
|
||||
if err := json.Unmarshal(body, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
return decodeJSON[*ipnstate.Status](body)
|
||||
}
|
||||
|
||||
// IDToken is a request to get an OIDC ID token for an audience.
|
||||
@@ -327,23 +423,27 @@ func (lc *LocalClient) IDToken(ctx context.Context, aud string) (*tailcfg.TokenR
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := new(tailcfg.TokenResponse)
|
||||
if err := json.Unmarshal(body, tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tr, nil
|
||||
return decodeJSON[*tailcfg.TokenResponse](body)
|
||||
}
|
||||
|
||||
// WaitingFiles returns the list of received Taildrop files that have been
|
||||
// received by the Tailscale daemon in its staging/cache directory but not yet
|
||||
// transferred by the user's CLI or GUI client and written to a user's home
|
||||
// directory somewhere.
|
||||
func (lc *LocalClient) WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/files/")
|
||||
return lc.AwaitWaitingFiles(ctx, 0)
|
||||
}
|
||||
|
||||
// AwaitWaitingFiles is like WaitingFiles but takes a duration to await for an answer.
|
||||
// If the duration is 0, it will return immediately. The duration is respected at second
|
||||
// granularity only. If no files are available, it returns (nil, nil).
|
||||
func (lc *LocalClient) AwaitWaitingFiles(ctx context.Context, d time.Duration) ([]apitype.WaitingFile, error) {
|
||||
path := "/localapi/v0/files/?waitsec=" + fmt.Sprint(int(d.Seconds()))
|
||||
body, err := lc.get200(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wfs []apitype.WaitingFile
|
||||
if err := json.Unmarshal(body, &wfs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wfs, nil
|
||||
return decodeJSON[[]apitype.WaitingFile](body)
|
||||
}
|
||||
|
||||
func (lc *LocalClient) DeleteWaitingFile(ctx context.Context, baseName string) error {
|
||||
@@ -352,7 +452,7 @@ func (lc *LocalClient) DeleteWaitingFile(ctx context.Context, baseName string) e
|
||||
}
|
||||
|
||||
func (lc *LocalClient) GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/files/"+url.PathEscape(baseName), nil)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+apitype.LocalAPIHost+"/localapi/v0/files/"+url.PathEscape(baseName), nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -365,7 +465,7 @@ func (lc *LocalClient) GetWaitingFile(ctx context.Context, baseName string) (rc
|
||||
return nil, 0, fmt.Errorf("unexpected chunking")
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
body, _ := ioutil.ReadAll(res.Body)
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
return nil, 0, fmt.Errorf("HTTP %s: %s", res.Status, body)
|
||||
}
|
||||
@@ -377,11 +477,7 @@ func (lc *LocalClient) FileTargets(ctx context.Context) ([]apitype.FileTarget, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fts []apitype.FileTarget
|
||||
if err := json.Unmarshal(body, &fts); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
return fts, nil
|
||||
return decodeJSON[[]apitype.FileTarget](body)
|
||||
}
|
||||
|
||||
// PushFile sends Taildrop file r to target.
|
||||
@@ -389,7 +485,7 @@ func (lc *LocalClient) FileTargets(ctx context.Context) ([]apitype.FileTarget, e
|
||||
// A size of -1 means unknown.
|
||||
// The name parameter is the original filename, not escaped.
|
||||
func (lc *LocalClient) PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", "http://local-tailscaled.sock/localapi/v0/file-put/"+string(target)+"/"+url.PathEscape(name), r)
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", "http://"+apitype.LocalAPIHost+"/localapi/v0/file-put/"+string(target)+"/"+url.PathEscape(name), r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -435,11 +531,7 @@ func (lc *LocalClient) CheckIPForwarding(ctx context.Context) error {
|
||||
// Note that EditPrefs does the same validation as this, so call CheckPrefs before
|
||||
// EditPrefs is not necessary.
|
||||
func (lc *LocalClient) CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
|
||||
pj, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = lc.send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, bytes.NewReader(pj))
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, jsonBody(p))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -456,19 +548,11 @@ func (lc *LocalClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
|
||||
}
|
||||
|
||||
func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||||
mpj, err := json.Marshal(mp)
|
||||
body, err := lc.send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, jsonBody(mp))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := lc.send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, bytes.NewReader(mpj))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p ipn.Prefs
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
return decodeJSON[*ipn.Prefs](body)
|
||||
}
|
||||
|
||||
func (lc *LocalClient) Logout(ctx context.Context) error {
|
||||
@@ -512,7 +596,7 @@ func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (n
|
||||
},
|
||||
}
|
||||
ctx = httptrace.WithClientTrace(ctx, &trace)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://local-tailscaled.sock/localapi/v0/dial", nil)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/dial", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -643,14 +727,14 @@ func (lc *LocalClient) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// ExpandSNIName expands bare label name into the the most likely actual TLS cert name.
|
||||
// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
|
||||
//
|
||||
// Deprecated: use LocalClient.ExpandSNIName.
|
||||
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||||
return defaultLocalClient.ExpandSNIName(ctx, name)
|
||||
}
|
||||
|
||||
// ExpandSNIName expands bare label name into the the most likely actual TLS cert name.
|
||||
// ExpandSNIName expands bare label name into the most likely actual TLS cert name.
|
||||
func (lc *LocalClient) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||||
st, err := lc.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
@@ -674,11 +758,7 @@ func (lc *LocalClient) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
pr := new(ipnstate.PingResult)
|
||||
if err := json.Unmarshal(body, pr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pr, nil
|
||||
return decodeJSON[*ipnstate.PingResult](body)
|
||||
}
|
||||
|
||||
// NetworkLockStatus fetches information about the tailnet key authority, if one is configured.
|
||||
@@ -687,21 +767,20 @@ func (lc *LocalClient) NetworkLockStatus(ctx context.Context) (*ipnstate.Network
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: %w", err)
|
||||
}
|
||||
pr := new(ipnstate.NetworkLockStatus)
|
||||
if err := json.Unmarshal(body, pr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pr, nil
|
||||
return decodeJSON[*ipnstate.NetworkLockStatus](body)
|
||||
}
|
||||
|
||||
// NetworkLockInit initializes the tailnet key authority.
|
||||
func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key) (*ipnstate.NetworkLockStatus, error) {
|
||||
//
|
||||
// TODO(tom): Plumb through disablement secrets.
|
||||
func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key, disablementValues [][]byte) (*ipnstate.NetworkLockStatus, error) {
|
||||
var b bytes.Buffer
|
||||
type initRequest struct {
|
||||
Keys []tka.Key
|
||||
Keys []tka.Key
|
||||
DisablementValues [][]byte
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(&b).Encode(initRequest{Keys: keys}); err != nil {
|
||||
if err := json.NewEncoder(&b).Encode(initRequest{Keys: keys, DisablementValues: disablementValues}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -709,12 +788,92 @@ func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key) (*ip
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: %w", err)
|
||||
}
|
||||
return decodeJSON[*ipnstate.NetworkLockStatus](body)
|
||||
}
|
||||
|
||||
pr := new(ipnstate.NetworkLockStatus)
|
||||
if err := json.Unmarshal(body, pr); err != nil {
|
||||
// NetworkLockModify adds and/or removes key(s) to the tailnet key authority.
|
||||
func (lc *LocalClient) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) (*ipnstate.NetworkLockStatus, error) {
|
||||
var b bytes.Buffer
|
||||
type modifyRequest struct {
|
||||
AddKeys []tka.Key
|
||||
RemoveKeys []tka.Key
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(&b).Encode(modifyRequest{AddKeys: addKeys, RemoveKeys: removeKeys}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pr, nil
|
||||
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/modify", 200, &b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: %w", err)
|
||||
}
|
||||
return decodeJSON[*ipnstate.NetworkLockStatus](body)
|
||||
}
|
||||
|
||||
// NetworkLockSign signs the specified node-key and transmits that signature to the control plane.
|
||||
// rotationPublic, if specified, must be an ed25519 public key.
|
||||
func (lc *LocalClient) NetworkLockSign(ctx context.Context, nodeKey key.NodePublic, rotationPublic []byte) error {
|
||||
var b bytes.Buffer
|
||||
type signRequest struct {
|
||||
NodeKey key.NodePublic
|
||||
RotationPublic []byte
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(&b).Encode(signRequest{NodeKey: nodeKey, RotationPublic: rotationPublic}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/sign", 200, &b); err != nil {
|
||||
return fmt.Errorf("error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetworkLockLog returns up to maxEntries number of changes to network-lock state.
|
||||
func (lc *LocalClient) NetworkLockLog(ctx context.Context, maxEntries int) ([]ipnstate.NetworkLockUpdate, error) {
|
||||
v := url.Values{}
|
||||
v.Set("limit", fmt.Sprint(maxEntries))
|
||||
body, err := lc.send(ctx, "GET", "/localapi/v0/tka/log?"+v.Encode(), 200, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
return decodeJSON[[]ipnstate.NetworkLockUpdate](body)
|
||||
}
|
||||
|
||||
// SetServeConfig sets or replaces the serving settings.
|
||||
// If config is nil, settings are cleared and serving is disabled.
|
||||
func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/serve-config", 200, jsonBody(config))
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending serve config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NetworkLockDisable shuts down network-lock across the tailnet.
|
||||
func (lc *LocalClient) NetworkLockDisable(ctx context.Context, secret []byte) error {
|
||||
if _, err := lc.send(ctx, "POST", "/localapi/v0/tka/disable", 200, bytes.NewReader(secret)); err != nil {
|
||||
return fmt.Errorf("error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServeConfig return the current serve config.
|
||||
//
|
||||
// If the serve config is empty, it returns (nil, nil).
|
||||
func (lc *LocalClient) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) {
|
||||
body, err := lc.send(ctx, "GET", "/localapi/v0/serve-config", 200, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting serve config: %w", err)
|
||||
}
|
||||
return getServeConfigFromJSON(body)
|
||||
}
|
||||
|
||||
func getServeConfigFromJSON(body []byte) (sc *ipn.ServeConfig, err error) {
|
||||
if err := json.Unmarshal(body, &sc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
// tailscaledConnectHint gives a little thing about why tailscaled (or
|
||||
@@ -746,3 +905,64 @@ func tailscaledConnectHint() string {
|
||||
}
|
||||
return "not running?"
|
||||
}
|
||||
|
||||
type jsonReader struct {
|
||||
b *bytes.Reader
|
||||
err error // sticky JSON marshal error, if any
|
||||
}
|
||||
|
||||
// jsonBody returns an io.Reader that marshals v as JSON and then reads it.
|
||||
func jsonBody(v any) jsonReader {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return jsonReader{err: err}
|
||||
}
|
||||
return jsonReader{b: bytes.NewReader(b)}
|
||||
}
|
||||
|
||||
func (r jsonReader) Read(p []byte) (n int, err error) {
|
||||
if r.err != nil {
|
||||
return 0, r.err
|
||||
}
|
||||
return r.b.Read(p)
|
||||
}
|
||||
|
||||
// ProfileStatus returns the current profile and the list of all profiles.
|
||||
func (lc *LocalClient) ProfileStatus(ctx context.Context) (current ipn.LoginProfile, all []ipn.LoginProfile, err error) {
|
||||
body, err := lc.send(ctx, "GET", "/localapi/v0/profiles/current", 200, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
current, err = decodeJSON[ipn.LoginProfile](body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
body, err = lc.send(ctx, "GET", "/localapi/v0/profiles/", 200, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
all, err = decodeJSON[[]ipn.LoginProfile](body)
|
||||
return current, all, err
|
||||
}
|
||||
|
||||
// SwitchToEmptyProfile creates and switches to a new unnamed profile. The new
|
||||
// profile is not assigned an ID until it is persisted after a successful login.
|
||||
// In order to login to the new profile, the user must call LoginInteractive.
|
||||
func (lc *LocalClient) SwitchToEmptyProfile(ctx context.Context) error {
|
||||
_, err := lc.send(ctx, "PUT", "/localapi/v0/profiles/", http.StatusCreated, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// SwitchProfile switches to the given profile.
|
||||
func (lc *LocalClient) SwitchProfile(ctx context.Context, profile ipn.ProfileID) error {
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/profiles/"+url.PathEscape(string(profile)), 204, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteProfile removes the profile with the given ID.
|
||||
// If the profile is the current profile, an empty profile
|
||||
// will be selected as if SwitchToEmptyProfile was called.
|
||||
func (lc *LocalClient) DeleteProfile(ctx context.Context, profile ipn.ProfileID) error {
|
||||
_, err := lc.send(ctx, "DELETE", "/localapi/v0/profiles"+url.PathEscape(string(profile)), http.StatusNoContent, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
28
client/tailscale/localclient_test.go
Normal file
28
client/tailscale/localclient_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
sc, err := getServeConfigFromJSON([]byte("null"))
|
||||
if sc != nil {
|
||||
t.Errorf("want nil for null")
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("reading null: %v", err)
|
||||
}
|
||||
|
||||
sc, err = getServeConfigFromJSON([]byte(`{"TCP":{}}`))
|
||||
if err != nil {
|
||||
t.Errorf("reading object: %v", err)
|
||||
} else if sc == nil {
|
||||
t.Errorf("want non-nil for object")
|
||||
} else if sc.TCP == nil {
|
||||
t.Errorf("want non-nil TCP for object")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !go1.19
|
||||
// +build !go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
// +build go1.19
|
||||
|
||||
package tailscale
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
// +build go1.19
|
||||
|
||||
// Package tailscale contains Go clients for the Tailscale Local API and
|
||||
// Package tailscale contains Go clients for the Tailscale LocalAPI and
|
||||
// Tailscale control plane API.
|
||||
//
|
||||
// Warning: this package is in development and makes no API compatibility
|
||||
@@ -17,7 +16,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -116,7 +114,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
return c.httpClient().Do(req)
|
||||
}
|
||||
|
||||
// sendRequest add the authenication key to the request and sends it. It
|
||||
// sendRequest add the authentication key to the request and sends it. It
|
||||
// receives the response and reads up to 10MB of it.
|
||||
func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
|
||||
if !I_Acknowledge_This_API_Is_Unstable {
|
||||
@@ -131,7 +129,7 @@ func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error)
|
||||
|
||||
// Read response. Limit the response to 10MB.
|
||||
body := io.LimitReader(resp.Body, maxReadSize+1)
|
||||
b, err := ioutil.ReadAll(body)
|
||||
b, err := io.ReadAll(body)
|
||||
if len(b) > maxReadSize {
|
||||
err = errors.New("API response too large")
|
||||
}
|
||||
|
||||
200
cmd/containerboot/kube.go
Normal file
200
cmd/containerboot/kube.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", kubeNamespace, secretName), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := doKubeRequest(ctx, req)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
||||
// Kube secret doesn't exist yet, can't have an authkey.
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bs, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// We use a map[string]any here rather than import corev1.Secret,
|
||||
// because we only do very limited things to the secret, and
|
||||
// importing corev1 adds 12MiB to the compiled binary.
|
||||
var s map[string]any
|
||||
if err := json.Unmarshal(bs, &s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if d, ok := s["data"].(map[string]any); ok {
|
||||
if v, ok := d["authkey"].(string); ok {
|
||||
bs, err := base64.StdEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bs), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// storeDeviceID writes deviceID into the "device_id" data field of
|
||||
// the kube secret secretName.
|
||||
func storeDeviceID(ctx context.Context, secretName, deviceID string) error {
|
||||
// First check if the secret exists at all. Even if running on
|
||||
// kubernetes, we do not necessarily store state in a k8s secret.
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s", kubeNamespace, secretName), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := doKubeRequest(ctx, req)
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode >= 400 && resp.StatusCode <= 499 {
|
||||
// Assume the secret doesn't exist, or we don't have
|
||||
// permission to access it.
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
m := map[string]map[string]string{
|
||||
"stringData": map[string]string{
|
||||
"device_id": deviceID,
|
||||
},
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(m); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err = http.NewRequest("PATCH", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s?fieldManager=tailscale-container", kubeNamespace, secretName), &b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/strategic-merge-patch+json")
|
||||
if _, err := doKubeRequest(ctx, req); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteAuthKey deletes the 'authkey' field of the given kube
|
||||
// secret. No-op if there is no authkey in the secret.
|
||||
func deleteAuthKey(ctx context.Context, secretName string) error {
|
||||
// m is a JSON Patch data structure, see https://jsonpatch.com/ or RFC 6902.
|
||||
m := []struct {
|
||||
Op string `json:"op"`
|
||||
Path string `json:"path"`
|
||||
}{
|
||||
{
|
||||
Op: "remove",
|
||||
Path: "/data/authkey",
|
||||
},
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(m); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest("PATCH", fmt.Sprintf("/api/v1/namespaces/%s/secrets/%s?fieldManager=tailscale-container", kubeNamespace, secretName), &b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
||||
if resp, err := doKubeRequest(ctx, req); err != nil {
|
||||
if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity {
|
||||
// This is kubernetes-ese for "the field you asked to
|
||||
// delete already doesn't exist", aka no-op.
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
kubeHost string
|
||||
kubeNamespace string
|
||||
kubeToken string
|
||||
kubeHTTP *http.Transport
|
||||
)
|
||||
|
||||
func initKube(root string) {
|
||||
// If running in Kubernetes, set things up so that doKubeRequest
|
||||
// can talk successfully to the kube apiserver.
|
||||
if os.Getenv("KUBERNETES_SERVICE_HOST") == "" {
|
||||
return
|
||||
}
|
||||
|
||||
kubeHost = os.Getenv("KUBERNETES_SERVICE_HOST") + ":" + os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")
|
||||
|
||||
bs, err := os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/namespace"))
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading kube namespace: %v", err)
|
||||
}
|
||||
kubeNamespace = strings.TrimSpace(string(bs))
|
||||
|
||||
bs, err = os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/token"))
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading kube token: %v", err)
|
||||
}
|
||||
kubeToken = strings.TrimSpace(string(bs))
|
||||
|
||||
bs, err = os.ReadFile(filepath.Join(root, "var/run/secrets/kubernetes.io/serviceaccount/ca.crt"))
|
||||
if err != nil {
|
||||
log.Fatalf("Error reading kube CA cert: %v", err)
|
||||
}
|
||||
cp := x509.NewCertPool()
|
||||
cp.AppendCertsFromPEM(bs)
|
||||
kubeHTTP = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: cp,
|
||||
},
|
||||
IdleConnTimeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// doKubeRequest sends r to the kube apiserver.
|
||||
func doKubeRequest(ctx context.Context, r *http.Request) (*http.Response, error) {
|
||||
if kubeHTTP == nil {
|
||||
panic("not in kubernetes")
|
||||
}
|
||||
|
||||
r.URL.Scheme = "https"
|
||||
r.URL.Host = kubeHost
|
||||
r.Header.Set("Authorization", "Bearer "+kubeToken)
|
||||
r.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := kubeHTTP.RoundTrip(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp, fmt.Errorf("got non-200 status code %d", resp.StatusCode)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
471
cmd/containerboot/main.go
Normal file
471
cmd/containerboot/main.go
Normal file
@@ -0,0 +1,471 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build linux
|
||||
|
||||
// The containerboot binary is a wrapper for starting tailscaled in a
|
||||
// container. It handles reading the desired mode of operation out of
|
||||
// environment variables, bringing up and authenticating Tailscale,
|
||||
// and any other kubernetes-specific side jobs.
|
||||
//
|
||||
// As with most container things, configuration is passed through
|
||||
// environment variables. All configuration is optional.
|
||||
//
|
||||
// - TS_AUTH_KEY: the authkey to use for login.
|
||||
// - TS_ROUTES: subnet routes to advertise.
|
||||
// - TS_DEST_IP: proxy all incoming Tailscale traffic to the given
|
||||
// destination.
|
||||
// - TS_TAILSCALED_EXTRA_ARGS: extra arguments to 'tailscaled'.
|
||||
// - TS_EXTRA_ARGS: extra arguments to 'tailscale up'.
|
||||
// - TS_USERSPACE: run with userspace networking (the default)
|
||||
// instead of kernel networking.
|
||||
// - TS_STATE_DIR: the directory in which to store tailscaled
|
||||
// state. The data should persist across container
|
||||
// restarts.
|
||||
// - TS_ACCEPT_DNS: whether to use the tailnet's DNS configuration.
|
||||
// - TS_KUBE_SECRET: the name of the Kubernetes secret in which to
|
||||
// store tailscaled state.
|
||||
// - TS_SOCKS5_SERVER: the address on which to listen for SOCKS5
|
||||
// proxying into the tailnet.
|
||||
// - TS_OUTBOUND_HTTP_PROXY_LISTEN: the address on which to listen
|
||||
// for HTTP proxying into the tailnet.
|
||||
// - TS_SOCKET: the path where the tailscaled LocalAPI socket should
|
||||
// be created.
|
||||
// - TS_AUTH_ONCE: if true, only attempt to log in if not already
|
||||
// logged in. If false (the default, for backwards
|
||||
// compatibility), forcibly log in every time the
|
||||
// container starts.
|
||||
//
|
||||
// When running on Kubernetes, TS_KUBE_SECRET takes precedence over
|
||||
// TS_STATE_DIR. Additionally, if TS_AUTH_KEY is not provided and the
|
||||
// TS_KUBE_SECRET contains an "authkey" field, that key is used.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetPrefix("boot: ")
|
||||
tailscale.I_Acknowledge_This_API_Is_Unstable = true
|
||||
|
||||
cfg := &settings{
|
||||
AuthKey: defaultEnv("TS_AUTH_KEY", ""),
|
||||
Routes: defaultEnv("TS_ROUTES", ""),
|
||||
ProxyTo: defaultEnv("TS_DEST_IP", ""),
|
||||
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
|
||||
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
|
||||
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
|
||||
UserspaceMode: defaultBool("TS_USERSPACE", true),
|
||||
StateDir: defaultEnv("TS_STATE_DIR", ""),
|
||||
AcceptDNS: defaultBool("TS_ACCEPT_DNS", false),
|
||||
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
|
||||
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
|
||||
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
|
||||
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
|
||||
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
|
||||
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
|
||||
}
|
||||
|
||||
if cfg.ProxyTo != "" && cfg.UserspaceMode {
|
||||
log.Fatal("TS_DEST_IP is not supported with TS_USERSPACE")
|
||||
}
|
||||
|
||||
if !cfg.UserspaceMode {
|
||||
if err := ensureTunFile(cfg.Root); err != nil {
|
||||
log.Fatalf("Unable to create tuntap device file: %v", err)
|
||||
}
|
||||
if cfg.ProxyTo != "" || cfg.Routes != "" {
|
||||
if err := ensureIPForwarding(cfg.Root, cfg.ProxyTo, 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 {
|
||||
log.Fatalf("You can either set the sysctls as a privileged initContainer, or run the tailscale container with privileged=true.")
|
||||
} else {
|
||||
log.Fatalf("You can fix this by running the container with privileged=true, or the equivalent in your container runtime that permits access to sysctls.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.AuthKey == "" {
|
||||
key, err := findKeyInKubeSecret(ctx, cfg.KubeSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("Getting authkey from kube secret: %v", err)
|
||||
}
|
||||
if key != "" {
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
st, daemonPid, err := startAndAuthTailscaled(ctx, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to bring up tailscale: %v", err)
|
||||
}
|
||||
|
||||
if cfg.ProxyTo != "" {
|
||||
if err := installIPTablesRule(ctx, cfg.ProxyTo, st.TailscaleIPs); err != nil {
|
||||
log.Fatalf("installing proxy rules: %v", err)
|
||||
}
|
||||
}
|
||||
if cfg.InKubernetes && cfg.KubeSecret != "" {
|
||||
if err := storeDeviceID(ctx, cfg.KubeSecret, string(st.Self.ID)); err != nil {
|
||||
log.Fatalf("storing device ID in kube secret: %v", err)
|
||||
}
|
||||
if cfg.AuthOnce {
|
||||
// We were told to only auth once, so any secret-bound
|
||||
// authkey is no longer needed. We don't strictly need to
|
||||
// wipe it, but it's good hygiene.
|
||||
log.Printf("Deleting authkey from kube secret")
|
||||
if err := deleteAuthKey(ctx, cfg.KubeSecret); err != nil {
|
||||
log.Fatalf("deleting authkey from kube secret: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Startup complete, waiting for shutdown signal")
|
||||
// Reap all processes, since we are PID1 and need to collect
|
||||
// zombies.
|
||||
for {
|
||||
var status unix.WaitStatus
|
||||
pid, err := unix.Wait4(-1, &status, 0, nil)
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("Waiting for exited processes: %v", err)
|
||||
}
|
||||
if pid == daemonPid {
|
||||
log.Printf("Tailscaled exited")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startAndAuthTailscaled starts the tailscale daemon and attempts to
|
||||
// auth it, according to the settings in cfg. If successful, returns
|
||||
// tailscaled's Status and pid.
|
||||
func startAndAuthTailscaled(ctx context.Context, cfg *settings) (*ipnstate.Status, int, error) {
|
||||
args := tailscaledArgs(cfg)
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, unix.SIGTERM, unix.SIGINT)
|
||||
// tailscaled runs without context, since it needs to persist
|
||||
// beyond the startup timeout in ctx.
|
||||
cmd := exec.Command("tailscaled", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
log.Printf("Starting tailscaled")
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, 0, fmt.Errorf("starting tailscaled failed: %v", err)
|
||||
}
|
||||
go func() {
|
||||
<-sigCh
|
||||
log.Printf("Received SIGTERM from container runtime, shutting down tailscaled")
|
||||
cmd.Process.Signal(unix.SIGTERM)
|
||||
}()
|
||||
|
||||
// Wait for the socket file to appear, otherwise 'tailscale up'
|
||||
// can fail.
|
||||
log.Printf("Waiting for tailscaled socket")
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
log.Fatalf("Timed out waiting for tailscaled socket")
|
||||
}
|
||||
_, err := os.Stat(cfg.Socket)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
} else if err != nil {
|
||||
log.Fatalf("Waiting for tailscaled socket: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
didLogin := false
|
||||
if !cfg.AuthOnce {
|
||||
if err := tailscaleUp(ctx, cfg); err != nil {
|
||||
return nil, 0, fmt.Errorf("couldn't log in: %v", err)
|
||||
}
|
||||
didLogin = true
|
||||
}
|
||||
|
||||
tsClient := tailscale.LocalClient{
|
||||
Socket: cfg.Socket,
|
||||
UseSocketOnly: true,
|
||||
}
|
||||
|
||||
// Poll for daemon state until it goes to either Running or
|
||||
// NeedsLogin. The latter only happens if cfg.AuthOnce is true,
|
||||
// because in that case we only try to auth when it's necessary to
|
||||
// reach the running state.
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil, 0, ctx.Err()
|
||||
}
|
||||
|
||||
loopCtx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
st, err := tsClient.Status(loopCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("Getting tailscaled state: %w", err)
|
||||
}
|
||||
|
||||
switch st.BackendState {
|
||||
case "Running":
|
||||
if len(st.TailscaleIPs) > 0 {
|
||||
return st, cmd.Process.Pid, nil
|
||||
}
|
||||
log.Printf("No Tailscale IPs assigned yet")
|
||||
case "NeedsLogin":
|
||||
if !didLogin {
|
||||
// Alas, we cannot currently trigger an authkey login from
|
||||
// LocalAPI, so we still have to shell out to the
|
||||
// tailscale CLI for this bit.
|
||||
if err := tailscaleUp(ctx, cfg); err != nil {
|
||||
return nil, 0, fmt.Errorf("couldn't log in: %v", err)
|
||||
}
|
||||
didLogin = true
|
||||
}
|
||||
default:
|
||||
log.Printf("tailscaled in state %q, waiting", st.BackendState)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// tailscaledArgs uses cfg to construct the argv for tailscaled.
|
||||
func tailscaledArgs(cfg *settings) []string {
|
||||
args := []string{"--socket=" + cfg.Socket}
|
||||
switch {
|
||||
case cfg.InKubernetes && cfg.KubeSecret != "":
|
||||
args = append(args, "--state=kube:"+cfg.KubeSecret, "--statedir=/tmp")
|
||||
case cfg.StateDir != "":
|
||||
args = append(args, "--state="+cfg.StateDir)
|
||||
default:
|
||||
args = append(args, "--state=mem:", "--statedir=/tmp")
|
||||
}
|
||||
|
||||
if cfg.UserspaceMode {
|
||||
args = append(args, "--tun=userspace-networking")
|
||||
} else if err := ensureTunFile(cfg.Root); err != nil {
|
||||
log.Fatalf("ensuring that /dev/net/tun exists: %v", err)
|
||||
}
|
||||
|
||||
if cfg.SOCKSProxyAddr != "" {
|
||||
args = append(args, "--socks5-server="+cfg.SOCKSProxyAddr)
|
||||
}
|
||||
if cfg.HTTPProxyAddr != "" {
|
||||
args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr)
|
||||
}
|
||||
if cfg.DaemonExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// tailscaleUp uses cfg to run 'tailscale up'.
|
||||
func tailscaleUp(ctx context.Context, cfg *settings) error {
|
||||
args := []string{"--socket=" + cfg.Socket, "up"}
|
||||
if cfg.AcceptDNS {
|
||||
args = append(args, "--accept-dns=true")
|
||||
} else {
|
||||
args = append(args, "--accept-dns=false")
|
||||
}
|
||||
if cfg.AuthKey != "" {
|
||||
args = append(args, "--authkey="+cfg.AuthKey)
|
||||
}
|
||||
if cfg.Routes != "" {
|
||||
args = append(args, "--advertise-routes="+cfg.Routes)
|
||||
}
|
||||
if cfg.ExtraArgs != "" {
|
||||
args = append(args, strings.Fields(cfg.ExtraArgs)...)
|
||||
}
|
||||
log.Printf("Running 'tailscale up'")
|
||||
cmd := exec.CommandContext(ctx, "tailscale", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("tailscale up failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTunFile checks that /dev/net/tun exists, creating it if
|
||||
// missing.
|
||||
func ensureTunFile(root string) error {
|
||||
// Verify that /dev/net/tun exists, in some container envs it
|
||||
// needs to be mknod-ed.
|
||||
if _, err := os.Stat(filepath.Join(root, "dev/net")); errors.Is(err, fs.ErrNotExist) {
|
||||
if err := os.MkdirAll(filepath.Join(root, "dev/net"), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "dev/net/tun")); errors.Is(err, fs.ErrNotExist) {
|
||||
dev := unix.Mkdev(10, 200) // tuntap major and minor
|
||||
if err := unix.Mknod(filepath.Join(root, "dev/net/tun"), 0600|unix.S_IFCHR, int(dev)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureIPForwarding enables IPv4/IPv6 forwarding for the container.
|
||||
func ensureIPForwarding(root, proxyTo, routes string) error {
|
||||
var (
|
||||
v4Forwarding, v6Forwarding bool
|
||||
)
|
||||
if proxyTo != "" {
|
||||
proxyIP, err := netip.ParseAddr(proxyTo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy destination IP: %v", err)
|
||||
}
|
||||
if proxyIP.Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
if routes != "" {
|
||||
for _, route := range strings.Split(routes, ",") {
|
||||
cidr, err := netip.ParsePrefix(route)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid subnet route: %v", err)
|
||||
}
|
||||
if cidr.Addr().Is4() {
|
||||
v4Forwarding = true
|
||||
} else {
|
||||
v6Forwarding = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var paths []string
|
||||
if v4Forwarding {
|
||||
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv4/ip_forward"))
|
||||
}
|
||||
if v6Forwarding {
|
||||
paths = append(paths, filepath.Join(root, "proc/sys/net/ipv6/conf/all/forwarding"))
|
||||
}
|
||||
|
||||
// In some common configurations (e.g. default docker,
|
||||
// kubernetes), the container environment denies write access to
|
||||
// most sysctls, including IP forwarding controls. Check the
|
||||
// sysctl values before trying to change them, so that we
|
||||
// gracefully do nothing if the container's already been set up
|
||||
// properly by e.g. a k8s initContainer.
|
||||
for _, path := range paths {
|
||||
bs, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %q: %w", path, err)
|
||||
}
|
||||
if v := strings.TrimSpace(string(bs)); v != "1" {
|
||||
if err := os.WriteFile(path, []byte("1"), 0644); err != nil {
|
||||
return fmt.Errorf("enabling %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installIPTablesRule(ctx context.Context, dstStr string, tsIPs []netip.Addr) error {
|
||||
dst, err := netip.ParseAddr(dstStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
argv0 := "iptables"
|
||||
if dst.Is6() {
|
||||
argv0 = "ip6tables"
|
||||
}
|
||||
var local string
|
||||
for _, ip := range tsIPs {
|
||||
if ip.Is4() != dst.Is4() {
|
||||
continue
|
||||
}
|
||||
local = ip.String()
|
||||
break
|
||||
}
|
||||
if local == "" {
|
||||
return fmt.Errorf("no tailscale IP matching family of %s found in %v", dstStr, tsIPs)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, argv0, "-t", "nat", "-I", "PREROUTING", "1", "-d", local, "-j", "DNAT", "--to-destination", dstStr)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("executing iptables failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// settings is all the configuration for containerboot.
|
||||
type settings struct {
|
||||
AuthKey string
|
||||
Routes string
|
||||
ProxyTo 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
|
||||
}
|
||||
|
||||
// defaultEnv returns the value of the given envvar name, or defVal if
|
||||
// unset.
|
||||
func defaultEnv(name, defVal string) string {
|
||||
if v := os.Getenv(name); v != "" {
|
||||
return v
|
||||
}
|
||||
return defVal
|
||||
}
|
||||
|
||||
// defaultBool returns the boolean value of the given envvar name, or
|
||||
// defVal if unset or not a bool.
|
||||
func defaultBool(name string, defVal bool) bool {
|
||||
v := os.Getenv(name)
|
||||
ret, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return defVal
|
||||
}
|
||||
return ret
|
||||
}
|
||||
744
cmd/containerboot/main_test.go
Normal file
744
cmd/containerboot/main_test.go
Normal file
@@ -0,0 +1,744 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"golang.org/x/sys/unix"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func TestContainerBoot(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
|
||||
lapi := localAPI{FSRoot: d}
|
||||
if err := lapi.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer lapi.Close()
|
||||
|
||||
kube := kubeServer{FSRoot: d}
|
||||
if err := kube.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer kube.Close()
|
||||
|
||||
dirs := []string{
|
||||
"var/lib",
|
||||
"usr/bin",
|
||||
"tmp",
|
||||
"dev/net",
|
||||
"proc/sys/net/ipv4",
|
||||
"proc/sys/net/ipv6/conf/all",
|
||||
}
|
||||
for _, path := range dirs {
|
||||
if err := os.MkdirAll(filepath.Join(d, path), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
files := map[string][]byte{
|
||||
"usr/bin/tailscaled": fakeTailscaled,
|
||||
"usr/bin/tailscale": fakeTailscale,
|
||||
"usr/bin/iptables": fakeTailscale,
|
||||
"usr/bin/ip6tables": fakeTailscale,
|
||||
"dev/net/tun": []byte(""),
|
||||
"proc/sys/net/ipv4/ip_forward": []byte("0"),
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": []byte("0"),
|
||||
}
|
||||
resetFiles := func() {
|
||||
for path, content := range files {
|
||||
// Making everything executable is a little weird, but the
|
||||
// stuff that doesn't need to be executable doesn't care if we
|
||||
// do make it executable.
|
||||
if err := os.WriteFile(filepath.Join(d, path), content, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
resetFiles()
|
||||
|
||||
boot := filepath.Join(d, "containerboot")
|
||||
if err := exec.Command("go", "build", "-o", boot, "tailscale.com/cmd/containerboot").Run(); err != nil {
|
||||
t.Fatalf("Building containerboot: %v", err)
|
||||
}
|
||||
|
||||
argFile := filepath.Join(d, "args")
|
||||
tsIPs := []netip.Addr{netip.MustParseAddr("100.64.0.1")}
|
||||
runningSockPath := filepath.Join(d, "tmp/tailscaled.sock")
|
||||
|
||||
// TODO: refactor this 1-2 stuff if we ever need a third
|
||||
// step. Right now all of containerboot's modes either converge
|
||||
// with no further interaction needed, or with one extra step
|
||||
// only.
|
||||
tests := []struct {
|
||||
Name string
|
||||
Env map[string]string
|
||||
KubeSecret map[string]string
|
||||
WantArgs1 []string // Wait for containerboot to run these commands...
|
||||
Status1 ipnstate.Status // ... then report this status in LocalAPI.
|
||||
WantArgs2 []string // If non-nil, wait for containerboot to run these additional commands...
|
||||
Status2 ipnstate.Status // ... then report this status in LocalAPI.
|
||||
WantKubeSecret map[string]string
|
||||
WantFiles map[string]string
|
||||
}{
|
||||
{
|
||||
// Out of the box default: runs in userspace mode, ephemeral storage, interactive login.
|
||||
Name: "no_args",
|
||||
Env: nil,
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
|
||||
},
|
||||
// The tailscale up call blocks until auth is complete, so
|
||||
// by the time it returns the next converged state is
|
||||
// Running.
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Userspace mode, ephemeral storage, authkey provided on every run.
|
||||
Name: "authkey",
|
||||
Env: map[string]string{
|
||||
"TS_AUTH_KEY": "tskey-key",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authkey_disk_state",
|
||||
Env: map[string]string{
|
||||
"TS_AUTH_KEY": "tskey-key",
|
||||
"TS_STATE_DIR": filepath.Join(d, "tmp"),
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "routes",
|
||||
Env: map[string]string{
|
||||
"TS_AUTH_KEY": "tskey-key",
|
||||
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
WantFiles: map[string]string{
|
||||
"proc/sys/net/ipv4/ip_forward": "0",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "routes_kernel_ipv4",
|
||||
Env: map[string]string{
|
||||
"TS_AUTH_KEY": "tskey-key",
|
||||
"TS_ROUTES": "1.2.3.0/24,10.20.30.0/24",
|
||||
"TS_USERSPACE": "false",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=1.2.3.0/24,10.20.30.0/24",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
WantFiles: map[string]string{
|
||||
"proc/sys/net/ipv4/ip_forward": "1",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "routes_kernel_ipv6",
|
||||
Env: map[string]string{
|
||||
"TS_AUTH_KEY": "tskey-key",
|
||||
"TS_ROUTES": "::/64,1::/64",
|
||||
"TS_USERSPACE": "false",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1::/64",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
WantFiles: map[string]string{
|
||||
"proc/sys/net/ipv4/ip_forward": "0",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "routes_kernel_all_families",
|
||||
Env: map[string]string{
|
||||
"TS_AUTH_KEY": "tskey-key",
|
||||
"TS_ROUTES": "::/64,1.2.3.0/24",
|
||||
"TS_USERSPACE": "false",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key --advertise-routes=::/64,1.2.3.0/24",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
WantFiles: map[string]string{
|
||||
"proc/sys/net/ipv4/ip_forward": "1",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "proxy",
|
||||
Env: map[string]string{
|
||||
"TS_AUTH_KEY": "tskey-key",
|
||||
"TS_DEST_IP": "1.2.3.4",
|
||||
"TS_USERSPACE": "false",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
WantArgs2: []string{
|
||||
"/usr/bin/iptables -t nat -I PREROUTING 1 -d 100.64.0.1 -j DNAT --to-destination 1.2.3.4",
|
||||
},
|
||||
Status2: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authkey_once",
|
||||
Env: map[string]string{
|
||||
"TS_AUTH_KEY": "tskey-key",
|
||||
"TS_AUTH_ONCE": "true",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "NeedsLogin",
|
||||
},
|
||||
WantArgs2: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
Status2: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "kube_storage",
|
||||
Env: map[string]string{
|
||||
"KUBERNETES_SERVICE_HOST": kube.Host,
|
||||
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
|
||||
},
|
||||
KubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
Self: &ipnstate.PeerStatus{
|
||||
ID: tailcfg.StableNodeID("myID"),
|
||||
},
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
"device_id": "myID",
|
||||
},
|
||||
},
|
||||
{
|
||||
// Same as previous, but deletes the authkey from the kube secret.
|
||||
Name: "kube_storage_auth_once",
|
||||
Env: map[string]string{
|
||||
"KUBERNETES_SERVICE_HOST": kube.Host,
|
||||
"KUBERNETES_SERVICE_PORT_HTTPS": kube.Port,
|
||||
"TS_AUTH_ONCE": "true",
|
||||
},
|
||||
KubeSecret: map[string]string{
|
||||
"authkey": "tskey-key",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=kube:tailscale --statedir=/tmp --tun=userspace-networking",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "NeedsLogin",
|
||||
},
|
||||
WantArgs2: []string{
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
|
||||
},
|
||||
Status2: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
Self: &ipnstate.PeerStatus{
|
||||
ID: tailcfg.StableNodeID("myID"),
|
||||
},
|
||||
},
|
||||
WantKubeSecret: map[string]string{
|
||||
"device_id": "myID",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "proxies",
|
||||
Env: map[string]string{
|
||||
"TS_SOCKS5_SERVER": "localhost:1080",
|
||||
"TS_OUTBOUND_HTTP_PROXY_LISTEN": "localhost:8080",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --socks5-server=localhost:1080 --outbound-http-proxy-listen=localhost:8080",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false",
|
||||
},
|
||||
// The tailscale up call blocks until auth is complete, so
|
||||
// by the time it returns the next converged state is
|
||||
// Running.
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "dns",
|
||||
Env: map[string]string{
|
||||
"TS_ACCEPT_DNS": "true",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=true",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "extra_args",
|
||||
Env: map[string]string{
|
||||
"TS_EXTRA_ARGS": "--widget=rotated",
|
||||
"TS_TAILSCALED_EXTRA_ARGS": "--experiments=widgets",
|
||||
},
|
||||
WantArgs1: []string{
|
||||
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --experiments=widgets",
|
||||
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --widget=rotated",
|
||||
},
|
||||
Status1: ipnstate.Status{
|
||||
BackendState: "Running",
|
||||
TailscaleIPs: tsIPs,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
lapi.Reset()
|
||||
kube.Reset()
|
||||
os.Remove(argFile)
|
||||
os.Remove(runningSockPath)
|
||||
resetFiles()
|
||||
|
||||
for k, v := range test.KubeSecret {
|
||||
kube.SetSecret(k, v)
|
||||
}
|
||||
|
||||
cmd := exec.Command(boot)
|
||||
cmd.Env = []string{
|
||||
fmt.Sprintf("PATH=%s/usr/bin:%s", d, os.Getenv("PATH")),
|
||||
fmt.Sprintf("TS_TEST_RECORD_ARGS=%s", argFile),
|
||||
fmt.Sprintf("TS_TEST_SOCKET=%s", lapi.Path),
|
||||
fmt.Sprintf("TS_SOCKET=%s", runningSockPath),
|
||||
fmt.Sprintf("TS_TEST_ONLY_ROOT=%s", d),
|
||||
}
|
||||
for k, v := range test.Env {
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
cbOut := &lockingBuffer{}
|
||||
defer func() {
|
||||
if t.Failed() {
|
||||
t.Logf("containerboot output:\n%s", cbOut.String())
|
||||
}
|
||||
}()
|
||||
cmd.Stderr = cbOut
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("starting containerboot: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
cmd.Process.Signal(unix.SIGTERM)
|
||||
cmd.Process.Wait()
|
||||
}()
|
||||
|
||||
waitArgs(t, 2*time.Second, d, argFile, strings.Join(test.WantArgs1, "\n"))
|
||||
lapi.SetStatus(test.Status1)
|
||||
if test.WantArgs2 != nil {
|
||||
waitArgs(t, 2*time.Second, d, argFile, strings.Join(append(test.WantArgs1, test.WantArgs2...), "\n"))
|
||||
lapi.SetStatus(test.Status2)
|
||||
}
|
||||
waitLogLine(t, 2*time.Second, cbOut, "Startup complete, waiting for shutdown signal")
|
||||
|
||||
if test.WantKubeSecret != nil {
|
||||
got := kube.Secret()
|
||||
if diff := cmp.Diff(got, test.WantKubeSecret); diff != "" {
|
||||
t.Fatalf("unexpected kube secret data (-got+want):\n%s", diff)
|
||||
}
|
||||
} else {
|
||||
got := kube.Secret()
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("kube secret unexpectedly not empty, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
for path, want := range test.WantFiles {
|
||||
gotBs, err := os.ReadFile(filepath.Join(d, path))
|
||||
if err != nil {
|
||||
t.Fatalf("reading wanted file %q: %v", path, err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(gotBs)); got != want {
|
||||
t.Errorf("wrong file contents for %q, got %q want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type lockingBuffer struct {
|
||||
sync.Mutex
|
||||
b bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *lockingBuffer) Write(bs []byte) (int, error) {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return b.b.Write(bs)
|
||||
}
|
||||
|
||||
func (b *lockingBuffer) String() string {
|
||||
b.Lock()
|
||||
defer b.Unlock()
|
||||
return b.b.String()
|
||||
}
|
||||
|
||||
// waitLogLine looks for want in the contents of b.
|
||||
//
|
||||
// Only lines starting with 'boot: ' (the output of containerboot
|
||||
// itself) are considered, and the logged timestamp is ignored.
|
||||
//
|
||||
// waitLogLine fails the entire test if path doesn't contain want
|
||||
// before the timeout.
|
||||
func waitLogLine(t *testing.T, timeout time.Duration, b *lockingBuffer, want string) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
for _, line := range strings.Split(b.String(), "\n") {
|
||||
if !strings.HasPrefix(line, "boot: ") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(line, " "+want) {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for wanted output line %q. Output:\n%s", want, b.String())
|
||||
}
|
||||
|
||||
// waitArgs waits until the contents of path matches wantArgs, a set
|
||||
// of command lines recorded by test_tailscale.sh and
|
||||
// test_tailscaled.sh.
|
||||
//
|
||||
// All occurrences of removeStr are removed from the file prior to
|
||||
// comparison. This is used to remove the varying temporary root
|
||||
// directory name from recorded commandlines, so that wantArgs can be
|
||||
// a constant value.
|
||||
//
|
||||
// waitArgs fails the entire test if path doesn't contain wantArgs
|
||||
// before the timeout.
|
||||
func waitArgs(t *testing.T, timeout time.Duration, removeStr, path, wantArgs string) {
|
||||
t.Helper()
|
||||
wantArgs = strings.TrimSpace(wantArgs)
|
||||
deadline := time.Now().Add(timeout)
|
||||
var got string
|
||||
for time.Now().Before(deadline) {
|
||||
bs, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// Don't bother logging that the file doesn't exist, it
|
||||
// should start existing soon.
|
||||
goto loop
|
||||
} else if err != nil {
|
||||
t.Logf("reading %q: %v", path, err)
|
||||
goto loop
|
||||
}
|
||||
got = strings.TrimSpace(string(bs))
|
||||
got = strings.ReplaceAll(got, removeStr, "")
|
||||
if got == wantArgs {
|
||||
return
|
||||
}
|
||||
loop:
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("waiting for args file %q to have expected output, got:\n%s\n\nWant: %s", path, got, wantArgs)
|
||||
}
|
||||
|
||||
//go:embed test_tailscaled.sh
|
||||
var fakeTailscaled []byte
|
||||
|
||||
//go:embed test_tailscale.sh
|
||||
var fakeTailscale []byte
|
||||
|
||||
// localAPI is a minimal fake tailscaled LocalAPI server that presents
|
||||
// just enough functionality for containerboot to function
|
||||
// correctly. In practice this means it only supports querying
|
||||
// tailscaled status, and panics on all other uses to make it very
|
||||
// obvious that something unexpected happened.
|
||||
type localAPI struct {
|
||||
FSRoot string
|
||||
Path string // populated by Start
|
||||
|
||||
srv *http.Server
|
||||
|
||||
sync.Mutex
|
||||
status ipnstate.Status
|
||||
}
|
||||
|
||||
func (l *localAPI) Start() error {
|
||||
path := filepath.Join(l.FSRoot, "tmp/tailscaled.sock.fake")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.srv = &http.Server{
|
||||
Handler: l,
|
||||
}
|
||||
l.Path = path
|
||||
go l.srv.Serve(ln)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *localAPI) Close() {
|
||||
l.srv.Close()
|
||||
}
|
||||
|
||||
func (l *localAPI) Reset() {
|
||||
l.SetStatus(ipnstate.Status{
|
||||
BackendState: "NoState",
|
||||
})
|
||||
}
|
||||
|
||||
func (l *localAPI) SetStatus(st ipnstate.Status) {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
l.status = st
|
||||
}
|
||||
|
||||
func (l *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
panic(fmt.Sprintf("unsupported method %q", r.Method))
|
||||
}
|
||||
if r.URL.Path != "/localapi/v0/status" {
|
||||
panic(fmt.Sprintf("unsupported localAPI path %q", r.URL.Path))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
if err := json.NewEncoder(w).Encode(l.status); err != nil {
|
||||
panic("json encode failed")
|
||||
}
|
||||
}
|
||||
|
||||
// kubeServer is a minimal fake Kubernetes server that presents just
|
||||
// enough functionality for containerboot to function correctly. In
|
||||
// practice this means it only supports reading and modifying a single
|
||||
// kube secret, and panics on all other uses to make it very obvious
|
||||
// that something unexpected happened.
|
||||
type kubeServer struct {
|
||||
FSRoot string
|
||||
Host, Port string // populated by Start
|
||||
|
||||
srv *httptest.Server
|
||||
|
||||
sync.Mutex
|
||||
secret map[string]string
|
||||
}
|
||||
|
||||
func (k *kubeServer) Secret() map[string]string {
|
||||
k.Lock()
|
||||
defer k.Unlock()
|
||||
ret := map[string]string{}
|
||||
for k, v := range k.secret {
|
||||
ret[k] = v
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (k *kubeServer) SetSecret(key, val string) {
|
||||
k.Lock()
|
||||
defer k.Unlock()
|
||||
k.secret[key] = val
|
||||
}
|
||||
|
||||
func (k *kubeServer) Reset() {
|
||||
k.Lock()
|
||||
defer k.Unlock()
|
||||
k.secret = map[string]string{}
|
||||
}
|
||||
|
||||
func (k *kubeServer) Start() error {
|
||||
root := filepath.Join(k.FSRoot, "var/run/secrets/kubernetes.io/serviceaccount")
|
||||
|
||||
if err := os.MkdirAll(root, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "namespace"), []byte("default"), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "token"), []byte("bearer_token"), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
k.srv = httptest.NewTLSServer(k)
|
||||
k.Host = k.srv.Listener.Addr().(*net.TCPAddr).IP.String()
|
||||
k.Port = strconv.Itoa(k.srv.Listener.Addr().(*net.TCPAddr).Port)
|
||||
|
||||
var cert bytes.Buffer
|
||||
if err := pem.Encode(&cert, &pem.Block{Type: "CERTIFICATE", Bytes: k.srv.Certificate().Raw}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "ca.crt"), cert.Bytes(), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *kubeServer) Close() {
|
||||
k.srv.Close()
|
||||
}
|
||||
|
||||
func (k *kubeServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer bearer_token" {
|
||||
panic("client didn't provide bearer token in request")
|
||||
}
|
||||
if r.URL.Path != "/api/v1/namespaces/default/secrets/tailscale" {
|
||||
panic(fmt.Sprintf("unhandled fake kube api path %q", r.URL.Path))
|
||||
}
|
||||
|
||||
bs, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("reading request body: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
ret := map[string]map[string]string{
|
||||
"data": map[string]string{},
|
||||
}
|
||||
k.Lock()
|
||||
defer k.Unlock()
|
||||
for k, v := range k.secret {
|
||||
v := base64.StdEncoding.EncodeToString([]byte(v))
|
||||
if err != nil {
|
||||
panic("encode failed")
|
||||
}
|
||||
ret["data"][k] = v
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(ret); err != nil {
|
||||
panic("encode failed")
|
||||
}
|
||||
case "PATCH":
|
||||
switch r.Header.Get("Content-Type") {
|
||||
case "application/json-patch+json":
|
||||
req := []struct {
|
||||
Op string `json:"op"`
|
||||
Path string `json:"path"`
|
||||
}{}
|
||||
if err := json.Unmarshal(bs, &req); err != nil {
|
||||
panic(fmt.Sprintf("json decode failed: %v. Body:\n\n%s", err, string(bs)))
|
||||
}
|
||||
k.Lock()
|
||||
defer k.Unlock()
|
||||
for _, op := range req {
|
||||
if op.Op != "remove" {
|
||||
panic(fmt.Sprintf("unsupported json-patch op %q", op.Op))
|
||||
}
|
||||
if !strings.HasPrefix(op.Path, "/data/") {
|
||||
panic(fmt.Sprintf("unsupported json-patch path %q", op.Path))
|
||||
}
|
||||
delete(k.secret, strings.TrimPrefix(op.Path, "/data/"))
|
||||
}
|
||||
case "application/strategic-merge-patch+json":
|
||||
req := struct {
|
||||
Data map[string]string `json:"stringData"`
|
||||
}{}
|
||||
if err := json.Unmarshal(bs, &req); err != nil {
|
||||
panic(fmt.Sprintf("json decode failed: %v. Body:\n\n%s", err, string(bs)))
|
||||
}
|
||||
k.Lock()
|
||||
defer k.Unlock()
|
||||
for key, val := range req.Data {
|
||||
k.secret[key] = val
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("unknown content type %q", r.Header.Get("Content-Type")))
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("unhandled HTTP method %q", r.Method))
|
||||
}
|
||||
}
|
||||
8
cmd/containerboot/test_tailscale.sh
Normal file
8
cmd/containerboot/test_tailscale.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# This is a fake tailscale CLI (and also iptables and ip6tables) that
|
||||
# records its arguments and exits successfully.
|
||||
#
|
||||
# It is used by main_test.go to test the behavior of containerboot.
|
||||
|
||||
echo $0 $@ >>$TS_TEST_RECORD_ARGS
|
||||
37
cmd/containerboot/test_tailscaled.sh
Normal file
37
cmd/containerboot/test_tailscaled.sh
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# This is a fake tailscale CLI that records its arguments, symlinks a
|
||||
# fake LocalAPI socket into place, and does nothing until terminated.
|
||||
#
|
||||
# It is used by main_test.go to test the behavior of containerboot.
|
||||
|
||||
set -eu
|
||||
|
||||
echo $0 $@ >>$TS_TEST_RECORD_ARGS
|
||||
|
||||
socket=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--socket=*)
|
||||
socket="${1#--socket=}"
|
||||
shift
|
||||
;;
|
||||
--socket)
|
||||
shift
|
||||
socket="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$socket" ]]; then
|
||||
echo "didn't find socket path in args"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ln -s "$TS_TEST_SOCKET" "$socket"
|
||||
|
||||
while true; do sleep 1; done
|
||||
@@ -34,7 +34,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/hostinfo from tailscale.com/net/interfaces+
|
||||
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/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/netns+
|
||||
@@ -47,11 +47,12 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/tsaddr from tailscale.com/ipn+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/wsconn from tailscale.com/cmd/derper+
|
||||
tailscale.com/paths from tailscale.com/client/tailscale
|
||||
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/tka from tailscale.com/client/tailscale
|
||||
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||
W tailscale.com/tsconst from tailscale.com/net/interfaces
|
||||
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
|
||||
@@ -63,19 +64,21 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/types/logger from tailscale.com/cmd/derper+
|
||||
tailscale.com/types/netmap from tailscale.com/ipn
|
||||
tailscale.com/types/opt from tailscale.com/client/tailscale+
|
||||
tailscale.com/types/pad32 from tailscale.com/derp
|
||||
tailscale.com/types/persist from tailscale.com/ipn
|
||||
tailscale.com/types/preftype from tailscale.com/ipn
|
||||
tailscale.com/types/structs from tailscale.com/ipn+
|
||||
tailscale.com/types/tkatype from tailscale.com/types/key+
|
||||
tailscale.com/types/views from tailscale.com/ipn/ipnstate+
|
||||
W tailscale.com/util/clientmetric from tailscale.com/net/tshttpproxy
|
||||
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
|
||||
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/hostinfo+
|
||||
W tailscale.com/util/endian from tailscale.com/net/netns
|
||||
tailscale.com/util/lineread from tailscale.com/hostinfo+
|
||||
tailscale.com/util/mak from tailscale.com/syncs
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache
|
||||
L tailscale.com/util/strs from tailscale.com/hostinfo
|
||||
tailscale.com/util/strs from tailscale.com/hostinfo+
|
||||
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
tailscale.com/version/distro from tailscale.com/hostinfo+
|
||||
@@ -94,6 +97,8 @@ 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+
|
||||
golang.org/x/exp/constraints from golang.org/x/exp/slices
|
||||
golang.org/x/exp/slices from tailscale.com/net/tsaddr
|
||||
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
|
||||
@@ -107,6 +112,8 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
LD golang.org/x/sys/unix from github.com/jsimonetti/rtnetlink/internal/unix+
|
||||
W golang.org/x/sys/windows from golang.org/x/sys/windows/registry+
|
||||
W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+
|
||||
W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+
|
||||
W golang.org/x/sys/windows/svc/mgr from tailscale.com/util/winutil
|
||||
golang.org/x/text/secure/bidirule from golang.org/x/net/idna
|
||||
golang.org/x/text/transform from golang.org/x/text/secure/bidirule+
|
||||
golang.org/x/text/unicode/bidi from golang.org/x/net/idna+
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
@@ -99,7 +98,7 @@ func loadConfig() config {
|
||||
}
|
||||
log.Printf("no config path specified; using %s", *configPath)
|
||||
}
|
||||
b, err := ioutil.ReadFile(*configPath)
|
||||
b, err := os.ReadFile(*configPath)
|
||||
switch {
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return writeNewConfig()
|
||||
@@ -155,7 +154,7 @@ func main() {
|
||||
s.SetVerifyClient(*verifyClients)
|
||||
|
||||
if *meshPSKFile != "" {
|
||||
b, err := ioutil.ReadFile(*meshPSKFile)
|
||||
b, err := os.ReadFile(*meshPSKFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -326,11 +325,31 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
noContentChallengeHeader = "X-Tailscale-Challenge"
|
||||
noContentResponseHeader = "X-Tailscale-Response"
|
||||
)
|
||||
|
||||
// For captive portal detection
|
||||
func serveNoContent(w http.ResponseWriter, r *http.Request) {
|
||||
if challenge := r.Header.Get(noContentChallengeHeader); challenge != "" {
|
||||
badChar := strings.IndexFunc(challenge, func(r rune) bool {
|
||||
return !isChallengeChar(r)
|
||||
}) != -1
|
||||
if len(challenge) <= 64 && !badChar {
|
||||
w.Header().Set(noContentResponseHeader, "response "+challenge)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func isChallengeChar(c rune) bool {
|
||||
// Semi-randomly chosen as a limited set of valid characters
|
||||
return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
|
||||
('0' <= c && c <= '9') ||
|
||||
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) {
|
||||
|
||||
@@ -7,6 +7,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/net/stun"
|
||||
@@ -67,3 +70,62 @@ func BenchmarkServerSTUN(b *testing.B) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestNoContent(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no challenge",
|
||||
},
|
||||
{
|
||||
name: "valid challenge",
|
||||
input: "input",
|
||||
want: "response input",
|
||||
},
|
||||
{
|
||||
name: "valid challenge hostname",
|
||||
input: "ts_derp99b.tailscale.com",
|
||||
want: "response ts_derp99b.tailscale.com",
|
||||
},
|
||||
{
|
||||
name: "invalid challenge",
|
||||
input: "foo\x00bar",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace invalid challenge",
|
||||
input: "foo bar",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "long challenge",
|
||||
input: strings.Repeat("x", 65),
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "https://localhost/generate_204", nil)
|
||||
if tt.input != "" {
|
||||
req.Header.Set(noContentChallengeHeader, tt.input)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
serveNoContent(w, req)
|
||||
resp := w.Result()
|
||||
|
||||
if tt.want == "" {
|
||||
if h, found := resp.Header[noContentResponseHeader]; found {
|
||||
t.Errorf("got %+v; expected no response header", h)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if got := resp.Header.Get(noContentResponseHeader); got != tt.want {
|
||||
t.Errorf("got %q; want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"tailscale.com/derp/derphttp"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/strs"
|
||||
)
|
||||
|
||||
func startMesh(s *derp.Server) error {
|
||||
@@ -50,8 +51,7 @@ func startMeshWithHost(s *derp.Server, host string) error {
|
||||
}
|
||||
var d net.Dialer
|
||||
var r net.Resolver
|
||||
if port == "443" && strings.HasSuffix(host, ".tailscale.com") {
|
||||
base := strings.TrimSuffix(host, ".tailscale.com")
|
||||
if base, ok := strs.CutSuffix(host, ".tailscale.com"); ok && port == "443" {
|
||||
subCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
vpcHost := base + "-vpc.tailscale.com"
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/net/wsconn"
|
||||
)
|
||||
|
||||
var counterWebSocketAccepts = expvar.NewInt("derp_websocket_accepts")
|
||||
@@ -23,7 +24,7 @@ func addWebSocketSupport(s *derp.Server, base http.Handler) http.Handler {
|
||||
up := strings.ToLower(r.Header.Get("Upgrade"))
|
||||
|
||||
// Very early versions of Tailscale set "Upgrade: WebSocket" but didn't actually
|
||||
// speak WebSockets (they still assumed DERP's binary framining). So to distinguish
|
||||
// speak WebSockets (they still assumed DERP's binary framing). So to distinguish
|
||||
// clients that actually want WebSockets, look for an explicit "derp" subprotocol.
|
||||
if up != "websocket" || !strings.Contains(r.Header.Get("Sec-Websocket-Protocol"), "derp") {
|
||||
base.ServeHTTP(w, r)
|
||||
@@ -33,6 +34,12 @@ func addWebSocketSupport(s *derp.Server, base http.Handler) http.Handler {
|
||||
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
Subprotocols: []string{"derp"},
|
||||
OriginPatterns: []string{"*"},
|
||||
// Disable compression because we transmit WireGuard messages that
|
||||
// are not compressible.
|
||||
// Additionally, Safari has a broken implementation of compression
|
||||
// (see https://github.com/nhooyr/websocket/issues/218) that makes
|
||||
// enabling it actively harmful.
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("websocket.Accept: %v", err)
|
||||
@@ -44,7 +51,7 @@ func addWebSocketSupport(s *derp.Server, base http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
counterWebSocketAccepts.Add(1)
|
||||
wc := websocket.NetConn(r.Context(), c, websocket.MessageBinary)
|
||||
wc := wsconn.NetConn(r.Context(), c, websocket.MessageBinary)
|
||||
brw := bufio.NewReadWriter(bufio.NewReader(wc), bufio.NewWriter(wc))
|
||||
s.Accept(r.Context(), wc, brw, r.RemoteAddr)
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -106,7 +105,7 @@ func devMode() bool { return *httpsAddr == "" && *httpAddr != "" }
|
||||
|
||||
func getTmpl() (*template.Template, error) {
|
||||
if devMode() {
|
||||
tmplData, err := ioutil.ReadFile("hello.tmpl.html")
|
||||
tmplData, err := os.ReadFile("hello.tmpl.html")
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("using baked-in template in dev mode; can't find hello.tmpl.html in current directory")
|
||||
return tmpl, nil
|
||||
|
||||
52
cmd/mkmanifest/main.go
Normal file
52
cmd/mkmanifest/main.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// The mkmanifest command is a simple helper utility to create a '.syso' file
|
||||
// that contains a Windows manifest file.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/tc-hib/winres"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 4 {
|
||||
log.Fatalf("usage: %s arch manifest.xml output.syso", os.Args[0])
|
||||
}
|
||||
|
||||
arch := winres.Arch(os.Args[1])
|
||||
switch arch {
|
||||
case winres.ArchAMD64, winres.ArchARM64, winres.ArchI386, winres.ArchARM:
|
||||
default:
|
||||
log.Fatalf("unsupported arch: %s", arch)
|
||||
}
|
||||
|
||||
manifest, err := os.ReadFile(os.Args[2])
|
||||
if err != nil {
|
||||
log.Fatalf("error reading manifest file %q: %v", os.Args[2], err)
|
||||
}
|
||||
|
||||
out := os.Args[3]
|
||||
|
||||
// Start by creating an empty resource set
|
||||
rs := winres.ResourceSet{}
|
||||
|
||||
// Add resources
|
||||
rs.Set(winres.RT_MANIFEST, winres.ID(1), 0, manifest)
|
||||
|
||||
// Compile to a COFF object file
|
||||
f, err := os.Create(out)
|
||||
if err != nil {
|
||||
log.Fatalf("error creating output file %q: %v", out, err)
|
||||
}
|
||||
if err := rs.WriteObject(f, arch); err != nil {
|
||||
log.Fatalf("error writing object: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
log.Fatalf("error writing output file %q: %v", out, err)
|
||||
}
|
||||
}
|
||||
387
cmd/netlogfmt/main.go
Normal file
387
cmd/netlogfmt/main.go
Normal file
@@ -0,0 +1,387 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// netlogfmt parses a stream of JSON log messages from stdin and
|
||||
// formats the network traffic logs produced by "tailscale.com/wgengine/netlog"
|
||||
// according to the schema in "tailscale.com/types/netlogtype.Message"
|
||||
// in a more humanly readable format.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// $ cat netlog.json | go run tailscale.com/cmd/netlogfmt
|
||||
// =========================================================================================
|
||||
// NodeID: n123456CNTRL
|
||||
// Logged: 2022-10-13T20:23:10.165Z
|
||||
// Window: 2022-10-13T20:23:09.644Z (5s)
|
||||
// --------------------------------------------------- Tx[P/s] Tx[B/s] Rx[P/s] Rx[B/s]
|
||||
// VirtualTraffic: 16.80 1.64Ki 11.20 1.03Ki
|
||||
// TCP: 100.109.51.95:22 -> 100.85.80.41:42912 16.00 1.59Ki 10.40 1008.84
|
||||
// TCP: 100.109.51.95:21291 -> 100.107.177.2:53133 0.40 27.60 0.40 24.20
|
||||
// TCP: 100.109.51.95:21291 -> 100.107.177.2:53134 0.40 23.40 0.40 24.20
|
||||
// PhysicalTraffic: 16.80 2.32Ki 11.20 1.48Ki
|
||||
// 100.85.80.41 -> 192.168.0.101:41641 16.00 2.23Ki 10.40 1.40Ki
|
||||
// 100.107.177.2 -> 192.168.0.100:41641 0.80 83.20 0.80 83.20
|
||||
// =========================================================================================
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dsnet/try"
|
||||
jsonv2 "github.com/go-json-experiment/json"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/logtail"
|
||||
"tailscale.com/types/netlogtype"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
var (
|
||||
resolveNames = flag.Bool("resolve-names", false, "convert tailscale IP addresses to hostnames; must also specify --api-key and --tailnet-id")
|
||||
apiKey = flag.String("api-key", "", "API key to query the Tailscale API with; see https://login.tailscale.com/admin/settings/keys")
|
||||
tailnetName = flag.String("tailnet-name", "", "tailnet domain name to lookup devices in; see https://login.tailscale.com/admin/settings/general")
|
||||
)
|
||||
|
||||
var namesByAddr map[netip.Addr]string
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *resolveNames {
|
||||
namesByAddr = mustMakeNamesByAddr()
|
||||
}
|
||||
|
||||
// The logic handles a stream of arbitrary JSON.
|
||||
// So long as a JSON object seems like a network log message,
|
||||
// then this will unmarshal and print it.
|
||||
if err := processStream(os.Stdin); err != nil {
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
log.Fatalf("processStream: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func processStream(r io.Reader) (err error) {
|
||||
defer try.Handle(&err)
|
||||
dec := jsonv2.NewDecoder(os.Stdin)
|
||||
for {
|
||||
processValue(dec)
|
||||
}
|
||||
}
|
||||
|
||||
func processValue(dec *jsonv2.Decoder) {
|
||||
switch dec.PeekKind() {
|
||||
case '[':
|
||||
processArray(dec)
|
||||
case '{':
|
||||
processObject(dec)
|
||||
default:
|
||||
try.E(dec.SkipValue())
|
||||
}
|
||||
}
|
||||
|
||||
func processArray(dec *jsonv2.Decoder) {
|
||||
try.E1(dec.ReadToken()) // parse '['
|
||||
for dec.PeekKind() != ']' {
|
||||
processValue(dec)
|
||||
}
|
||||
try.E1(dec.ReadToken()) // parse ']'
|
||||
}
|
||||
|
||||
func processObject(dec *jsonv2.Decoder) {
|
||||
var hasTraffic bool
|
||||
var rawMsg []byte
|
||||
try.E1(dec.ReadToken()) // parse '{'
|
||||
for dec.PeekKind() != '}' {
|
||||
// Capture any members that could belong to a network log message.
|
||||
switch name := try.E1(dec.ReadToken()); name.String() {
|
||||
case "virtualTraffic", "subnetTraffic", "exitTraffic", "physicalTraffic":
|
||||
hasTraffic = true
|
||||
fallthrough
|
||||
case "logtail", "nodeId", "logged", "start", "end":
|
||||
if len(rawMsg) == 0 {
|
||||
rawMsg = append(rawMsg, '{')
|
||||
} else {
|
||||
rawMsg = append(rawMsg[:len(rawMsg)-1], ',')
|
||||
}
|
||||
rawMsg = append(append(append(rawMsg, '"'), name.String()...), '"')
|
||||
rawMsg = append(rawMsg, ':')
|
||||
rawMsg = append(rawMsg, try.E1(dec.ReadValue())...)
|
||||
rawMsg = append(rawMsg, '}')
|
||||
default:
|
||||
processValue(dec)
|
||||
}
|
||||
}
|
||||
try.E1(dec.ReadToken()) // parse '}'
|
||||
|
||||
// If this appears to be a network log message, then unmarshal and print it.
|
||||
if hasTraffic {
|
||||
var msg message
|
||||
try.E(jsonv2.Unmarshal(rawMsg, &msg))
|
||||
printMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
type message struct {
|
||||
Logtail struct {
|
||||
ID logtail.PublicID `json:"id"`
|
||||
Logged time.Time `json:"server_time"`
|
||||
} `json:"logtail"`
|
||||
Logged time.Time `json:"logged"`
|
||||
netlogtype.Message
|
||||
}
|
||||
|
||||
func printMessage(msg message) {
|
||||
// Construct a table of network traffic per connection.
|
||||
rows := [][7]string{{3: "Tx[P/s]", 4: "Tx[B/s]", 5: "Rx[P/s]", 6: "Rx[B/s]"}}
|
||||
duration := msg.End.Sub(msg.Start)
|
||||
addRows := func(heading string, traffic []netlogtype.ConnectionCounts) {
|
||||
if len(traffic) == 0 {
|
||||
return
|
||||
}
|
||||
slices.SortFunc(traffic, func(x, y netlogtype.ConnectionCounts) bool {
|
||||
nx := x.TxPackets + x.TxBytes + x.RxPackets + x.RxBytes
|
||||
ny := y.TxPackets + y.TxBytes + y.RxPackets + y.RxBytes
|
||||
return nx > ny
|
||||
})
|
||||
var sum netlogtype.Counts
|
||||
for _, cc := range traffic {
|
||||
sum = sum.Add(cc.Counts)
|
||||
}
|
||||
rows = append(rows, [7]string{
|
||||
0: heading + ":",
|
||||
3: formatSI(float64(sum.TxPackets) / duration.Seconds()),
|
||||
4: formatIEC(float64(sum.TxBytes) / duration.Seconds()),
|
||||
5: formatSI(float64(sum.RxPackets) / duration.Seconds()),
|
||||
6: formatIEC(float64(sum.RxBytes) / duration.Seconds()),
|
||||
})
|
||||
if len(traffic) == 1 && traffic[0].Connection.IsZero() {
|
||||
return // this is already a summary counts
|
||||
}
|
||||
formatAddrPort := func(a netip.AddrPort) string {
|
||||
if !a.IsValid() {
|
||||
return ""
|
||||
}
|
||||
if name, ok := namesByAddr[a.Addr()]; ok {
|
||||
if a.Port() == 0 {
|
||||
return name
|
||||
}
|
||||
return name + ":" + strconv.Itoa(int(a.Port()))
|
||||
}
|
||||
if a.Port() == 0 {
|
||||
return a.Addr().String()
|
||||
}
|
||||
return a.String()
|
||||
}
|
||||
for _, cc := range traffic {
|
||||
row := [7]string{
|
||||
0: " ",
|
||||
1: formatAddrPort(cc.Src),
|
||||
2: formatAddrPort(cc.Dst),
|
||||
3: formatSI(float64(cc.TxPackets) / duration.Seconds()),
|
||||
4: formatIEC(float64(cc.TxBytes) / duration.Seconds()),
|
||||
5: formatSI(float64(cc.RxPackets) / duration.Seconds()),
|
||||
6: formatIEC(float64(cc.RxBytes) / duration.Seconds()),
|
||||
}
|
||||
if cc.Proto > 0 {
|
||||
row[0] += cc.Proto.String() + ":"
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
addRows("VirtualTraffic", msg.VirtualTraffic)
|
||||
addRows("SubnetTraffic", msg.SubnetTraffic)
|
||||
addRows("ExitTraffic", msg.ExitTraffic)
|
||||
addRows("PhysicalTraffic", msg.PhysicalTraffic)
|
||||
|
||||
// Compute the maximum width of each field.
|
||||
var maxWidths [7]int
|
||||
for _, row := range rows {
|
||||
for i, col := range row {
|
||||
if maxWidths[i] < len(col) && !(i == 0 && !strings.HasPrefix(col, " ")) {
|
||||
maxWidths[i] = len(col)
|
||||
}
|
||||
}
|
||||
}
|
||||
var maxSum int
|
||||
for _, n := range maxWidths {
|
||||
maxSum += n
|
||||
}
|
||||
|
||||
// Output a table of network traffic per connection.
|
||||
line := make([]byte, 0, maxSum+len(" ")+len(" -> ")+4*len(" "))
|
||||
line = appendRepeatByte(line, '=', cap(line))
|
||||
fmt.Println(string(line))
|
||||
if !msg.Logtail.ID.IsZero() {
|
||||
fmt.Printf("LogID: %s\n", msg.Logtail.ID)
|
||||
}
|
||||
if msg.NodeID != "" {
|
||||
fmt.Printf("NodeID: %s\n", msg.NodeID)
|
||||
}
|
||||
formatTime := func(t time.Time) string {
|
||||
return t.In(time.Local).Format("2006-01-02 15:04:05.000")
|
||||
}
|
||||
switch {
|
||||
case !msg.Logged.IsZero():
|
||||
fmt.Printf("Logged: %s\n", formatTime(msg.Logged))
|
||||
case !msg.Logtail.Logged.IsZero():
|
||||
fmt.Printf("Logged: %s\n", formatTime(msg.Logtail.Logged))
|
||||
}
|
||||
fmt.Printf("Window: %s (%0.3fs)\n", formatTime(msg.Start), duration.Seconds())
|
||||
for i, row := range rows {
|
||||
line = line[:0]
|
||||
isHeading := !strings.HasPrefix(row[0], " ")
|
||||
for j, col := range row {
|
||||
if isHeading && j == 0 {
|
||||
col = "" // headings will be printed later
|
||||
}
|
||||
switch j {
|
||||
case 0, 2: // left justified
|
||||
line = append(line, col...)
|
||||
line = appendRepeatByte(line, ' ', maxWidths[j]-len(col))
|
||||
case 1, 3, 4, 5, 6: // right justified
|
||||
line = appendRepeatByte(line, ' ', maxWidths[j]-len(col))
|
||||
line = append(line, col...)
|
||||
}
|
||||
switch j {
|
||||
case 0:
|
||||
line = append(line, " "...)
|
||||
case 1:
|
||||
if row[1] == "" && row[2] == "" {
|
||||
line = append(line, " "...)
|
||||
} else {
|
||||
line = append(line, " -> "...)
|
||||
}
|
||||
case 2, 3, 4, 5:
|
||||
line = append(line, " "...)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case i == 0: // print dashed-line table heading
|
||||
line = appendRepeatByte(line[:0], '-', maxWidths[0]+len(" ")+maxWidths[1]+len(" -> ")+maxWidths[2])[:cap(line)]
|
||||
case isHeading:
|
||||
copy(line[:], row[0])
|
||||
}
|
||||
fmt.Println(string(line))
|
||||
}
|
||||
}
|
||||
|
||||
func mustMakeNamesByAddr() map[netip.Addr]string {
|
||||
switch {
|
||||
case *apiKey == "":
|
||||
log.Fatalf("--api-key must be specified with --resolve-names")
|
||||
case *tailnetName == "":
|
||||
log.Fatalf("--tailnet must be specified with --resolve-names")
|
||||
}
|
||||
|
||||
// Query the Tailscale API for a list of devices in the tailnet.
|
||||
const apiURL = "https://api.tailscale.com/api/v2"
|
||||
req := must.Get(http.NewRequest("GET", apiURL+"/tailnet/"+*tailnetName+"/devices", nil))
|
||||
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(*apiKey+":")))
|
||||
resp := must.Get(http.DefaultClient.Do(req))
|
||||
defer resp.Body.Close()
|
||||
b := must.Get(io.ReadAll(resp.Body))
|
||||
if resp.StatusCode != 200 {
|
||||
log.Fatalf("http: %v: %s", http.StatusText(resp.StatusCode), b)
|
||||
}
|
||||
|
||||
// Unmarshal the API response.
|
||||
var m struct {
|
||||
Devices []struct {
|
||||
Name string `json:"name"`
|
||||
Addrs []netip.Addr `json:"addresses"`
|
||||
} `json:"devices"`
|
||||
}
|
||||
must.Do(json.Unmarshal(b, &m))
|
||||
|
||||
// Construct a unique mapping of Tailscale IP addresses to hostnames.
|
||||
// For brevity, we start with the first segment of the name and
|
||||
// use more segments until we find the shortest prefix that is unique
|
||||
// for all names in the tailnet.
|
||||
seen := make(map[string]bool)
|
||||
namesByAddr := make(map[netip.Addr]string)
|
||||
retry:
|
||||
for i := 0; i < 10; i++ {
|
||||
maps.Clear(seen)
|
||||
maps.Clear(namesByAddr)
|
||||
for _, d := range m.Devices {
|
||||
name := fieldPrefix(d.Name, i)
|
||||
if seen[name] {
|
||||
continue retry
|
||||
}
|
||||
seen[name] = true
|
||||
for _, a := range d.Addrs {
|
||||
namesByAddr[a] = name
|
||||
}
|
||||
}
|
||||
return namesByAddr
|
||||
}
|
||||
panic("unable to produce unique mapping of address to names")
|
||||
}
|
||||
|
||||
// fieldPrefix returns the first n number of dot-separated segments.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// fieldPrefix("foo.bar.baz", 0) returns ""
|
||||
// fieldPrefix("foo.bar.baz", 1) returns "foo"
|
||||
// fieldPrefix("foo.bar.baz", 2) returns "foo.bar"
|
||||
// fieldPrefix("foo.bar.baz", 3) returns "foo.bar.baz"
|
||||
// fieldPrefix("foo.bar.baz", 4) returns "foo.bar.baz"
|
||||
func fieldPrefix(s string, n int) string {
|
||||
s0 := s
|
||||
for i := 0; i < n && len(s) > 0; i++ {
|
||||
if j := strings.IndexByte(s, '.'); j >= 0 {
|
||||
s = s[j+1:]
|
||||
} else {
|
||||
s = ""
|
||||
}
|
||||
}
|
||||
return strings.TrimSuffix(s0[:len(s0)-len(s)], ".")
|
||||
}
|
||||
|
||||
func appendRepeatByte(b []byte, c byte, n int) []byte {
|
||||
for i := 0; i < n; i++ {
|
||||
b = append(b, c)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func formatSI(n float64) string {
|
||||
switch n := math.Abs(n); {
|
||||
case n < 1e3:
|
||||
return fmt.Sprintf("%0.2f ", n/(1e0))
|
||||
case n < 1e6:
|
||||
return fmt.Sprintf("%0.2fk", n/(1e3))
|
||||
case n < 1e9:
|
||||
return fmt.Sprintf("%0.2fM", n/(1e6))
|
||||
default:
|
||||
return fmt.Sprintf("%0.2fG", n/(1e9))
|
||||
}
|
||||
}
|
||||
|
||||
func formatIEC(n float64) string {
|
||||
switch n := math.Abs(n); {
|
||||
case n < 1<<10:
|
||||
return fmt.Sprintf("%0.2f ", n/(1<<0))
|
||||
case n < 1<<20:
|
||||
return fmt.Sprintf("%0.2fKi", n/(1<<10))
|
||||
case n < 1<<30:
|
||||
return fmt.Sprintf("%0.2fMi", n/(1<<20))
|
||||
default:
|
||||
return fmt.Sprintf("%0.2fGi", n/(1<<30))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
# nginx-auth
|
||||
|
||||
[](https://tailscale.com/kb/1167/release-stages/#experimental)
|
||||
|
||||
This is a tool that allows users to use Tailscale Whois authentication with
|
||||
NGINX as a reverse proxy. This allows users that already have a bunch of
|
||||
services hosted on an internal NGINX server to point those domains to the
|
||||
|
||||
@@ -4,7 +4,7 @@ set -e
|
||||
|
||||
CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build -o tailscale.nginx-auth .
|
||||
|
||||
VERSION=0.1.1
|
||||
VERSION=0.1.2
|
||||
|
||||
mkpkg \
|
||||
--out=tailscale-nginx-auth-${VERSION}-amd64.deb \
|
||||
|
||||
42
cmd/pgproxy/README.md
Normal file
42
cmd/pgproxy/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# pgproxy
|
||||
|
||||
The pgproxy server is a proxy for the Postgres wire protocol. [Read
|
||||
more in our blog
|
||||
post](https://tailscale.com/blog/introducing-pgproxy/) about it!
|
||||
|
||||
The proxy runs an in-process Tailscale instance, accepts postgres
|
||||
client connections over Tailscale only, and proxies them to the
|
||||
configured upstream postgres server.
|
||||
|
||||
This proxy exists because postgres clients default to very insecure
|
||||
connection settings: either they "prefer" but do not require TLS; or
|
||||
they set sslmode=require, which merely requires that a TLS handshake
|
||||
took place, but don't verify the server's TLS certificate or the
|
||||
presented TLS hostname. In other words, sslmode=require enforces that
|
||||
a TLS session is created, but that session can trivially be
|
||||
machine-in-the-middled to steal credentials, data, inject malicious
|
||||
queries, and so forth.
|
||||
|
||||
Because this flaw is in the client's validation of the TLS session,
|
||||
you have no way of reliably detecting the misconfiguration
|
||||
server-side. You could fix the configuration of all the clients you
|
||||
know of, but the default makes it very easy to accidentally regress.
|
||||
|
||||
Instead of trying to verify client configuration over time, this proxy
|
||||
removes the need for postgres clients to be configured correctly: the
|
||||
upstream database is configured to only accept connections from the
|
||||
proxy, and the proxy is only available to clients over Tailscale.
|
||||
|
||||
Therefore, clients must use the proxy to connect to the database. The
|
||||
client<>proxy connection is secured end-to-end by Tailscale, which the
|
||||
proxy enforces by verifying that the connecting client is a known
|
||||
current Tailscale peer. The proxy<>server connection is established by
|
||||
the proxy itself, using strict TLS verification settings, and the
|
||||
client is only allowed to communicate with the server once we've
|
||||
established that the upstream connection is safe to use.
|
||||
|
||||
A couple side benefits: because clients can only connect via
|
||||
Tailscale, you can use Tailscale ACLs as an extra layer of defense on
|
||||
top of the postgres user/password authentication. And, the proxy can
|
||||
maintain an audit log of who connected to the database, complete with
|
||||
the strongly authenticated Tailscale identity of the client.
|
||||
366
cmd/pgproxy/pgproxy.go
Normal file
366
cmd/pgproxy/pgproxy.go
Normal file
@@ -0,0 +1,366 @@
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// The pgproxy server is a proxy for the Postgres wire protocol.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
crand "crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"expvar"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/metrics"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/tsweb"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
hostname = flag.String("hostname", "", "Tailscale hostname to serve on")
|
||||
port = flag.Int("port", 5432, "Listening port for client connections")
|
||||
debugPort = flag.Int("debug-port", 80, "Listening port for debug/metrics endpoint")
|
||||
upstreamAddr = flag.String("upstream-addr", "", "Address of the upstream Postgres server, in host:port format")
|
||||
upstreamCA = flag.String("upstream-ca-file", "", "File containing the PEM-encoded CA certificate for the upstream server")
|
||||
tailscaleDir = flag.String("state-dir", "", "Directory in which to store the Tailscale auth state")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *hostname == "" {
|
||||
log.Fatal("missing --hostname")
|
||||
}
|
||||
if *upstreamAddr == "" {
|
||||
log.Fatal("missing --upstream-addr")
|
||||
}
|
||||
if *upstreamCA == "" {
|
||||
log.Fatal("missing --upstream-ca-file")
|
||||
}
|
||||
if *tailscaleDir == "" {
|
||||
log.Fatal("missing --state-dir")
|
||||
}
|
||||
|
||||
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") == "" {
|
||||
log.Print("Note: you need to run this with TS_AUTHKEY=... the first time, to join your tailnet of choice.")
|
||||
}
|
||||
|
||||
tsclient, err := ts.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatalf("getting tsnet API client: %v", err)
|
||||
}
|
||||
|
||||
p, err := newProxy(*upstreamAddr, *upstreamCA, tsclient)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
expvar.Publish("pgproxy", p.Expvar())
|
||||
|
||||
if *debugPort != 0 {
|
||||
mux := http.NewServeMux()
|
||||
tsweb.Debugger(mux)
|
||||
srv := &http.Server{
|
||||
Handler: mux,
|
||||
}
|
||||
dln, err := ts.Listen("tcp", fmt.Sprintf(":%d", *debugPort))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go func() {
|
||||
log.Fatal(srv.Serve(dln))
|
||||
}()
|
||||
}
|
||||
|
||||
ln, err := ts.Listen("tcp", fmt.Sprintf(":%d", *port))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("serving access to %s on port %d", *upstreamAddr, *port)
|
||||
log.Fatal(p.Serve(ln))
|
||||
}
|
||||
|
||||
// proxy is a postgres wire protocol proxy, which strictly enforces
|
||||
// the security of the TLS connection to its upstream regardless of
|
||||
// what the client's TLS configuration is.
|
||||
type proxy struct {
|
||||
upstreamAddr string // "my.database.com:5432"
|
||||
upstreamHost string // "my.database.com"
|
||||
upstreamCertPool *x509.CertPool
|
||||
downstreamCert []tls.Certificate
|
||||
client *tailscale.LocalClient
|
||||
|
||||
activeSessions expvar.Int
|
||||
startedSessions expvar.Int
|
||||
errors metrics.LabelMap
|
||||
}
|
||||
|
||||
// newProxy returns a proxy that forwards connections to
|
||||
// upstreamAddr. The upstream's TLS session is verified using the CA
|
||||
// cert(s) in upstreamCAPath.
|
||||
func newProxy(upstreamAddr, upstreamCAPath string, client *tailscale.LocalClient) (*proxy, error) {
|
||||
bs, err := os.ReadFile(upstreamCAPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
upstreamCertPool := x509.NewCertPool()
|
||||
if !upstreamCertPool.AppendCertsFromPEM(bs) {
|
||||
return nil, fmt.Errorf("invalid CA cert in %q", upstreamCAPath)
|
||||
}
|
||||
|
||||
h, _, err := net.SplitHostPort(upstreamAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
downstreamCert, err := mkSelfSigned(h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &proxy{
|
||||
upstreamAddr: upstreamAddr,
|
||||
upstreamHost: h,
|
||||
upstreamCertPool: upstreamCertPool,
|
||||
downstreamCert: []tls.Certificate{downstreamCert},
|
||||
client: client,
|
||||
errors: metrics.LabelMap{Label: "kind"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Expvar returns p's monitoring metrics.
|
||||
func (p *proxy) Expvar() expvar.Var {
|
||||
ret := &metrics.Set{}
|
||||
ret.Set("sessions_active", &p.activeSessions)
|
||||
ret.Set("sessions_started", &p.startedSessions)
|
||||
ret.Set("session_errors", &p.errors)
|
||||
return ret
|
||||
}
|
||||
|
||||
// Serve accepts postgres client connections on ln and proxies them to
|
||||
// the configured upstream. ln can be any net.Listener, but all client
|
||||
// connections must originate from tailscale IPs that can be verified
|
||||
// with WhoIs.
|
||||
func (p *proxy) Serve(ln net.Listener) error {
|
||||
var lastSessionID int64
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := time.Now().UnixNano()
|
||||
if id == lastSessionID {
|
||||
// Bluntly enforce SID uniqueness, even if collisions are
|
||||
// fantastically unlikely (but OSes vary in how much timer
|
||||
// precision they expose to the OS, so id might be rounded
|
||||
// e.g. to the same millisecond)
|
||||
id++
|
||||
}
|
||||
lastSessionID = id
|
||||
go func(sessionID int64) {
|
||||
if err := p.serve(sessionID, c); err != nil {
|
||||
log.Printf("%d: session ended with error: %v", sessionID, err)
|
||||
}
|
||||
}(id)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// sslStart is the magic bytes that postgres clients use to indicate
|
||||
// that they want to do a TLS handshake. Servers should respond with
|
||||
// the single byte "S" before starting a normal TLS handshake.
|
||||
sslStart = [8]byte{0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f}
|
||||
// plaintextStart is the magic bytes that postgres clients use to
|
||||
// indicate that they're starting a plaintext authentication
|
||||
// handshake.
|
||||
plaintextStart = [8]byte{0, 0, 0, 86, 0, 3, 0, 0}
|
||||
)
|
||||
|
||||
// serve proxies the postgres client on c to the proxy's upstream,
|
||||
// enforcing strict TLS to the upstream.
|
||||
func (p *proxy) serve(sessionID int64, c net.Conn) error {
|
||||
defer c.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
whois, err := p.client.WhoIs(ctx, c.RemoteAddr().String())
|
||||
if err != nil {
|
||||
p.errors.Add("whois-failed", 1)
|
||||
return fmt.Errorf("getting client identity: %v", err)
|
||||
}
|
||||
|
||||
// Before anything else, log the connection attempt.
|
||||
user, machine := "", ""
|
||||
if whois.Node != nil {
|
||||
if whois.Node.Hostinfo.ShareeNode() {
|
||||
machine = "external-device"
|
||||
} else {
|
||||
machine = strings.TrimSuffix(whois.Node.Name, ".")
|
||||
}
|
||||
}
|
||||
if whois.UserProfile != nil {
|
||||
user = whois.UserProfile.LoginName
|
||||
if user == "tagged-devices" && whois.Node != nil {
|
||||
user = strings.Join(whois.Node.Tags, ",")
|
||||
}
|
||||
}
|
||||
if user == "" || machine == "" {
|
||||
p.errors.Add("no-ts-identity", 1)
|
||||
return fmt.Errorf("couldn't identify source user and machine (user %q, machine %q)", user, machine)
|
||||
}
|
||||
log.Printf("%d: session start, from %s (machine %s, user %s)", sessionID, c.RemoteAddr(), machine, user)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
elapsed := time.Since(start)
|
||||
log.Printf("%d: session end, from %s (machine %s, user %s), lasted %s", sessionID, c.RemoteAddr(), machine, user, elapsed.Round(time.Millisecond))
|
||||
}()
|
||||
|
||||
// Read the client's opening message, to figure out if it's trying
|
||||
// to TLS or not.
|
||||
var buf [8]byte
|
||||
if _, err := io.ReadFull(c, buf[:len(sslStart)]); err != nil {
|
||||
p.errors.Add("network-error", 1)
|
||||
return fmt.Errorf("initial magic read: %v", err)
|
||||
}
|
||||
var clientIsTLS bool
|
||||
switch {
|
||||
case buf == sslStart:
|
||||
clientIsTLS = true
|
||||
case buf == plaintextStart:
|
||||
clientIsTLS = false
|
||||
default:
|
||||
p.errors.Add("client-bad-protocol", 1)
|
||||
return fmt.Errorf("unrecognized initial packet = % 02x", buf)
|
||||
}
|
||||
|
||||
// Dial & verify upstream connection.
|
||||
var d net.Dialer
|
||||
d.Timeout = 10 * time.Second
|
||||
upc, err := d.Dial("tcp", p.upstreamAddr)
|
||||
if err != nil {
|
||||
p.errors.Add("network-error", 1)
|
||||
return fmt.Errorf("upstream dial: %v", err)
|
||||
}
|
||||
defer upc.Close()
|
||||
if _, err := upc.Write(sslStart[:]); err != nil {
|
||||
p.errors.Add("network-error", 1)
|
||||
return fmt.Errorf("upstream write of start-ssl magic: %v", err)
|
||||
}
|
||||
if _, err := io.ReadFull(upc, buf[:1]); err != nil {
|
||||
p.errors.Add("network-error", 1)
|
||||
return fmt.Errorf("reading upstream start-ssl response: %v", err)
|
||||
}
|
||||
if buf[0] != 'S' {
|
||||
p.errors.Add("upstream-bad-protocol", 1)
|
||||
return fmt.Errorf("upstream didn't acknowldge start-ssl, said %q", buf[0])
|
||||
}
|
||||
tlsConf := &tls.Config{
|
||||
ServerName: p.upstreamHost,
|
||||
RootCAs: p.upstreamCertPool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
uptc := tls.Client(upc, tlsConf)
|
||||
if err = uptc.HandshakeContext(ctx); err != nil {
|
||||
p.errors.Add("upstream-tls", 1)
|
||||
return fmt.Errorf("upstream TLS handshake: %v", err)
|
||||
}
|
||||
|
||||
// Accept the client conn and set it up the way the client wants.
|
||||
var clientConn net.Conn
|
||||
if clientIsTLS {
|
||||
io.WriteString(c, "S") // yeah, we're good to speak TLS
|
||||
s := tls.Server(c, &tls.Config{
|
||||
ServerName: p.upstreamHost,
|
||||
Certificates: p.downstreamCert,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
if err = uptc.HandshakeContext(ctx); err != nil {
|
||||
p.errors.Add("client-tls", 1)
|
||||
return fmt.Errorf("client TLS handshake: %v", err)
|
||||
}
|
||||
clientConn = s
|
||||
} else {
|
||||
// Repeat the header we read earlier up to the server.
|
||||
if _, err := uptc.Write(plaintextStart[:]); err != nil {
|
||||
p.errors.Add("network-error", 1)
|
||||
return fmt.Errorf("sending initial client bytes to upstream: %v", err)
|
||||
}
|
||||
clientConn = c
|
||||
}
|
||||
|
||||
// Finally, proxy the client to the upstream.
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := io.Copy(uptc, clientConn)
|
||||
errc <- err
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(clientConn, uptc)
|
||||
errc <- err
|
||||
}()
|
||||
if err := <-errc; err != nil {
|
||||
// Don't increment error counts here, because the most common
|
||||
// cause of termination is client or server closing the
|
||||
// connection normally, and it'll obscure "interesting"
|
||||
// handshake errors.
|
||||
return fmt.Errorf("session terminated with error: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mkSelfSigned creates and returns a self-signed TLS certificate for
|
||||
// hostname.
|
||||
func mkSelfSigned(hostname string) (tls.Certificate, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
pub := priv.Public()
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"pgproxy"},
|
||||
},
|
||||
DNSNames: []string{hostname},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
derBytes, err := x509.CreateCertificate(crand.Reader, &template, &template, pub, priv)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
cert, err := x509.ParseCertificate(derBytes)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
return tls.Certificate{
|
||||
Certificate: [][]byte{derBytes},
|
||||
PrivateKey: priv,
|
||||
Leaf: cert,
|
||||
}, nil
|
||||
}
|
||||
@@ -110,11 +110,12 @@ func runSpeedtest(ctx context.Context, args []string) error {
|
||||
w := tabwriter.NewWriter(os.Stdout, 12, 0, 0, ' ', tabwriter.TabIndent)
|
||||
fmt.Println("Results:")
|
||||
fmt.Fprintln(w, "Interval\t\tTransfer\t\tBandwidth\t\t")
|
||||
startTime := results[0].IntervalStart
|
||||
for _, r := range results {
|
||||
if r.Total {
|
||||
fmt.Fprintln(w, "-------------------------------------------------------------------------")
|
||||
}
|
||||
fmt.Fprintf(w, "%.2f-%.2f\tsec\t%.4f\tMBits\t%.4f\tMbits/sec\t\n", r.IntervalStart.Seconds(), r.IntervalEnd.Seconds(), r.MegaBits(), r.MBitsPerSecond())
|
||||
fmt.Fprintf(w, "%.2f-%.2f\tsec\t%.4f\tMBits\t%.4f\tMbits/sec\t\n", r.IntervalStart.Sub(startTime).Seconds(), r.IntervalEnd.Sub(startTime).Seconds(), r.MegaBits(), r.MBitsPerSecond())
|
||||
}
|
||||
w.Flush()
|
||||
return nil
|
||||
|
||||
189
cmd/ssh-auth-none-demo/ssh-auth-none-demo.go
Normal file
189
cmd/ssh-auth-none-demo/ssh-auth-none-demo.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// ssh-auth-none-demo is a demo SSH server that's meant to run on the
|
||||
// public internet (at 188.166.70.128 port 2222) and
|
||||
// highlight the unique parts of the Tailscale SSH server so SSH
|
||||
// client authors can hit it easily and fix their SSH clients without
|
||||
// needing to set up Tailscale and Tailscale SSH.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
gossh "github.com/tailscale/golang-x-crypto/ssh"
|
||||
"tailscale.com/tempfork/gliderlabs/ssh"
|
||||
)
|
||||
|
||||
// keyTypes are the SSH key types that we either try to read from the
|
||||
// system's OpenSSH keys.
|
||||
var keyTypes = []string{"rsa", "ecdsa", "ed25519"}
|
||||
|
||||
var (
|
||||
addr = flag.String("addr", ":2222", "address to listen on")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(cacheDir, "ssh-auth-none-demo")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
keys, err := getHostKeys(dir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
log.Fatal("no host keys")
|
||||
}
|
||||
|
||||
srv := &ssh.Server{
|
||||
Addr: *addr,
|
||||
Version: "Tailscale",
|
||||
Handler: handleSessionPostSSHAuth,
|
||||
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
|
||||
start := time.Now()
|
||||
return &gossh.ServerConfig{
|
||||
NextAuthMethodCallback: func(conn gossh.ConnMetadata, prevErrors []error) []string {
|
||||
return []string{"tailscale"}
|
||||
},
|
||||
NoClientAuth: true, // required for the NoClientAuthCallback to run
|
||||
NoClientAuthCallback: func(cm gossh.ConnMetadata) (*gossh.Permissions, error) {
|
||||
cm.SendAuthBanner(fmt.Sprintf("# Banner: doing none auth at %v\r\n", time.Since(start)))
|
||||
|
||||
totalBanners := 2
|
||||
if cm.User() == "banners" {
|
||||
totalBanners = 5
|
||||
}
|
||||
for banner := 2; banner <= totalBanners; banner++ {
|
||||
time.Sleep(time.Second)
|
||||
if banner == totalBanners {
|
||||
cm.SendAuthBanner(fmt.Sprintf("# Banner%d: access granted at %v\r\n", banner, time.Since(start)))
|
||||
} else {
|
||||
cm.SendAuthBanner(fmt.Sprintf("# Banner%d at %v\r\n", banner, time.Since(start)))
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
BannerCallback: func(cm gossh.ConnMetadata) string {
|
||||
log.Printf("Got connection from user %q, %q from %v", cm.User(), cm.ClientVersion(), cm.RemoteAddr())
|
||||
return fmt.Sprintf("# Banner for user %q, %q\n", cm.User(), cm.ClientVersion())
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
for _, signer := range keys {
|
||||
srv.AddHostKey(signer)
|
||||
}
|
||||
|
||||
log.Printf("Running on %s ...", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("done")
|
||||
}
|
||||
|
||||
func handleSessionPostSSHAuth(s ssh.Session) {
|
||||
log.Printf("Started session from user %q", s.User())
|
||||
fmt.Fprintf(s, "Hello user %q, it worked.\n", s.User())
|
||||
|
||||
// Abort the session on Control-C or Control-D.
|
||||
go func() {
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, err := s.Read(buf)
|
||||
for _, b := range buf[:n] {
|
||||
if b <= 4 { // abort on Control-C (3) or Control-D (4)
|
||||
io.WriteString(s, "bye\n")
|
||||
s.Exit(1)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 10; i > 0; i-- {
|
||||
fmt.Fprintf(s, "%v ...\n", i)
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
s.Exit(0)
|
||||
}
|
||||
|
||||
func getHostKeys(dir string) (ret []ssh.Signer, err error) {
|
||||
for _, typ := range keyTypes {
|
||||
hostKey, err := hostKeyFileOrCreate(dir, typ)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signer, err := gossh.ParsePrivateKey(hostKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret = append(ret, signer)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func hostKeyFileOrCreate(keyDir, typ string) ([]byte, error) {
|
||||
path := filepath.Join(keyDir, "ssh_host_"+typ+"_key")
|
||||
v, err := ioutil.ReadFile(path)
|
||||
if err == nil {
|
||||
return v, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
var priv any
|
||||
switch typ {
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported key type %q", typ)
|
||||
case "ed25519":
|
||||
_, priv, err = ed25519.GenerateKey(rand.Reader)
|
||||
case "ecdsa":
|
||||
// curve is arbitrary. We pick whatever will at
|
||||
// least pacify clients as the actual encryption
|
||||
// doesn't matter: it's all over WireGuard anyway.
|
||||
curve := elliptic.P256()
|
||||
priv, err = ecdsa.GenerateKey(curve, rand.Reader)
|
||||
case "rsa":
|
||||
// keySize is arbitrary. We pick whatever will at
|
||||
// least pacify clients as the actual encryption
|
||||
// doesn't matter: it's all over WireGuard anyway.
|
||||
const keySize = 2048
|
||||
priv, err = rsa.GenerateKey(rand.Reader, keySize)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mk, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pemGen := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: mk})
|
||||
err = os.WriteFile(path, pemGen, 0700)
|
||||
return pemGen, err
|
||||
}
|
||||
@@ -7,8 +7,11 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
var bugReportCmd = &ffcli.Command{
|
||||
@@ -16,6 +19,17 @@ var bugReportCmd = &ffcli.Command{
|
||||
Exec: runBugReport,
|
||||
ShortHelp: "Print a shareable identifier to help diagnose issues",
|
||||
ShortUsage: "bugreport [note]",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("bugreport")
|
||||
fs.BoolVar(&bugReportArgs.diagnose, "diagnose", false, "run additional in-depth checks")
|
||||
fs.BoolVar(&bugReportArgs.record, "record", false, "if true, pause and then write another bugreport")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
|
||||
var bugReportArgs struct {
|
||||
diagnose bool
|
||||
record bool
|
||||
}
|
||||
|
||||
func runBugReport(ctx context.Context, args []string) error {
|
||||
@@ -25,12 +39,46 @@ func runBugReport(ctx context.Context, args []string) error {
|
||||
case 1:
|
||||
note = args[0]
|
||||
default:
|
||||
return errors.New("unknown argumets")
|
||||
return errors.New("unknown arguments")
|
||||
}
|
||||
logMarker, err := localClient.BugReport(ctx, note)
|
||||
if err != nil {
|
||||
return err
|
||||
opts := tailscale.BugReportOpts{
|
||||
Note: note,
|
||||
Diagnose: bugReportArgs.diagnose,
|
||||
}
|
||||
outln(logMarker)
|
||||
if !bugReportArgs.record {
|
||||
// Simple, non-record case
|
||||
logMarker, err := localClient.BugReportWithOpts(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outln(logMarker)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Recording; run the request in the background
|
||||
done := make(chan struct{})
|
||||
opts.Record = done
|
||||
|
||||
type bugReportResp struct {
|
||||
marker string
|
||||
err error
|
||||
}
|
||||
resCh := make(chan bugReportResp, 1)
|
||||
go func() {
|
||||
m, err := localClient.BugReportWithOpts(ctx, opts)
|
||||
resCh <- bugReportResp{m, err}
|
||||
}()
|
||||
|
||||
outln("Recording started; please reproduce your issue and then press Enter...")
|
||||
fmt.Scanln()
|
||||
close(done)
|
||||
res := <-resCh
|
||||
|
||||
if res.err != nil {
|
||||
return res.err
|
||||
}
|
||||
|
||||
outln(res.marker)
|
||||
outln("Please provide both bugreport markers above to the support team or GitHub issue.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ package cli
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -16,6 +19,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"software.sslmate.com/src/go-pkcs12"
|
||||
"tailscale.com/atomicfile"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/version"
|
||||
@@ -44,6 +48,7 @@ var certArgs struct {
|
||||
func runCert(ctx context.Context, args []string) error {
|
||||
if certArgs.serve {
|
||||
s := &http.Server{
|
||||
Addr: ":443",
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: localClient.GetCertificate,
|
||||
},
|
||||
@@ -57,7 +62,16 @@ func runCert(ctx context.Context, args []string) error {
|
||||
fmt.Fprintf(w, "<h1>Hello from Tailscale</h1>It works.")
|
||||
}),
|
||||
}
|
||||
log.Printf("running TLS server on :443 ...")
|
||||
switch len(args) {
|
||||
case 0:
|
||||
// Nothing.
|
||||
case 1:
|
||||
s.Addr = args[0]
|
||||
default:
|
||||
return errors.New("too many arguments; max 1 allowed with --serve-demo (the listen address)")
|
||||
}
|
||||
|
||||
log.Printf("running TLS server on %s ...", s.Addr)
|
||||
return s.ListenAndServeTLS("", "")
|
||||
}
|
||||
|
||||
@@ -119,17 +133,25 @@ func runCert(ctx context.Context, args []string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if certArgs.keyFile != "" {
|
||||
keyChanged, err := writeIfChanged(certArgs.keyFile, keyPEM, 0600)
|
||||
if dst := certArgs.keyFile; dst != "" {
|
||||
contents := keyPEM
|
||||
if isPKCS12(dst) {
|
||||
var err error
|
||||
contents, err = convertToPKCS12(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
keyChanged, err := writeIfChanged(dst, contents, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if certArgs.keyFile != "-" {
|
||||
macWarn()
|
||||
if keyChanged {
|
||||
printf("Wrote private key to %v\n", certArgs.keyFile)
|
||||
printf("Wrote private key to %v\n", dst)
|
||||
} else {
|
||||
printf("Private key unchanged at %v\n", certArgs.keyFile)
|
||||
printf("Private key unchanged at %v\n", dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,3 +171,29 @@ func writeIfChanged(filename string, contents []byte, mode os.FileMode) (changed
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func isPKCS12(dst string) bool {
|
||||
return strings.HasSuffix(dst, ".p12") || strings.HasSuffix(dst, ".pfx")
|
||||
}
|
||||
|
||||
func convertToPKCS12(certPEM, keyPEM []byte) ([]byte, error) {
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var certs []*x509.Certificate
|
||||
for _, c := range cert.Certificate {
|
||||
cert, err := x509.ParseCertificate(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
if len(certs) == 0 {
|
||||
return nil, errors.New("no certs")
|
||||
}
|
||||
// TODO(bradfitz): I'm not sure this is right yet. The goal was to make this
|
||||
// work for https://github.com/tailscale/tailscale/issues/2928 but I'm still
|
||||
// fighting Windows.
|
||||
return pkcs12.Encode(rand.Reader, cert.PrivateKey, certs[0], certs[1:], "" /* no password */)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
@@ -35,6 +36,10 @@ import (
|
||||
var Stderr io.Writer = os.Stderr
|
||||
var Stdout io.Writer = os.Stdout
|
||||
|
||||
func errf(format string, a ...any) {
|
||||
fmt.Fprintf(Stderr, format, a...)
|
||||
}
|
||||
|
||||
func printf(format string, a ...any) {
|
||||
fmt.Fprintf(Stdout, format, a...)
|
||||
}
|
||||
@@ -157,6 +162,7 @@ change in the future.
|
||||
Subcommands: []*ffcli.Command{
|
||||
upCmd,
|
||||
downCmd,
|
||||
setCmd,
|
||||
logoutCmd,
|
||||
netcheckCmd,
|
||||
ipCmd,
|
||||
@@ -177,15 +183,26 @@ change in the future.
|
||||
UsageFunc: usageFunc,
|
||||
}
|
||||
for _, c := range rootCmd.Subcommands {
|
||||
c.UsageFunc = usageFunc
|
||||
if c.UsageFunc == nil {
|
||||
c.UsageFunc = usageFunc
|
||||
}
|
||||
}
|
||||
if envknob.UseWIPCode() {
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, idTokenCmd)
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands,
|
||||
idTokenCmd,
|
||||
)
|
||||
}
|
||||
|
||||
// Don't advertise the debug command, but it exists.
|
||||
if strSliceContains(args, "debug") {
|
||||
// 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, "login"):
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, loginCmd)
|
||||
case slices.Contains(args, "switch"):
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, switchCmd)
|
||||
case slices.Contains(args, "serve"):
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, serveCmd)
|
||||
}
|
||||
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)
|
||||
@@ -283,16 +300,16 @@ func pump(ctx context.Context, bc *ipn.BackendClient, conn net.Conn) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func strSliceContains(ss []string, s string) bool {
|
||||
for _, v := range ss {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
// usageFuncNoDefaultValues is like usageFunc but doesn't print default values.
|
||||
func usageFuncNoDefaultValues(c *ffcli.Command) string {
|
||||
return usageFuncOpt(c, false)
|
||||
}
|
||||
|
||||
func usageFunc(c *ffcli.Command) string {
|
||||
return usageFuncOpt(c, true)
|
||||
}
|
||||
|
||||
func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "USAGE\n")
|
||||
@@ -323,6 +340,9 @@ func usageFunc(c *ffcli.Command) string {
|
||||
c.FlagSet.VisitAll(func(f *flag.Flag) {
|
||||
var s string
|
||||
name, usage := flag.UnquoteUsage(f)
|
||||
if strings.HasPrefix(usage, "HIDDEN: ") {
|
||||
return
|
||||
}
|
||||
if isBoolFlag(f) {
|
||||
s = fmt.Sprintf(" --%s, --%s=false", f.Name, f.Name)
|
||||
} else {
|
||||
@@ -336,7 +356,7 @@ func usageFunc(c *ffcli.Command) string {
|
||||
s += "\n \t"
|
||||
s += strings.ReplaceAll(usage, "\n", "\n \t")
|
||||
|
||||
if f.DefValue != "" {
|
||||
if f.DefValue != "" && withDefaults {
|
||||
s += fmt.Sprintf(" (default %s)", f.DefValue)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@ import (
|
||||
|
||||
qt "github.com/frankban/quicktest"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/health/healthmsg"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/persist"
|
||||
"tailscale.com/types/preftype"
|
||||
@@ -36,10 +38,10 @@ var geese = []string{"linux", "darwin", "windows", "freebsd"}
|
||||
func TestUpdateMaskedPrefsFromUpFlag(t *testing.T) {
|
||||
for _, goos := range geese {
|
||||
var upArgs upArgsT
|
||||
fs := newUpFlagSet(goos, &upArgs)
|
||||
fs := newUpFlagSet(goos, &upArgs, "up")
|
||||
fs.VisitAll(func(f *flag.Flag) {
|
||||
mp := new(ipn.MaskedPrefs)
|
||||
updateMaskedPrefsFromUpFlag(mp, f.Name)
|
||||
updateMaskedPrefsFromUpOrSetFlag(mp, f.Name)
|
||||
got := mp.Pretty()
|
||||
wantEmpty := preflessFlag(f.Name)
|
||||
isEmpty := got == "MaskedPrefs{}"
|
||||
@@ -410,7 +412,7 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.7",
|
||||
},
|
||||
{
|
||||
name: "error_exit_node_and_allow_lan_omit_with_id_pref", // Isue 3480
|
||||
name: "error_exit_node_and_allow_lan_omit_with_id_pref", // Issue 3480
|
||||
flags: []string{"--hostname=foo"},
|
||||
curExitNodeIP: netip.MustParseAddr("100.2.3.4"),
|
||||
curPrefs: &ipn.Prefs{
|
||||
@@ -448,7 +450,7 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// Issue 3176: on Synology, don't require --accept-routes=false because user
|
||||
// migth've had old an install, and we don't support --accept-routes anyway.
|
||||
// might've had an old install, and we don't support --accept-routes anyway.
|
||||
name: "synology_permit_omit_accept_routes",
|
||||
flags: []string{"--hostname=foo"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
@@ -478,6 +480,19 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
distro: "", // not Synology
|
||||
want: accidentalUpPrefix + " --hostname=foo --accept-routes",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
goos: "linux",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -486,7 +501,7 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
goos = tt.goos
|
||||
}
|
||||
var upArgs upArgsT
|
||||
flagSet := newUpFlagSet(goos, &upArgs)
|
||||
flagSet := newUpFlagSet(goos, &upArgs, "up")
|
||||
flags := CleanUpArgs(tt.flags)
|
||||
flagSet.Parse(flags)
|
||||
newPrefs, err := prefsFromUpArgs(upArgs, t.Logf, new(ipnstate.Status), goos)
|
||||
@@ -513,7 +528,7 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
}
|
||||
|
||||
func upArgsFromOSArgs(goos string, flagArgs ...string) (args upArgsT) {
|
||||
fs := newUpFlagSet(goos, &args)
|
||||
fs := newUpFlagSet(goos, &args, "up")
|
||||
fs.Parse(flagArgs) // populates args
|
||||
return
|
||||
}
|
||||
@@ -773,7 +788,7 @@ func TestPrefFlagMapping(t *testing.T) {
|
||||
func TestFlagAppliesToOS(t *testing.T) {
|
||||
for _, goos := range geese {
|
||||
var upArgs upArgsT
|
||||
fs := newUpFlagSet(goos, &upArgs)
|
||||
fs := newUpFlagSet(goos, &upArgs, "up")
|
||||
fs.VisitAll(func(f *flag.Flag) {
|
||||
if !flagAppliesToOS(f.Name, goos) {
|
||||
t.Errorf("flagAppliesToOS(%q, %q) = false but found in %s set", f.Name, goos, goos)
|
||||
@@ -789,6 +804,10 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
curPrefs *ipn.Prefs
|
||||
env upCheckEnv // empty goos means "linux"
|
||||
|
||||
// sshOverTailscale specifies if the cmd being run over SSH over Tailscale.
|
||||
// It is used to test the --accept-risks flag.
|
||||
sshOverTailscale bool
|
||||
|
||||
// checkUpdatePrefsMutations, if non-nil, is run with the new prefs after
|
||||
// updatePrefs might've mutated them (from applyImplicitPrefs).
|
||||
checkUpdatePrefsMutations func(t *testing.T, newPrefs *ipn.Prefs)
|
||||
@@ -916,15 +935,159 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "enable_ssh",
|
||||
flags: []string{"--ssh"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
WantRunningSet: true,
|
||||
},
|
||||
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
||||
if !newPrefs.RunSSH {
|
||||
t.Errorf("RunSSH not set to true")
|
||||
}
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
},
|
||||
{
|
||||
name: "disable_ssh",
|
||||
flags: []string{"--ssh=false"},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
RunSSH: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
WantRunningSet: true,
|
||||
},
|
||||
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
||||
if newPrefs.RunSSH {
|
||||
t.Errorf("RunSSH not set to false")
|
||||
}
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running", upArgs: upArgsT{
|
||||
runSSH: true,
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "disable_ssh_over_ssh_no_risk",
|
||||
flags: []string{"--ssh=false"},
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
RunSSH: true,
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
WantRunningSet: true,
|
||||
},
|
||||
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
||||
if !newPrefs.RunSSH {
|
||||
t.Errorf("RunSSH not set to true")
|
||||
}
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
wantErrSubtr: "aborted, no changes made",
|
||||
},
|
||||
{
|
||||
name: "enable_ssh_over_ssh_no_risk",
|
||||
flags: []string{"--ssh=true"},
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
WantRunningSet: true,
|
||||
},
|
||||
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
||||
if !newPrefs.RunSSH {
|
||||
t.Errorf("RunSSH not set to true")
|
||||
}
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
wantErrSubtr: "aborted, no changes made",
|
||||
},
|
||||
{
|
||||
name: "enable_ssh_over_ssh",
|
||||
flags: []string{"--ssh=true", "--accept-risk=lose-ssh"},
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
WantRunningSet: true,
|
||||
},
|
||||
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
||||
if !newPrefs.RunSSH {
|
||||
t.Errorf("RunSSH not set to true")
|
||||
}
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
},
|
||||
{
|
||||
name: "disable_ssh_over_ssh",
|
||||
flags: []string{"--ssh=false", "--accept-risk=lose-ssh"},
|
||||
sshOverTailscale: true,
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
Persist: &persist.Persist{LoginName: "crawshaw.github"},
|
||||
AllowSingleHosts: true,
|
||||
CorpDNS: true,
|
||||
RunSSH: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
RunSSHSet: true,
|
||||
WantRunningSet: true,
|
||||
},
|
||||
checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) {
|
||||
if newPrefs.RunSSH {
|
||||
t.Errorf("RunSSH not set to false")
|
||||
}
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.sshOverTailscale {
|
||||
old := getSSHClientEnvVar
|
||||
getSSHClientEnvVar = func() string { return "100.100.100.100 1 1" }
|
||||
t.Cleanup(func() { getSSHClientEnvVar = old })
|
||||
}
|
||||
if tt.env.goos == "" {
|
||||
tt.env.goos = "linux"
|
||||
}
|
||||
tt.env.flagSet = newUpFlagSet(tt.env.goos, &tt.env.upArgs)
|
||||
tt.env.flagSet = newUpFlagSet(tt.env.goos, &tt.env.upArgs, "up")
|
||||
flags := CleanUpArgs(tt.flags)
|
||||
tt.env.flagSet.Parse(flags)
|
||||
if err := tt.env.flagSet.Parse(flags); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newPrefs, err := prefsFromUpArgs(tt.env.upArgs, t.Logf, new(ipnstate.Status), tt.env.goos)
|
||||
if err != nil {
|
||||
@@ -939,6 +1102,8 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
return
|
||||
}
|
||||
t.Fatal(err)
|
||||
} else if tt.wantErrSubtr != "" {
|
||||
t.Fatalf("want error %q, got nil", tt.wantErrSubtr)
|
||||
}
|
||||
if tt.checkUpdatePrefsMutations != nil {
|
||||
tt.checkUpdatePrefsMutations(t, newPrefs)
|
||||
@@ -952,13 +1117,18 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
justEditMP.Prefs = ipn.Prefs{} // uninteresting
|
||||
}
|
||||
if !reflect.DeepEqual(justEditMP, tt.wantJustEditMP) {
|
||||
t.Logf("justEditMP != wantJustEditMP; following diff omits the Prefs field, which was %+v", oldEditPrefs)
|
||||
t.Logf("justEditMP != wantJustEditMP; following diff omits the Prefs field, which was \n%v", asJSON(oldEditPrefs))
|
||||
t.Fatalf("justEditMP: %v\n\n: ", cmp.Diff(justEditMP, tt.wantJustEditMP, cmpIP))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func asJSON(v any) string {
|
||||
b, _ := json.MarshalIndent(v, "", "\t")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
var cmpIP = cmp.Comparer(func(a, b netip.Addr) bool {
|
||||
return a == b
|
||||
})
|
||||
@@ -988,3 +1158,81 @@ func TestCleanUpArgs(t *testing.T) {
|
||||
c.Assert(got, qt.DeepEquals, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpWorthWarning(t *testing.T) {
|
||||
if !upWorthyWarning(healthmsg.WarnAcceptRoutesOff) {
|
||||
t.Errorf("WarnAcceptRoutesOff of %q should be worth warning", healthmsg.WarnAcceptRoutesOff)
|
||||
}
|
||||
if !upWorthyWarning(healthmsg.TailscaleSSHOnBut + "some problem") {
|
||||
t.Errorf("want true for SSH problems")
|
||||
}
|
||||
if upWorthyWarning("not in map poll") {
|
||||
t.Errorf("want false for other misc errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNLArgs(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
input []string
|
||||
parseKeys bool
|
||||
parseDisablements bool
|
||||
|
||||
wantErr error
|
||||
wantKeys []tka.Key
|
||||
wantDisablements [][]byte
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
input: nil,
|
||||
parseKeys: true,
|
||||
parseDisablements: true,
|
||||
},
|
||||
{
|
||||
name: "key no votes",
|
||||
input: []string{"nlpub:" + strings.Repeat("00", 32)},
|
||||
parseKeys: true,
|
||||
wantKeys: []tka.Key{{Kind: tka.Key25519, Votes: 1, Public: bytes.Repeat([]byte{0}, 32)}},
|
||||
},
|
||||
{
|
||||
name: "key with votes",
|
||||
input: []string{"nlpub:" + strings.Repeat("01", 32) + "?5"},
|
||||
parseKeys: true,
|
||||
wantKeys: []tka.Key{{Kind: tka.Key25519, Votes: 5, Public: bytes.Repeat([]byte{1}, 32)}},
|
||||
},
|
||||
{
|
||||
name: "disablements",
|
||||
input: []string{"disablement:" + strings.Repeat("02", 32), "disablement-secret:" + strings.Repeat("03", 32)},
|
||||
parseDisablements: true,
|
||||
wantDisablements: [][]byte{bytes.Repeat([]byte{2}, 32), bytes.Repeat([]byte{3}, 32)},
|
||||
},
|
||||
{
|
||||
name: "disablements not allowed",
|
||||
input: []string{"disablement:" + strings.Repeat("02", 32)},
|
||||
parseKeys: true,
|
||||
wantErr: fmt.Errorf("parsing key 1: key hex string doesn't have expected type prefix nlpub:"),
|
||||
},
|
||||
{
|
||||
name: "keys not allowed",
|
||||
input: []string{"nlpub:" + strings.Repeat("02", 32)},
|
||||
parseDisablements: true,
|
||||
wantErr: fmt.Errorf("parsing argument 1: expected value with \"disablement:\" or \"disablement-secret:\" prefix, got %q", "nlpub:0202020202020202020202020202020202020202020202020202020202020202"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
keys, disablements, err := parseNLArgs(tc.input, tc.parseKeys, tc.parseDisablements)
|
||||
if !reflect.DeepEqual(err, tc.wantErr) {
|
||||
t.Fatalf("parseNLArgs(%v).err = %v, want %v", tc.input, err, tc.wantErr)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(keys, tc.wantKeys) {
|
||||
t.Errorf("keys = %v, want %v", keys, tc.wantKeys)
|
||||
}
|
||||
if !reflect.DeepEqual(disablements, tc.wantDisablements) {
|
||||
t.Errorf("disablements = %v, want %v", disablements, tc.wantDisablements)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,11 +48,11 @@ func runConfigureHost(ctx context.Context, args []string) error {
|
||||
if uid := os.Getuid(); uid != 0 {
|
||||
return fmt.Errorf("must be run as root, not %q (%v)", os.Getenv("USER"), uid)
|
||||
}
|
||||
osVer := hostinfo.GetOSVersion()
|
||||
isDSM6 := strings.HasPrefix(osVer, "Synology 6")
|
||||
isDSM7 := strings.HasPrefix(osVer, "Synology 7")
|
||||
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", osVer)
|
||||
return fmt.Errorf("unsupported DSM version %q", hi.DistroVersion)
|
||||
}
|
||||
if _, err := os.Stat("/dev/net/tun"); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll("/dev/net", 0755); err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
@@ -25,14 +26,18 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
"tailscale.com/control/controlhttp"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/net/tshttpproxy"
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/strs"
|
||||
)
|
||||
|
||||
var debugCmd = &ffcli.Command{
|
||||
@@ -42,7 +47,7 @@ var debugCmd = &ffcli.Command{
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("debug")
|
||||
fs.StringVar(&debugArgs.file, "file", "", "get, delete:NAME, or NAME")
|
||||
fs.StringVar(&debugArgs.cpuFile, "cpu-profile", "", "if non-empty, grab a CPU profile for --profile-sec seconds and write it to this file; - for stdout")
|
||||
fs.StringVar(&debugArgs.cpuFile, "cpu-profile", "", "if non-empty, grab a CPU profile for --profile-seconds seconds and write it to this file; - for stdout")
|
||||
fs.StringVar(&debugArgs.memFile, "mem-profile", "", "if non-empty, grab a memory profile and write it to this file; - for stdout")
|
||||
fs.IntVar(&debugArgs.cpuSec, "profile-seconds", 15, "number of seconds to run a CPU profile for, when --cpu-profile is non-empty")
|
||||
return fs
|
||||
@@ -53,6 +58,16 @@ var debugCmd = &ffcli.Command{
|
||||
Exec: runDERPMap,
|
||||
ShortHelp: "print DERP map",
|
||||
},
|
||||
{
|
||||
Name: "component-logs",
|
||||
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")
|
||||
return fs
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "daemon-goroutines",
|
||||
Exec: runDaemonGoroutines,
|
||||
@@ -86,7 +101,7 @@ var debugCmd = &ffcli.Command{
|
||||
{
|
||||
Name: "local-creds",
|
||||
Exec: runLocalCreds,
|
||||
ShortHelp: "print how to access Tailscale local API",
|
||||
ShortHelp: "print how to access Tailscale LocalAPI",
|
||||
},
|
||||
{
|
||||
Name: "restun",
|
||||
@@ -131,6 +146,17 @@ var debugCmd = &ffcli.Command{
|
||||
fs := newFlagSet("ts2021")
|
||||
fs.StringVar(&ts2021Args.host, "host", "controlplane.tailscale.com", "hostname of control plane")
|
||||
fs.IntVar(&ts2021Args.version, "version", int(tailcfg.CurrentCapabilityVersion), "protocol version")
|
||||
fs.BoolVar(&ts2021Args.verbose, "verbose", false, "be extra verbose")
|
||||
return fs
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "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")
|
||||
return fs
|
||||
})(),
|
||||
},
|
||||
@@ -168,9 +194,9 @@ func runDebug(ctx context.Context, args []string) error {
|
||||
}
|
||||
var usedFlag bool
|
||||
if out := debugArgs.cpuFile; out != "" {
|
||||
usedFlag = true // TODO(bradfitz): add "profile" subcommand
|
||||
usedFlag = true // TODO(bradfitz): add "pprof" subcommand
|
||||
log.Printf("Capturing CPU profile for %v seconds ...", debugArgs.cpuSec)
|
||||
if v, err := localClient.Profile(ctx, "profile", debugArgs.cpuSec); err != nil {
|
||||
if v, err := localClient.Pprof(ctx, "profile", debugArgs.cpuSec); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if err := writeProfile(out, v); err != nil {
|
||||
@@ -180,9 +206,9 @@ func runDebug(ctx context.Context, args []string) error {
|
||||
}
|
||||
}
|
||||
if out := debugArgs.memFile; out != "" {
|
||||
usedFlag = true // TODO(bradfitz): add "profile" subcommand
|
||||
usedFlag = true // TODO(bradfitz): add "pprof" subcommand
|
||||
log.Printf("Capturing memory profile ...")
|
||||
if v, err := localClient.Profile(ctx, "heap", 0); err != nil {
|
||||
if v, err := localClient.Pprof(ctx, "heap", 0); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if err := writeProfile(out, v); err != nil {
|
||||
@@ -203,9 +229,8 @@ func runDebug(ctx context.Context, args []string) error {
|
||||
e.Encode(wfs)
|
||||
return nil
|
||||
}
|
||||
delete := strings.HasPrefix(debugArgs.file, "delete:")
|
||||
if delete {
|
||||
return localClient.DeleteWaitingFile(ctx, strings.TrimPrefix(debugArgs.file, "delete:"))
|
||||
if name, ok := strs.CutPrefix(debugArgs.file, "delete:"); ok {
|
||||
return localClient.DeleteWaitingFile(ctx, name)
|
||||
}
|
||||
rc, size, err := localClient.GetWaitingFile(ctx, debugArgs.file)
|
||||
if err != nil {
|
||||
@@ -445,19 +470,35 @@ func runVia(ctx context.Context, args []string) error {
|
||||
var ts2021Args struct {
|
||||
host string // "controlplane.tailscale.com"
|
||||
version int // 27 or whatever
|
||||
verbose bool
|
||||
}
|
||||
|
||||
func runTS2021(ctx context.Context, args []string) error {
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetFlags(log.Ltime | log.Lmicroseconds)
|
||||
|
||||
keysURL := "https://" + ts2021Args.host + "/key?v=" + strconv.Itoa(ts2021Args.version)
|
||||
|
||||
if ts2021Args.verbose {
|
||||
u, err := url.Parse(keysURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
envConf := httpproxy.FromEnvironment()
|
||||
if *envConf == (httpproxy.Config{}) {
|
||||
log.Printf("HTTP proxy env: (none)")
|
||||
} else {
|
||||
log.Printf("HTTP proxy env: %+v", envConf)
|
||||
}
|
||||
proxy, err := tshttpproxy.ProxyFromEnvironment(&http.Request{URL: u})
|
||||
log.Printf("tshttpproxy.ProxyFromEnvironment = (%v, %v)", proxy, err)
|
||||
}
|
||||
machinePrivate := key.NewMachine()
|
||||
var dialer net.Dialer
|
||||
|
||||
var keys struct {
|
||||
PublicKey key.MachinePublic
|
||||
}
|
||||
keysURL := "https://" + ts2021Args.host + "/key?v=" + strconv.Itoa(ts2021Args.version)
|
||||
log.Printf("Fetching keys from %s ...", keysURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", keysURL, nil)
|
||||
if err != nil {
|
||||
@@ -477,6 +518,9 @@ func runTS2021(ctx context.Context, args []string) error {
|
||||
return fmt.Errorf("decoding /keys JSON: %w", err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if ts2021Args.verbose {
|
||||
log.Printf("got public key: %v", keys.PublicKey)
|
||||
}
|
||||
|
||||
dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
log.Printf("Dial(%q, %q) ...", network, address)
|
||||
@@ -488,8 +532,20 @@ func runTS2021(ctx context.Context, args []string) error {
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
conn, err := controlhttp.Dial(ctx, ts2021Args.host, "80", "443", machinePrivate, keys.PublicKey, uint16(ts2021Args.version), dialFunc)
|
||||
var logf logger.Logf
|
||||
if ts2021Args.verbose {
|
||||
logf = log.Printf
|
||||
}
|
||||
conn, err := (&controlhttp.Dialer{
|
||||
Hostname: ts2021Args.host,
|
||||
HTTPPort: "80",
|
||||
HTTPSPort: "443",
|
||||
MachineKey: machinePrivate,
|
||||
ControlKey: keys.PublicKey,
|
||||
ProtocolVersion: uint16(ts2021Args.version),
|
||||
Dialer: dialFunc,
|
||||
Logf: logf,
|
||||
}).Dial(ctx)
|
||||
log.Printf("controlhttp.Dial = %p, %v", conn, err)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -505,3 +561,48 @@ func runTS2021(ctx context.Context, args []string) error {
|
||||
log.Printf("final underlying conn: %v / %v", conn.LocalAddr(), conn.RemoteAddr())
|
||||
return nil
|
||||
}
|
||||
|
||||
var debugComponentLogsArgs struct {
|
||||
forDur time.Duration
|
||||
}
|
||||
|
||||
func runDebugComponentLogs(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: debug component-logs <component>")
|
||||
}
|
||||
component := args[0]
|
||||
dur := debugComponentLogsArgs.forDur
|
||||
|
||||
err := localClient.SetComponentDebugLogging(ctx, component, dur)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if debugComponentLogsArgs.forDur <= 0 {
|
||||
fmt.Printf("Disabled debug logs for component %q\n", component)
|
||||
} else {
|
||||
fmt.Printf("Enabled debug logs for component %q for %v\n", component, dur)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var devStoreSetArgs struct {
|
||||
danger bool
|
||||
}
|
||||
|
||||
func runDevStoreSet(ctx context.Context, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: dev-store-set --danger <key> <value>")
|
||||
}
|
||||
if !devStoreSetArgs.danger {
|
||||
return errors.New("this command is dangerous; use --danger to proceed")
|
||||
}
|
||||
key, val := args[0], args[1]
|
||||
if val == "-" {
|
||||
valb, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
val = string(valb)
|
||||
}
|
||||
return localClient.SetDevStoreKeyValue(ctx, key, val)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build linux || windows || darwin
|
||||
// +build linux windows darwin
|
||||
|
||||
package cli
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !linux && !windows && !darwin
|
||||
// +build !linux,!windows,!darwin
|
||||
|
||||
package cli
|
||||
|
||||
|
||||
@@ -22,9 +22,13 @@ var downCmd = &ffcli.Command{
|
||||
FlagSet: newDownFlagSet(),
|
||||
}
|
||||
|
||||
var downArgs struct {
|
||||
acceptedRisks string
|
||||
}
|
||||
|
||||
func newDownFlagSet() *flag.FlagSet {
|
||||
downf := newFlagSet("down")
|
||||
registerAcceptRiskFlag(downf)
|
||||
registerAcceptRiskFlag(downf, &downArgs.acceptedRisks)
|
||||
return downf
|
||||
}
|
||||
|
||||
@@ -34,7 +38,7 @@ func runDown(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
if isSSHOverTailscale() {
|
||||
if err := presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will disable Tailscale and result in your session disconnecting.`); err != nil {
|
||||
if err := presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will disable Tailscale and result in your session disconnecting.`, downArgs.acceptedRisks); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,10 @@ import (
|
||||
"golang.org/x/time/rate"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/quarantine"
|
||||
"tailscale.com/util/strs"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
@@ -76,10 +77,10 @@ func runCp(ctx context.Context, args []string) error {
|
||||
return errors.New("usage: tailscale file cp <files...> <target>:")
|
||||
}
|
||||
files, target := args[:len(args)-1], args[len(args)-1]
|
||||
if !strings.HasSuffix(target, ":") {
|
||||
target, ok := strs.CutSuffix(target, ":")
|
||||
if !ok {
|
||||
return fmt.Errorf("final argument to 'tailscale file cp' must end in colon")
|
||||
}
|
||||
target = strings.TrimSuffix(target, ":")
|
||||
hadBrackets := false
|
||||
if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
|
||||
hadBrackets = true
|
||||
@@ -393,6 +394,10 @@ func receiveFile(ctx context.Context, wf apitype.WaitingFile, dir string) (targe
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
// Apply quarantine attribute before copying
|
||||
if err := quarantine.SetOnFile(f); err != nil {
|
||||
return "", 0, fmt.Errorf("failed to apply quarantine attribute to file %v: %v", f.Name(), err)
|
||||
}
|
||||
_, err = io.Copy(f, rc)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
@@ -523,30 +528,16 @@ func wipeInbox(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func waitForFile(ctx context.Context) error {
|
||||
c, bc, pumpCtx, cancel := connect(ctx)
|
||||
defer cancel()
|
||||
fileWaiting := make(chan bool, 1)
|
||||
notifyError := make(chan error, 1)
|
||||
bc.SetNotifyCallback(func(n ipn.Notify) {
|
||||
if n.ErrMessage != nil {
|
||||
notifyError <- fmt.Errorf("Notify.ErrMessage: %v", *n.ErrMessage)
|
||||
for {
|
||||
ff, err := localClient.AwaitWaitingFiles(ctx, time.Hour)
|
||||
if len(ff) > 0 {
|
||||
return nil
|
||||
}
|
||||
if n.FilesWaiting != nil {
|
||||
select {
|
||||
case fileWaiting <- true:
|
||||
default:
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
})
|
||||
go pump(pumpCtx, bc, c)
|
||||
select {
|
||||
case <-fileWaiting:
|
||||
return nil
|
||||
case <-pumpCtx.Done():
|
||||
return pumpCtx.Err()
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-notifyError:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
32
cmd/tailscale/cli/login.go
Normal file
32
cmd/tailscale/cli/login.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
)
|
||||
|
||||
var loginArgs upArgsT
|
||||
|
||||
var loginCmd = &ffcli.Command{
|
||||
Name: "login",
|
||||
ShortUsage: "[ALPHA] login [flags]",
|
||||
ShortHelp: "Log in to a Tailscale account",
|
||||
LongHelp: `"tailscale login" logs this machine in to your Tailscale network.
|
||||
This command is currently in alpha and may change in the future.`,
|
||||
UsageFunc: usageFunc,
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
return newUpFlagSet(effectiveGOOS(), &loginArgs, "login")
|
||||
}(),
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
if err := localClient.SwitchToEmptyProfile(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return runUp(ctx, "login", args, loginArgs)
|
||||
},
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
@@ -134,6 +133,9 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
|
||||
printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP)
|
||||
printf("\t* HairPinning: %v\n", report.HairPinning)
|
||||
printf("\t* PortMapping: %v\n", portMapping(report))
|
||||
if report.CaptivePortal != "" {
|
||||
printf("\t* CaptivePortal: %v\n", report.CaptivePortal)
|
||||
}
|
||||
|
||||
// When DERP latency checking failed,
|
||||
// magicsock will try to pick the DERP server that
|
||||
@@ -202,7 +204,7 @@ func prodDERPMap(ctx context.Context, httpc *http.Client) (*tailcfg.DERPMap, err
|
||||
return nil, fmt.Errorf("fetch prodDERPMap failed: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
b, err := ioutil.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
b, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch prodDERPMap failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,22 +6,38 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mattn/go-colorable"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
var netlockCmd = &ffcli.Command{
|
||||
Name: "lock",
|
||||
ShortUsage: "lock <sub-command> <arguments>",
|
||||
ShortHelp: "Manipulate the tailnet key authority",
|
||||
Subcommands: []*ffcli.Command{nlInitCmd, nlStatusCmd},
|
||||
Exec: runNetworkLockStatus,
|
||||
Name: "lock",
|
||||
ShortUsage: "lock <sub-command> <arguments>",
|
||||
ShortHelp: "Manipulate the tailnet key authority",
|
||||
Subcommands: []*ffcli.Command{
|
||||
nlInitCmd,
|
||||
nlStatusCmd,
|
||||
nlAddCmd,
|
||||
nlRemoveCmd,
|
||||
nlSignCmd,
|
||||
nlDisableCmd,
|
||||
nlDisablementKDFCmd,
|
||||
nlLogCmd,
|
||||
},
|
||||
Exec: runNetworkLockStatus,
|
||||
}
|
||||
|
||||
var nlInitCmd = &ffcli.Command{
|
||||
@@ -40,33 +56,13 @@ func runNetworkLockInit(ctx context.Context, args []string) error {
|
||||
return errors.New("network-lock is already enabled")
|
||||
}
|
||||
|
||||
// Parse the set of initially-trusted keys.
|
||||
// Keys are specified using their key.NLPublic.MarshalText representation,
|
||||
// with an optional '?<votes>' suffix.
|
||||
var keys []tka.Key
|
||||
for i, a := range args {
|
||||
var key key.NLPublic
|
||||
spl := strings.SplitN(a, "?", 2)
|
||||
if err := key.UnmarshalText([]byte(spl[0])); err != nil {
|
||||
return fmt.Errorf("parsing key %d: %v", i+1, err)
|
||||
}
|
||||
|
||||
k := tka.Key{
|
||||
Kind: tka.Key25519,
|
||||
Public: key.Verifier(),
|
||||
Votes: 1,
|
||||
}
|
||||
if len(spl) > 1 {
|
||||
votes, err := strconv.Atoi(spl[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing key %d votes: %v", i+1, err)
|
||||
}
|
||||
k.Votes = uint(votes)
|
||||
}
|
||||
keys = append(keys, k)
|
||||
// Parse initially-trusted keys & disablement values.
|
||||
keys, disablementValues, err := parseNLArgs(args, true, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := localClient.NetworkLockInit(ctx, keys)
|
||||
status, err := localClient.NetworkLockInit(ctx, keys, disablementValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -92,10 +88,301 @@ func runNetworkLockStatus(ctx context.Context, args []string) error {
|
||||
} else {
|
||||
fmt.Println("Network-lock is NOT enabled.")
|
||||
}
|
||||
p, err := st.PublicKey.MarshalText()
|
||||
fmt.Println()
|
||||
|
||||
if st.Enabled && st.NodeKey != nil {
|
||||
if st.NodeKeySigned {
|
||||
fmt.Println("This node is trusted by network-lock.")
|
||||
} else {
|
||||
fmt.Println("This node IS NOT trusted by network-lock, and action is required to establish connectivity.")
|
||||
fmt.Printf("Run the following command on a node with a network-lock key:\n\ttailscale lock sign %v\n", st.NodeKey)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if !st.PublicKey.IsZero() {
|
||||
p, err := st.PublicKey.MarshalText()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("This node's public-key: %s\n", p)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if st.Enabled && len(st.TrustedKeys) > 0 {
|
||||
fmt.Println("Keys trusted to make changes to network-lock:")
|
||||
for _, k := range st.TrustedKeys {
|
||||
key, err := k.Key.MarshalText()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var line strings.Builder
|
||||
line.WriteString("\t")
|
||||
line.WriteString(string(key))
|
||||
line.WriteString("\t")
|
||||
line.WriteString(fmt.Sprint(k.Votes))
|
||||
line.WriteString("\t")
|
||||
if k.Key == st.PublicKey {
|
||||
line.WriteString("(us)")
|
||||
}
|
||||
fmt.Println(line.String())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var nlAddCmd = &ffcli.Command{
|
||||
Name: "add",
|
||||
ShortUsage: "add <public-key>...",
|
||||
ShortHelp: "Adds one or more signing keys to the tailnet key authority",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
return runNetworkLockModify(ctx, args, nil)
|
||||
},
|
||||
}
|
||||
|
||||
var nlRemoveCmd = &ffcli.Command{
|
||||
Name: "remove",
|
||||
ShortUsage: "remove <public-key>...",
|
||||
ShortHelp: "Removes one or more signing keys to the tailnet key authority",
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
return runNetworkLockModify(ctx, nil, args)
|
||||
},
|
||||
}
|
||||
|
||||
// parseNLArgs parses a slice of strings into slices of tka.Key & disablement
|
||||
// values/secrets.
|
||||
// The keys encoded in args should be specified using their key.NLPublic.MarshalText
|
||||
// representation with an optional '?<votes>' suffix.
|
||||
// Disablement values or secrets must be encoded in hex with a prefix of 'disablement:' or
|
||||
// 'disablement-secret:'.
|
||||
//
|
||||
// If any element could not be parsed,
|
||||
// a nil slice is returned along with an appropriate error.
|
||||
func parseNLArgs(args []string, parseKeys, parseDisablements bool) (keys []tka.Key, disablements [][]byte, err error) {
|
||||
for i, a := range args {
|
||||
if parseDisablements && (strings.HasPrefix(a, "disablement:") || strings.HasPrefix(a, "disablement-secret:")) {
|
||||
b, err := hex.DecodeString(a[strings.Index(a, ":")+1:])
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parsing disablement %d: %v", i+1, err)
|
||||
}
|
||||
disablements = append(disablements, b)
|
||||
continue
|
||||
}
|
||||
|
||||
if !parseKeys {
|
||||
return nil, nil, fmt.Errorf("parsing argument %d: expected value with \"disablement:\" or \"disablement-secret:\" prefix, got %q", i+1, a)
|
||||
}
|
||||
|
||||
var nlpk key.NLPublic
|
||||
spl := strings.SplitN(a, "?", 2)
|
||||
if err := nlpk.UnmarshalText([]byte(spl[0])); err != nil {
|
||||
return nil, nil, fmt.Errorf("parsing key %d: %v", i+1, err)
|
||||
}
|
||||
|
||||
k := tka.Key{
|
||||
Kind: tka.Key25519,
|
||||
Public: nlpk.Verifier(),
|
||||
Votes: 1,
|
||||
}
|
||||
if len(spl) > 1 {
|
||||
votes, err := strconv.Atoi(spl[1])
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parsing key %d votes: %v", i+1, err)
|
||||
}
|
||||
k.Votes = uint(votes)
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, disablements, nil
|
||||
}
|
||||
|
||||
func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) error {
|
||||
st, err := localClient.NetworkLockStatus(ctx)
|
||||
if err != nil {
|
||||
return fixTailscaledConnectError(err)
|
||||
}
|
||||
if !st.Enabled {
|
||||
return errors.New("network-lock is not enabled")
|
||||
}
|
||||
|
||||
addKeys, _, err := parseNLArgs(addArgs, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("our public-key: %s\n", p)
|
||||
removeKeys, _, err := parseNLArgs(removeArgs, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := localClient.NetworkLockModify(ctx, addKeys, removeKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Status: %+v\n\n", status)
|
||||
return nil
|
||||
}
|
||||
|
||||
var nlSignCmd = &ffcli.Command{
|
||||
Name: "sign",
|
||||
ShortUsage: "sign <node-key> [<rotation-key>]",
|
||||
ShortHelp: "Signs a node-key and transmits that signature to the control plane",
|
||||
Exec: runNetworkLockSign,
|
||||
}
|
||||
|
||||
func runNetworkLockSign(ctx context.Context, args []string) error {
|
||||
var (
|
||||
nodeKey key.NodePublic
|
||||
rotationKey key.NLPublic
|
||||
)
|
||||
|
||||
if len(args) == 0 || len(args) > 2 {
|
||||
return errors.New("usage: lock sign <node-key> [<rotation-key>]")
|
||||
}
|
||||
if err := nodeKey.UnmarshalText([]byte(args[0])); err != nil {
|
||||
return fmt.Errorf("decoding node-key: %w", err)
|
||||
}
|
||||
if len(args) > 1 {
|
||||
if err := rotationKey.UnmarshalText([]byte(args[1])); err != nil {
|
||||
return fmt.Errorf("decoding rotation-key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return localClient.NetworkLockSign(ctx, nodeKey, []byte(rotationKey.Verifier()))
|
||||
}
|
||||
|
||||
var nlDisableCmd = &ffcli.Command{
|
||||
Name: "disable",
|
||||
ShortUsage: "disable <disablement-secret>",
|
||||
ShortHelp: "Consumes a disablement secret to shut down network-lock across the tailnet",
|
||||
Exec: runNetworkLockDisable,
|
||||
}
|
||||
|
||||
func runNetworkLockDisable(ctx context.Context, args []string) error {
|
||||
_, secrets, err := parseNLArgs(args, false, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(secrets) != 1 {
|
||||
return errors.New("usage: lock disable <disablement-secret>")
|
||||
}
|
||||
return localClient.NetworkLockDisable(ctx, secrets[0])
|
||||
}
|
||||
|
||||
var nlDisablementKDFCmd = &ffcli.Command{
|
||||
Name: "disablement-kdf",
|
||||
ShortUsage: "disablement-kdf <hex-encoded-disablement-secret>",
|
||||
ShortHelp: "Computes a disablement value from a disablement secret",
|
||||
Exec: runNetworkLockDisablementKDF,
|
||||
}
|
||||
|
||||
func runNetworkLockDisablementKDF(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: lock disablement-kdf <hex-encoded-disablement-secret>")
|
||||
}
|
||||
secret, err := hex.DecodeString(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("disablement:%x\n", tka.DisablementKDF(secret))
|
||||
return nil
|
||||
}
|
||||
|
||||
var nlLogArgs struct {
|
||||
limit int
|
||||
}
|
||||
|
||||
var nlLogCmd = &ffcli.Command{
|
||||
Name: "log",
|
||||
ShortUsage: "log [--limit N]",
|
||||
ShortHelp: "List changes applied to network-lock",
|
||||
Exec: runNetworkLockLog,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("lock log")
|
||||
fs.IntVar(&nlLogArgs.limit, "limit", 50, "max number of updates to list")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
|
||||
func nlDescribeUpdate(update ipnstate.NetworkLockUpdate, color bool) (string, error) {
|
||||
terminalYellow := ""
|
||||
terminalClear := ""
|
||||
if color {
|
||||
terminalYellow = "\x1b[33m"
|
||||
terminalClear = "\x1b[0m"
|
||||
}
|
||||
|
||||
var stanza strings.Builder
|
||||
printKey := func(key *tka.Key, prefix string) {
|
||||
fmt.Fprintf(&stanza, "%sType: %s\n", prefix, key.Kind.String())
|
||||
fmt.Fprintf(&stanza, "%sKeyID: %x\n", prefix, key.ID())
|
||||
fmt.Fprintf(&stanza, "%sVotes: %d\n", prefix, key.Votes)
|
||||
if key.Meta != nil {
|
||||
fmt.Fprintf(&stanza, "%sMetadata: %+v\n", prefix, key.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
var aum tka.AUM
|
||||
if err := aum.Unserialize(update.Raw); err != nil {
|
||||
return "", fmt.Errorf("decoding: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&stanza, "%supdate %x (%s)%s\n", terminalYellow, update.Hash, update.Change, terminalClear)
|
||||
|
||||
switch update.Change {
|
||||
case tka.AUMAddKey.String():
|
||||
printKey(aum.Key, "")
|
||||
case tka.AUMRemoveKey.String():
|
||||
fmt.Fprintf(&stanza, "KeyID: %x\n", aum.KeyID)
|
||||
|
||||
case tka.AUMUpdateKey.String():
|
||||
fmt.Fprintf(&stanza, "KeyID: %x\n", aum.KeyID)
|
||||
if aum.Votes != nil {
|
||||
fmt.Fprintf(&stanza, "Votes: %d\n", aum.Votes)
|
||||
}
|
||||
if aum.Meta != nil {
|
||||
fmt.Fprintf(&stanza, "Metadata: %+v\n", aum.Meta)
|
||||
}
|
||||
|
||||
case tka.AUMCheckpoint.String():
|
||||
fmt.Fprintln(&stanza, "Disablement values:")
|
||||
for _, v := range aum.State.DisablementSecrets {
|
||||
fmt.Fprintf(&stanza, " - %x\n", v)
|
||||
}
|
||||
fmt.Fprintln(&stanza, "Keys:")
|
||||
for _, k := range aum.State.Keys {
|
||||
printKey(&k, " ")
|
||||
}
|
||||
|
||||
default:
|
||||
// Print a JSON encoding of the AUM as a fallback.
|
||||
e := json.NewEncoder(&stanza)
|
||||
e.SetIndent("", "\t")
|
||||
if err := e.Encode(aum); err != nil {
|
||||
return "", err
|
||||
}
|
||||
stanza.WriteRune('\n')
|
||||
}
|
||||
|
||||
return stanza.String(), nil
|
||||
}
|
||||
|
||||
func runNetworkLockLog(ctx context.Context, args []string) error {
|
||||
updates, err := localClient.NetworkLockLog(ctx, nlLogArgs.limit)
|
||||
if err != nil {
|
||||
return fixTailscaledConnectError(err)
|
||||
}
|
||||
useColor := isatty.IsTerminal(os.Stdout.Fd())
|
||||
|
||||
stdOut := colorable.NewColorableStdout()
|
||||
for _, update := range updates {
|
||||
stanza, err := nlDescribeUpdate(update, useColor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(stdOut, stanza)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
riskTypes []string
|
||||
acceptedRisks string
|
||||
riskLoseSSH = registerRiskType("lose-ssh")
|
||||
riskTypes []string
|
||||
riskLoseSSH = registerRiskType("lose-ssh")
|
||||
riskAll = registerRiskType("all")
|
||||
)
|
||||
|
||||
func registerRiskType(riskType string) string {
|
||||
@@ -28,14 +28,15 @@ func registerRiskType(riskType string) string {
|
||||
|
||||
// registerAcceptRiskFlag registers the --accept-risk flag. Accepted risks are accounted for
|
||||
// in presentRiskToUser.
|
||||
func registerAcceptRiskFlag(f *flag.FlagSet) {
|
||||
f.StringVar(&acceptedRisks, "accept-risk", "", "accept risk and skip confirmation for risk types: "+strings.Join(riskTypes, ","))
|
||||
func registerAcceptRiskFlag(f *flag.FlagSet, acceptedRisks *string) {
|
||||
f.StringVar(acceptedRisks, "accept-risk", "", "accept risk and skip confirmation for risk types: "+strings.Join(riskTypes, ","))
|
||||
}
|
||||
|
||||
// riskAccepted reports whether riskType is in acceptedRisks.
|
||||
func riskAccepted(riskType string) bool {
|
||||
// isRiskAccepted reports whether riskType is in the comma-separated list of
|
||||
// risks in acceptedRisks.
|
||||
func isRiskAccepted(riskType, acceptedRisks string) bool {
|
||||
for _, r := range strings.Split(acceptedRisks, ",") {
|
||||
if r == riskType {
|
||||
if r == riskType || r == riskAll {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -49,12 +50,16 @@ var errAborted = errors.New("aborted, no changes made")
|
||||
// It is used by the presentRiskToUser function below.
|
||||
const riskAbortTimeSeconds = 5
|
||||
|
||||
// presentRiskToUser displays the risk message and waits for the user to
|
||||
// cancel. It returns errorAborted if the user aborts.
|
||||
func presentRiskToUser(riskType, riskMessage string) error {
|
||||
if riskAccepted(riskType) {
|
||||
// presentRiskToUser displays the risk message and waits for the user to cancel.
|
||||
// It returns errorAborted if the user aborts. In tests it returns errAborted
|
||||
// immediately unless the risk has been explicitly accepted.
|
||||
func presentRiskToUser(riskType, riskMessage, acceptedRisks string) error {
|
||||
if isRiskAccepted(riskType, acceptedRisks) {
|
||||
return nil
|
||||
}
|
||||
if inTest() {
|
||||
return errAborted
|
||||
}
|
||||
outln(riskMessage)
|
||||
printf("To skip this warning, use --accept-risk=%s\n", riskType)
|
||||
|
||||
|
||||
711
cmd/tailscale/cli/serve.go
Normal file
711
cmd/tailscale/cli/serve.go
Normal file
@@ -0,0 +1,711 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
var serveCmd = newServeCommand(&serveEnv{})
|
||||
|
||||
// newServeCommand returns a new "serve" subcommand using e as its environmment.
|
||||
func newServeCommand(e *serveEnv) *ffcli.Command {
|
||||
return &ffcli.Command{
|
||||
Name: "serve",
|
||||
ShortHelp: "[ALPHA] Serve from your Tailscale node",
|
||||
ShortUsage: strings.TrimSpace(`
|
||||
serve [flags] <mount-point> {proxy|path|text} <arg>
|
||||
serve [flags] <sub-command> [sub-flags] <args>`),
|
||||
LongHelp: strings.TrimSpace(`
|
||||
*** ALPHA; all of this is subject to change ***
|
||||
|
||||
The 'tailscale serve' set of commands allows you to serve
|
||||
content and local servers from your Tailscale node to
|
||||
your tailnet.
|
||||
|
||||
You can also choose to enable the Tailscale Funnel with:
|
||||
'tailscale serve funnel on'. Funnel allows you to publish
|
||||
a 'tailscale serve' server publicly, open to the entire
|
||||
internet. See https://tailscale.com/funnel.
|
||||
|
||||
EXAMPLES
|
||||
- To proxy requests to a web server at 127.0.0.1:3000:
|
||||
$ tailscale serve / proxy 3000
|
||||
|
||||
- To serve a single file or a directory of files:
|
||||
$ tailscale serve / path /home/alice/blog/index.html
|
||||
$ tailscale serve /images/ path /home/alice/blog/images
|
||||
|
||||
- To serve simple static text:
|
||||
$ tailscale serve / text "Hello, world!"
|
||||
`),
|
||||
Exec: e.runServe,
|
||||
FlagSet: e.newFlags("serve", func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&e.remove, "remove", false, "remove an existing serve config")
|
||||
fs.UintVar(&e.servePort, "serve-port", 443, "port to serve on (443, 8443 or 10000)")
|
||||
}),
|
||||
UsageFunc: usageFunc,
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "status",
|
||||
Exec: e.runServeStatus,
|
||||
ShortHelp: "show current serve status",
|
||||
FlagSet: e.newFlags("serve-status", func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&e.json, "json", false, "output JSON")
|
||||
}),
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
{
|
||||
Name: "tcp",
|
||||
Exec: e.runServeTCP,
|
||||
ShortHelp: "add or remove a TCP port forward",
|
||||
LongHelp: strings.Join([]string{
|
||||
"EXAMPLES",
|
||||
" - Forward TLS over TCP to a local TCP server on port 5432:",
|
||||
" $ tailscale serve tcp 5432",
|
||||
"",
|
||||
" - Forward raw, TLS-terminated TCP packets to a local TCP server on port 5432:",
|
||||
" $ tailscale serve --terminate-tls tcp 5432",
|
||||
}, "\n"),
|
||||
FlagSet: e.newFlags("serve-tcp", func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&e.terminateTLS, "terminate-tls", false, "terminate TLS before forwarding TCP connection")
|
||||
}),
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
{
|
||||
Name: "funnel",
|
||||
Exec: e.runServeFunnel,
|
||||
ShortUsage: "funnel [flags] {on|off}",
|
||||
ShortHelp: "turn Tailscale Funnel on or off",
|
||||
LongHelp: strings.Join([]string{
|
||||
"Funnel allows you to publish a 'tailscale serve'",
|
||||
"server publicly, open to the entire internet.",
|
||||
"",
|
||||
"Turning off Funnel only turns off serving to the internet.",
|
||||
"It does not affect serving to your tailnet.",
|
||||
}, "\n"),
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *serveEnv) newFlags(name string, setup func(fs *flag.FlagSet)) *flag.FlagSet {
|
||||
onError, out := flag.ExitOnError, Stderr
|
||||
if e.testFlagOut != nil {
|
||||
onError, out = flag.ContinueOnError, e.testFlagOut
|
||||
}
|
||||
fs := flag.NewFlagSet(name, onError)
|
||||
fs.SetOutput(out)
|
||||
if setup != nil {
|
||||
setup(fs)
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
// serveEnv is the environment the serve command runs within. All I/O should be
|
||||
// done via serveEnv methods so that it can be faked out for tests.
|
||||
//
|
||||
// It also contains the flags, as registered with newServeCommand.
|
||||
type serveEnv struct {
|
||||
// flags
|
||||
servePort uint // Port to serve on. Defaults to 443.
|
||||
terminateTLS bool
|
||||
remove bool // remove a serve config
|
||||
json bool // output JSON (status only for now)
|
||||
|
||||
// optional stuff for tests:
|
||||
testFlagOut io.Writer
|
||||
testGetServeConfig func(context.Context) (*ipn.ServeConfig, error)
|
||||
testSetServeConfig func(context.Context, *ipn.ServeConfig) error
|
||||
testGetLocalClientStatus func(context.Context) (*ipnstate.Status, error)
|
||||
testStdout io.Writer
|
||||
}
|
||||
|
||||
// getSelfDNSName returns the DNS name of the current node.
|
||||
// The trailing dot is removed.
|
||||
// Returns an error if local client status fails.
|
||||
func (e *serveEnv) getSelfDNSName(ctx context.Context) (string, error) {
|
||||
st, err := e.getLocalClientStatus(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting client status: %w", err)
|
||||
}
|
||||
return strings.TrimSuffix(st.Self.DNSName, "."), nil
|
||||
}
|
||||
|
||||
// getLocalClientStatus calls LocalClient.Status, checks if
|
||||
// Status is ready.
|
||||
// Returns error if unable to reach tailscaled or if self node is nil.
|
||||
// Exits if status is not running or starting.
|
||||
func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status, error) {
|
||||
if e.testGetLocalClientStatus != nil {
|
||||
return e.testGetLocalClientStatus(ctx)
|
||||
}
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return nil, fixTailscaledConnectError(err)
|
||||
}
|
||||
description, ok := isRunningOrStarting(st)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", description)
|
||||
os.Exit(1)
|
||||
}
|
||||
if st.Self == nil {
|
||||
return nil, errors.New("no self node")
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (e *serveEnv) getServeConfig(ctx context.Context) (*ipn.ServeConfig, error) {
|
||||
if e.testGetServeConfig != nil {
|
||||
return e.testGetServeConfig(ctx)
|
||||
}
|
||||
return localClient.GetServeConfig(ctx)
|
||||
}
|
||||
|
||||
func (e *serveEnv) setServeConfig(ctx context.Context, c *ipn.ServeConfig) error {
|
||||
if e.testSetServeConfig != nil {
|
||||
return e.testSetServeConfig(ctx, c)
|
||||
}
|
||||
return localClient.SetServeConfig(ctx, c)
|
||||
}
|
||||
|
||||
// validateServePort returns --serve-port flag value,
|
||||
// or an error if the port is not a valid port to serve on.
|
||||
func (e *serveEnv) validateServePort() (port uint16, err error) {
|
||||
// make sure e.servePort is uint16
|
||||
port = uint16(e.servePort)
|
||||
if uint(port) != e.servePort {
|
||||
return 0, fmt.Errorf("serve-port %d is out of range", e.servePort)
|
||||
}
|
||||
// make sure e.servePort is 443, 8443 or 10000
|
||||
if port != 443 && port != 8443 && port != 10000 {
|
||||
return 0, fmt.Errorf("serve-port %d is invalid; must be 443, 8443 or 10000", e.servePort)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// runServe is the entry point for the "serve" subcommand, managing Web
|
||||
// serve config types like proxy, path, and text.
|
||||
//
|
||||
// Examples:
|
||||
// - tailscale serve / proxy 3000
|
||||
// - tailscale serve /images/ path /var/www/images/
|
||||
// - tailscale --serve-port=10000 serve /motd.txt text "Hello, world!"
|
||||
func (e *serveEnv) runServe(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
// Undocumented debug command (not using ffcli subcommands) to set raw
|
||||
// configs from stdin for now (2022-11-13).
|
||||
if len(args) == 1 && args[0] == "set-raw" {
|
||||
valb, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sc := new(ipn.ServeConfig)
|
||||
if err := json.Unmarshal(valb, sc); err != nil {
|
||||
return fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
return localClient.SetServeConfig(ctx, sc)
|
||||
}
|
||||
|
||||
if !(len(args) == 3 || (e.remove && len(args) >= 1)) {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
srvPort, err := e.validateServePort()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srvPortStr := strconv.Itoa(int(srvPort))
|
||||
|
||||
mount, err := cleanMountPoint(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if e.remove {
|
||||
return e.handleWebServeRemove(ctx, mount)
|
||||
}
|
||||
|
||||
h := new(ipn.HTTPHandler)
|
||||
|
||||
switch args[1] {
|
||||
case "path":
|
||||
if version.IsSandboxedMacOS() {
|
||||
// don't allow path serving for now on macOS (2022-11-15)
|
||||
return fmt.Errorf("path serving is not supported if sandboxed on macOS")
|
||||
}
|
||||
if !filepath.IsAbs(args[2]) {
|
||||
fmt.Fprintf(os.Stderr, "error: path must be absolute\n\n")
|
||||
return flag.ErrHelp
|
||||
}
|
||||
fi, err := os.Stat(args[2])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid path: %v\n\n", err)
|
||||
return flag.ErrHelp
|
||||
}
|
||||
if fi.IsDir() && !strings.HasSuffix(mount, "/") {
|
||||
// dir mount points must end in /
|
||||
// for relative file links to work
|
||||
mount += "/"
|
||||
}
|
||||
h.Path = args[2]
|
||||
case "proxy":
|
||||
t, err := expandProxyTarget(args[2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.Proxy = t
|
||||
case "text":
|
||||
if args[2] == "" {
|
||||
return errors.New("unable to serve; text cannot be an empty string")
|
||||
}
|
||||
h.Text = args[2]
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: unknown serve type %q\n\n", args[1])
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
cursc, err := e.getServeConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sc := cursc.Clone() // nil if no config
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
dnsName, err := e.getSelfDNSName(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
|
||||
|
||||
if sc.IsTCPForwardingOnPort(srvPort) {
|
||||
fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n")
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: true})
|
||||
|
||||
if _, ok := sc.Web[hp]; !ok {
|
||||
mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
|
||||
}
|
||||
mak.Set(&sc.Web[hp].Handlers, mount, h)
|
||||
|
||||
for k, v := range sc.Web[hp].Handlers {
|
||||
if v == h {
|
||||
continue
|
||||
}
|
||||
// If the new mount point ends in / and another mount point
|
||||
// shares the same prefix, remove the other handler.
|
||||
// (e.g. /foo/ overwrites /foo)
|
||||
// The opposite example is also handled.
|
||||
m1 := strings.TrimSuffix(mount, "/")
|
||||
m2 := strings.TrimSuffix(k, "/")
|
||||
if m1 == m2 {
|
||||
delete(sc.Web[hp].Handlers, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(cursc, sc) {
|
||||
if err := e.setServeConfig(ctx, sc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error {
|
||||
srvPort, err := e.validateServePort()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srvPortStr := strconv.Itoa(int(srvPort))
|
||||
sc, err := e.getServeConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sc == nil {
|
||||
return errors.New("error: serve config does not exist")
|
||||
}
|
||||
dnsName, err := e.getSelfDNSName(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sc.IsTCPForwardingOnPort(srvPort) {
|
||||
return errors.New("cannot remove web handler; currently serving TCP")
|
||||
}
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
|
||||
if !sc.WebHandlerExists(hp, mount) {
|
||||
return errors.New("error: serve config does not exist")
|
||||
}
|
||||
// delete existing handler, then cascade delete if empty
|
||||
delete(sc.Web[hp].Handlers, mount)
|
||||
if len(sc.Web[hp].Handlers) == 0 {
|
||||
delete(sc.Web, hp)
|
||||
delete(sc.TCP, srvPort)
|
||||
}
|
||||
// clear empty maps mostly for testing
|
||||
if len(sc.Web) == 0 {
|
||||
sc.Web = nil
|
||||
}
|
||||
if len(sc.TCP) == 0 {
|
||||
sc.TCP = nil
|
||||
}
|
||||
if err := e.setServeConfig(ctx, sc); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanMountPoint(mount string) (string, error) {
|
||||
if mount == "" {
|
||||
return "", errors.New("mount point cannot be empty")
|
||||
}
|
||||
if !strings.HasPrefix(mount, "/") {
|
||||
mount = "/" + mount
|
||||
}
|
||||
c := path.Clean(mount)
|
||||
if mount == c || mount == c+"/" {
|
||||
return mount, nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid mount point %q", mount)
|
||||
}
|
||||
|
||||
func expandProxyTarget(target string) (string, error) {
|
||||
if allNumeric(target) {
|
||||
p, err := strconv.ParseUint(target, 10, 16)
|
||||
if p == 0 || err != nil {
|
||||
return "", fmt.Errorf("invalid port %q", target)
|
||||
}
|
||||
return "http://127.0.0.1:" + target, nil
|
||||
}
|
||||
if !strings.Contains(target, "://") {
|
||||
target = "http://" + target
|
||||
}
|
||||
u, err := url.ParseRequestURI(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing url: %w", err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http", "https", "https+insecure":
|
||||
// ok
|
||||
default:
|
||||
return "", fmt.Errorf("must be a URL starting with http://, https://, or https+insecure://")
|
||||
}
|
||||
host := u.Hostname()
|
||||
switch host {
|
||||
// TODO(shayne,bradfitz): do we want to do this?
|
||||
case "localhost", "127.0.0.1":
|
||||
host = "127.0.0.1"
|
||||
default:
|
||||
return "", fmt.Errorf("only localhost or 127.0.0.1 proxies are currently supported")
|
||||
}
|
||||
url := u.Scheme + "://" + host
|
||||
if u.Port() != "" {
|
||||
url += ":" + u.Port()
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func allNumeric(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
|
||||
// runServeStatus prints the current serve config.
|
||||
//
|
||||
// Examples:
|
||||
// - tailscale status
|
||||
// - tailscale status --json
|
||||
func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
|
||||
sc, err := e.getServeConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.json {
|
||||
j, err := json.MarshalIndent(sc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
j = append(j, '\n')
|
||||
e.stdout().Write(j)
|
||||
return nil
|
||||
}
|
||||
if sc == nil || (len(sc.TCP) == 0 && len(sc.Web) == 0 && len(sc.AllowFunnel) == 0) {
|
||||
printf("No serve config\n")
|
||||
return nil
|
||||
}
|
||||
st, err := e.getLocalClientStatus(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sc.IsTCPForwardingAny() {
|
||||
if err := printTCPStatusTree(ctx, sc, st); err != nil {
|
||||
return err
|
||||
}
|
||||
printf("\n")
|
||||
}
|
||||
for hp := range sc.Web {
|
||||
printWebStatusTree(sc, hp)
|
||||
printf("\n")
|
||||
}
|
||||
// warn when funnel on without handlers
|
||||
for hp, a := range sc.AllowFunnel {
|
||||
if !a {
|
||||
continue
|
||||
}
|
||||
_, portStr, _ := net.SplitHostPort(string(hp))
|
||||
p, _ := strconv.ParseUint(portStr, 10, 16)
|
||||
if _, ok := sc.TCP[uint16(p)]; !ok {
|
||||
printf("WARNING: funnel=on for %s, but no serve config\n", hp)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *serveEnv) stdout() io.Writer {
|
||||
if e.testStdout != nil {
|
||||
return e.testStdout
|
||||
}
|
||||
return os.Stdout
|
||||
}
|
||||
|
||||
func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.Status) error {
|
||||
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||
for p, h := range sc.TCP {
|
||||
if h.TCPForward == "" {
|
||||
continue
|
||||
}
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(p))))
|
||||
tlsStatus := "TLS over TCP"
|
||||
if h.TerminateTLS != "" {
|
||||
tlsStatus = "TLS terminated"
|
||||
}
|
||||
fStatus := "tailnet only"
|
||||
if sc.IsFunnelOn(hp) {
|
||||
fStatus = "Funnel on"
|
||||
}
|
||||
printf("|-- tcp://%s (%s, %s)\n", hp, tlsStatus, fStatus)
|
||||
for _, a := range st.TailscaleIPs {
|
||||
ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(p)))
|
||||
printf("|-- tcp://%s\n", ipp)
|
||||
}
|
||||
printf("|--> tcp://%s\n", h.TCPForward)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) {
|
||||
if sc == nil {
|
||||
return
|
||||
}
|
||||
fStatus := "tailnet only"
|
||||
if sc.IsFunnelOn(hp) {
|
||||
fStatus = "Funnel on"
|
||||
}
|
||||
host, portStr, _ := net.SplitHostPort(string(hp))
|
||||
if portStr == "443" {
|
||||
printf("https://%s (%s)\n", host, fStatus)
|
||||
} else {
|
||||
printf("https://%s:%s (%s)\n", host, portStr, fStatus)
|
||||
}
|
||||
srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) {
|
||||
switch {
|
||||
case h.Path != "":
|
||||
return "path", h.Path
|
||||
case h.Proxy != "":
|
||||
return "proxy", h.Proxy
|
||||
case h.Text != "":
|
||||
return "text", "\"" + elipticallyTruncate(h.Text, 20) + "\""
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
var mounts []string
|
||||
for k := range sc.Web[hp].Handlers {
|
||||
mounts = append(mounts, k)
|
||||
}
|
||||
sort.Slice(mounts, func(i, j int) bool {
|
||||
return len(mounts[i]) < len(mounts[j])
|
||||
})
|
||||
maxLen := len(mounts[len(mounts)-1])
|
||||
|
||||
for _, m := range mounts {
|
||||
h := sc.Web[hp].Handlers[m]
|
||||
t, d := srvTypeAndDesc(h)
|
||||
printf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d)
|
||||
}
|
||||
}
|
||||
|
||||
func elipticallyTruncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
|
||||
// runServeTCP is the entry point for the "serve tcp" subcommand and
|
||||
// manages the serve config for TCP forwarding.
|
||||
//
|
||||
// Examples:
|
||||
// - tailscale serve tcp 5432
|
||||
// - tailscale --serve-port=8443 tcp 4430
|
||||
// - tailscale --serve-port=10000 --terminate-tls tcp 8080
|
||||
func (e *serveEnv) runServeTCP(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
srvPort, err := e.validateServePort()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
portStr := args[0]
|
||||
p, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if p == 0 || err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", portStr)
|
||||
}
|
||||
|
||||
cursc, err := e.getServeConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sc := cursc.Clone() // nil if no config
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
|
||||
fwdAddr := "127.0.0.1:" + portStr
|
||||
|
||||
if sc.IsServingWeb(srvPort) {
|
||||
if e.remove {
|
||||
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", srvPort)
|
||||
}
|
||||
return fmt.Errorf("cannot serve TCP; already serving web on %d", srvPort)
|
||||
}
|
||||
|
||||
if e.remove {
|
||||
if ph := sc.GetTCPPortHandler(srvPort); ph != nil && ph.TCPForward == fwdAddr {
|
||||
delete(sc.TCP, srvPort)
|
||||
// clear map mostly for testing
|
||||
if len(sc.TCP) == 0 {
|
||||
sc.TCP = nil
|
||||
}
|
||||
return e.setServeConfig(ctx, sc)
|
||||
}
|
||||
return errors.New("error: serve config does not exist")
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{TCPForward: fwdAddr})
|
||||
|
||||
dnsName, err := e.getSelfDNSName(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.terminateTLS {
|
||||
sc.TCP[srvPort].TerminateTLS = dnsName
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(cursc, sc) {
|
||||
if err := e.setServeConfig(ctx, sc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runServeFunnel is the entry point for the "serve funnel" subcommand and
|
||||
// manages turning on/off funnel. Funnel is off by default.
|
||||
//
|
||||
// Note: funnel is only supported on single DNS name for now. (2022-11-15)
|
||||
func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
srvPort, err := e.validateServePort()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srvPortStr := strconv.Itoa(int(srvPort))
|
||||
|
||||
var on bool
|
||||
switch args[0] {
|
||||
case "on", "off":
|
||||
on = args[0] == "on"
|
||||
default:
|
||||
return flag.ErrHelp
|
||||
}
|
||||
sc, err := e.getServeConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
st, err := e.getLocalClientStatus(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting client status: %w", err)
|
||||
}
|
||||
if !slices.Contains(st.Self.Capabilities, tailcfg.NodeAttrFunnel) {
|
||||
return errors.New("Funnel not available. See https://tailscale.com/s/no-funnel")
|
||||
}
|
||||
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||
hp := ipn.HostPort(dnsName + ":" + srvPortStr)
|
||||
isFun := sc.IsFunnelOn(hp)
|
||||
if on && isFun || !on && !isFun {
|
||||
// Nothing to do.
|
||||
return nil
|
||||
}
|
||||
if on {
|
||||
mak.Set(&sc.AllowFunnel, hp, true)
|
||||
} else {
|
||||
delete(sc.AllowFunnel, hp)
|
||||
// clear map mostly for testing
|
||||
if len(sc.AllowFunnel) == 0 {
|
||||
sc.AllowFunnel = nil
|
||||
}
|
||||
}
|
||||
if err := e.setServeConfig(ctx, sc); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
676
cmd/tailscale/cli/serve_test.go
Normal file
676
cmd/tailscale/cli/serve_test.go
Normal file
@@ -0,0 +1,676 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func TestCleanMountPoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
mount string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"foo", "/foo", false}, // missing prefix
|
||||
{"/foo/", "/foo/", false}, // keep trailing slash
|
||||
{"////foo", "", true}, // too many slashes
|
||||
{"/foo//", "", true}, // too many slashes
|
||||
{"", "", true}, // empty
|
||||
{"https://tailscale.com", "", true}, // not a path
|
||||
}
|
||||
for _, tt := range tests {
|
||||
mp, err := cleanMountPoint(tt.mount)
|
||||
if err != nil && tt.wantErr {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if mp != tt.want {
|
||||
t.Fatalf("got %q, want %q", mp, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeConfigMutations(t *testing.T) {
|
||||
// Stateful mutations, starting from an empty config.
|
||||
type step struct {
|
||||
command []string // serve args; nil means no command to run (only reset)
|
||||
reset bool // if true, reset all ServeConfig state
|
||||
want *ipn.ServeConfig // non-nil means we want a save of this value
|
||||
wantErr func(error) (badErrMsg string) // nil means no error is wanted
|
||||
line int // line number of addStep call, for error messages
|
||||
}
|
||||
var steps []step
|
||||
add := func(s step) {
|
||||
_, _, s.line, _ = runtime.Caller(1)
|
||||
steps = append(steps, s)
|
||||
}
|
||||
|
||||
// funnel
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("funnel on"),
|
||||
want: &ipn.ServeConfig{AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("funnel on"),
|
||||
want: nil, // nothing to save
|
||||
})
|
||||
add(step{
|
||||
command: cmd("funnel off"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("funnel off"),
|
||||
want: nil, // nothing to save
|
||||
})
|
||||
add(step{
|
||||
command: cmd("funnel"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
})
|
||||
|
||||
// https
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/ proxy 0"), // invalid port, too low
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ proxy 65536"), // invalid port, too high
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ proxy somehost"), // invalid host
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ proxy http://otherhost"), // invalid host
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ proxy httpz://127.0.0.1"), // invalid scheme
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ proxy 3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--serve-port=9999 /abc proxy 3001"),
|
||||
wantErr: anyErr(),
|
||||
}) // invalid port
|
||||
add(step{
|
||||
command: cmd("--serve-port=8443 /abc proxy 3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--serve-port=10000 / text hi"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {HTTPS: true}, 8443: {HTTPS: true}, 10000: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
"foo.test.ts.net:10000": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Text: "hi"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--remove /foo"),
|
||||
want: nil, // nothing to save
|
||||
wantErr: anyErr(),
|
||||
}) // handler doesn't exist, so we get an error
|
||||
add(step{
|
||||
command: cmd("--remove --serve-port=10000 /"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--remove /"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--remove --serve-port=8443 /abc"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("bar proxy https://127.0.0.1:8443"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/bar": {Proxy: "https://127.0.0.1:8443"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("bar proxy https://127.0.0.1:8443"),
|
||||
want: nil, // nothing to save
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/ proxy https+insecure://127.0.0.1:3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "https+insecure://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/foo proxy localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/foo": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // test a second handler on the same port
|
||||
command: cmd("--serve-port=8443 /foo proxy localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/foo": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/foo": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// tcp
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("tcp 5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {TCPForward: "127.0.0.1:5432"},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("tcp -terminate-tls 8443"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:8443",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("tcp -terminate-tls 8443"),
|
||||
want: nil, // nothing to save
|
||||
})
|
||||
add(step{
|
||||
command: cmd("tcp --terminate-tls 8444"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:8444",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("tcp -terminate-tls=false 8445"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {TCPForward: "127.0.0.1:8445"},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("tcp 123"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {TCPForward: "127.0.0.1:123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--remove tcp 321"),
|
||||
wantErr: anyErr(),
|
||||
}) // handler doesn't exist, so we get an error
|
||||
add(step{
|
||||
command: cmd("--remove tcp 123"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
|
||||
// text
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/ text hello"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Text: "hello"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// path
|
||||
td := t.TempDir()
|
||||
writeFile := func(suffix, contents string) {
|
||||
if err := os.WriteFile(filepath.Join(td, suffix), []byte(contents), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
add(step{reset: true})
|
||||
writeFile("foo", "this is foo")
|
||||
add(step{
|
||||
command: cmd("/ path " + filepath.Join(td, "foo")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Path: filepath.Join(td, "foo")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
os.MkdirAll(filepath.Join(td, "subdir"), 0700)
|
||||
writeFile("subdir/file-a", "this is A")
|
||||
add(step{
|
||||
command: cmd("/some/where path " + filepath.Join(td, "subdir/file-a")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Path: filepath.Join(td, "foo")},
|
||||
"/some/where": {Path: filepath.Join(td, "subdir/file-a")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ path missing"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/ path " + filepath.Join(td, "subdir")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Path: filepath.Join(td, "subdir/")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--remove /"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
|
||||
// combos
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/ proxy 3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("funnel on"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // serving on secondary port doesn't change funnel
|
||||
command: cmd("--serve-port=8443 /bar proxy 3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/bar": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // turn funnel on for secondary port
|
||||
command: cmd("--serve-port=8443 funnel on"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true, "foo.test.ts.net:8443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/bar": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // turn funnel off for primary port 443
|
||||
command: cmd("funnel off"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/bar": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // remove secondary port
|
||||
command: cmd("--serve-port=8443 --remove /bar"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // start a tcp forwarder on 8443
|
||||
command: cmd("--serve-port=8443 tcp 5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "127.0.0.1:5432"}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // remove primary port http handler
|
||||
command: cmd("--remove /"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "127.0.0.1:5432"}},
|
||||
},
|
||||
})
|
||||
add(step{ // remove tcp forwarder
|
||||
command: cmd("--serve-port=8443 --remove tcp 5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
},
|
||||
})
|
||||
add(step{ // turn off funnel
|
||||
command: cmd("--serve-port=8443 funnel off"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
|
||||
// tricky steps
|
||||
add(step{reset: true})
|
||||
add(step{ // a directory with a trailing slash mount point
|
||||
command: cmd("/dir path " + filepath.Join(td, "subdir")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/dir/": {Path: filepath.Join(td, "subdir/")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // this should overwrite the previous one
|
||||
command: cmd("/dir path " + filepath.Join(td, "foo")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/dir": {Path: filepath.Join(td, "foo")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true}) // reset and do the opposite
|
||||
add(step{ // a file without a trailing slash mount point
|
||||
command: cmd("/dir path " + filepath.Join(td, "foo")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/dir": {Path: filepath.Join(td, "foo")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // this should overwrite the previous one
|
||||
command: cmd("/dir path " + filepath.Join(td, "subdir")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/dir/": {Path: filepath.Join(td, "subdir/")},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// error states
|
||||
add(step{reset: true})
|
||||
add(step{ // make sure we can't add "tcp" as if it was a mount
|
||||
command: cmd("tcp text foo"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
})
|
||||
add(step{ // "/tcp" is fine though as a mount
|
||||
command: cmd("/tcp text foo"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/tcp": {Text: "foo"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{ // tcp forward 5432 on serve port 443
|
||||
command: cmd("tcp 5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {TCPForward: "127.0.0.1:5432"},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // try to start a web handler on the same port
|
||||
command: cmd("/ proxy 3000"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{ // start a web handler on port 443
|
||||
command: cmd("/ proxy 3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // try to start a tcp forwarder on the same serve port (443 default)
|
||||
command: cmd("tcp 5432"),
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
|
||||
// And now run the steps above.
|
||||
var current *ipn.ServeConfig
|
||||
for i, st := range steps {
|
||||
if st.reset {
|
||||
t.Logf("Executing step #%d, line %v: [reset]", i, st.line)
|
||||
current = nil
|
||||
}
|
||||
if st.command == nil {
|
||||
continue
|
||||
}
|
||||
t.Logf("Executing step #%d, line %v: %q ... ", i, st.line, st.command)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var flagOut bytes.Buffer
|
||||
var newState *ipn.ServeConfig
|
||||
e := &serveEnv{
|
||||
testFlagOut: &flagOut,
|
||||
testStdout: &stdout,
|
||||
testGetLocalClientStatus: func(context.Context) (*ipnstate.Status, error) {
|
||||
return &ipnstate.Status{
|
||||
Self: &ipnstate.PeerStatus{
|
||||
DNSName: "foo.test.ts.net",
|
||||
Capabilities: []string{tailcfg.NodeAttrFunnel},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
testGetServeConfig: func(context.Context) (*ipn.ServeConfig, error) {
|
||||
return current, nil
|
||||
},
|
||||
testSetServeConfig: func(_ context.Context, c *ipn.ServeConfig) error {
|
||||
newState = c
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := newServeCommand(e)
|
||||
err := cmd.ParseAndRun(context.Background(), st.command)
|
||||
if flagOut.Len() > 0 {
|
||||
t.Logf("flag package output: %q", flagOut.Bytes())
|
||||
}
|
||||
if err != nil {
|
||||
if st.wantErr == nil {
|
||||
t.Fatalf("step #%d, line %v: unexpected error: %v", i, st.line, err)
|
||||
}
|
||||
if bad := st.wantErr(err); bad != "" {
|
||||
t.Fatalf("step #%d, line %v: unexpected error: %v", i, st.line, bad)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if st.wantErr != nil {
|
||||
t.Fatalf("step #%d, line %v: got success (saved=%v), but wanted an error", i, st.line, newState != nil)
|
||||
}
|
||||
if !reflect.DeepEqual(newState, st.want) {
|
||||
t.Fatalf("[%d] %v: bad state. got:\n%s\n\nwant:\n%s\n",
|
||||
i, st.command, asJSON(newState), asJSON(st.want))
|
||||
// NOTE: asJSON will omit empty fields, which might make
|
||||
// result in bad state got/want diffs being the same, even
|
||||
// though the actual state is different. Use below to debug:
|
||||
// t.Fatalf("[%d] %v: bad state. got:\n%+v\n\nwant:\n%+v\n",
|
||||
// i, st.command, newState, st.want)
|
||||
}
|
||||
if newState != nil {
|
||||
current = newState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exactError returns an error checker that wants exactly the provided want error.
|
||||
// If optName is non-empty, it's used in the error message.
|
||||
func exactErr(want error, optName ...string) func(error) string {
|
||||
return func(got error) string {
|
||||
if got == want {
|
||||
return ""
|
||||
}
|
||||
if len(optName) > 0 {
|
||||
return fmt.Sprintf("got error %v, want %v", got, optName[0])
|
||||
}
|
||||
return fmt.Sprintf("got error %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// anyErr returns an error checker that wants any error.
|
||||
func anyErr() func(error) string {
|
||||
return func(got error) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func cmd(s string) []string {
|
||||
cmds := strings.Fields(s)
|
||||
fmt.Printf("cmd: %v", cmds)
|
||||
return cmds
|
||||
}
|
||||
176
cmd/tailscale/cli/set.go
Normal file
176
cmd/tailscale/cli/set.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/safesocket"
|
||||
)
|
||||
|
||||
var setCmd = &ffcli.Command{
|
||||
Name: "set",
|
||||
ShortUsage: "set [flags]",
|
||||
ShortHelp: "Change specified preferences",
|
||||
LongHelp: `"tailscale set" allows changing specific preferences.
|
||||
|
||||
Unlike "tailscale up", this command does not require the complete set of desired settings.
|
||||
|
||||
Only settings explicitly mentioned will be set. There are no default values.`,
|
||||
FlagSet: setFlagSet,
|
||||
Exec: runSet,
|
||||
UsageFunc: usageFuncNoDefaultValues,
|
||||
}
|
||||
|
||||
type setArgsT struct {
|
||||
acceptRoutes bool
|
||||
acceptDNS bool
|
||||
exitNodeIP string
|
||||
exitNodeAllowLANAccess bool
|
||||
shieldsUp bool
|
||||
runSSH bool
|
||||
hostname string
|
||||
advertiseRoutes string
|
||||
advertiseDefaultRoute bool
|
||||
opUser string
|
||||
acceptedRisks string
|
||||
profileName string
|
||||
}
|
||||
|
||||
func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
|
||||
setf := newFlagSet("set")
|
||||
|
||||
setf.StringVar(&setArgs.profileName, "nickname", "", "nickname for the login profile")
|
||||
setf.BoolVar(&setArgs.acceptRoutes, "accept-routes", false, "accept routes advertised by other Tailscale nodes")
|
||||
setf.BoolVar(&setArgs.acceptDNS, "accept-dns", false, "accept DNS configuration from the admin panel")
|
||||
setf.StringVar(&setArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node")
|
||||
setf.BoolVar(&setArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
|
||||
setf.BoolVar(&setArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
|
||||
setf.BoolVar(&setArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy")
|
||||
setf.StringVar(&setArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
|
||||
setf.StringVar(&setArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes")
|
||||
setf.BoolVar(&setArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
|
||||
if safesocket.GOOSUsesPeerCreds(goos) {
|
||||
setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
|
||||
}
|
||||
registerAcceptRiskFlag(setf, &setArgs.acceptedRisks)
|
||||
return setf
|
||||
}
|
||||
|
||||
var (
|
||||
setArgs setArgsT
|
||||
setFlagSet = newSetFlagSet(effectiveGOOS(), &setArgs)
|
||||
)
|
||||
|
||||
func runSet(ctx context.Context, args []string) (retErr error) {
|
||||
if len(args) > 0 {
|
||||
fatalf("too many non-flag arguments: %q", args)
|
||||
}
|
||||
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
maskedPrefs := &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
ProfileName: setArgs.profileName,
|
||||
RouteAll: setArgs.acceptRoutes,
|
||||
CorpDNS: setArgs.acceptDNS,
|
||||
ExitNodeAllowLANAccess: setArgs.exitNodeAllowLANAccess,
|
||||
ShieldsUp: setArgs.shieldsUp,
|
||||
RunSSH: setArgs.runSSH,
|
||||
Hostname: setArgs.hostname,
|
||||
OperatorUser: setArgs.opUser,
|
||||
},
|
||||
}
|
||||
|
||||
if setArgs.exitNodeIP != "" {
|
||||
if err := maskedPrefs.Prefs.SetExitNodeIP(setArgs.exitNodeIP, st); err != nil {
|
||||
var e ipn.ExitNodeLocalIPError
|
||||
if errors.As(err, &e) {
|
||||
return fmt.Errorf("%w; did you mean --advertise-exit-node?", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var advertiseExitNodeSet, advertiseRoutesSet bool
|
||||
setFlagSet.Visit(func(f *flag.Flag) {
|
||||
updateMaskedPrefsFromUpOrSetFlag(maskedPrefs, f.Name)
|
||||
switch f.Name {
|
||||
case "advertise-exit-node":
|
||||
advertiseExitNodeSet = true
|
||||
case "advertise-routes":
|
||||
advertiseRoutesSet = true
|
||||
}
|
||||
})
|
||||
if maskedPrefs.IsEmpty() {
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
curPrefs, err := localClient.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if maskedPrefs.AdvertiseRoutesSet {
|
||||
maskedPrefs.AdvertiseRoutes, err = calcAdvertiseRoutesForSet(advertiseExitNodeSet, advertiseRoutesSet, curPrefs, setArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if maskedPrefs.RunSSHSet {
|
||||
wantSSH, haveSSH := maskedPrefs.RunSSH, curPrefs.RunSSH
|
||||
if err := presentSSHToggleRisk(wantSSH, haveSSH, setArgs.acceptedRisks); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
checkPrefs := curPrefs.Clone()
|
||||
checkPrefs.ApplyEdits(maskedPrefs)
|
||||
if err := localClient.CheckPrefs(ctx, checkPrefs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = localClient.EditPrefs(ctx, maskedPrefs)
|
||||
return err
|
||||
}
|
||||
|
||||
// calcAdvertiseRoutesForSet returns the new value for Prefs.AdvertiseRoutes based on the
|
||||
// current value, the flags passed to "tailscale set".
|
||||
// advertiseExitNodeSet is whether the --advertise-exit-node flag was set.
|
||||
// advertiseRoutesSet is whether the --advertise-routes flag was set.
|
||||
// curPrefs is the current Prefs.
|
||||
// setArgs is the parsed command-line arguments.
|
||||
func calcAdvertiseRoutesForSet(advertiseExitNodeSet, advertiseRoutesSet bool, curPrefs *ipn.Prefs, setArgs setArgsT) (routes []netip.Prefix, err error) {
|
||||
if advertiseExitNodeSet && advertiseRoutesSet {
|
||||
return calcAdvertiseRoutes(setArgs.advertiseRoutes, setArgs.advertiseDefaultRoute)
|
||||
|
||||
}
|
||||
if advertiseRoutesSet {
|
||||
return calcAdvertiseRoutes(setArgs.advertiseRoutes, curPrefs.AdvertisesExitNode())
|
||||
}
|
||||
if advertiseExitNodeSet {
|
||||
alreadyAdvertisesExitNode := curPrefs.AdvertisesExitNode()
|
||||
if alreadyAdvertisesExitNode == setArgs.advertiseDefaultRoute {
|
||||
return curPrefs.AdvertiseRoutes, nil
|
||||
}
|
||||
routes = tsaddr.FilterPrefixesCopy(curPrefs.AdvertiseRoutes, func(p netip.Prefix) bool {
|
||||
return p.Bits() != 0
|
||||
})
|
||||
if setArgs.advertiseDefaultRoute {
|
||||
routes = append(routes, tsaddr.AllIPv4(), tsaddr.AllIPv6())
|
||||
}
|
||||
return routes, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
133
cmd/tailscale/cli/set_test.go
Normal file
133
cmd/tailscale/cli/set_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/tsaddr"
|
||||
)
|
||||
|
||||
func ptrTo[T any](v T) *T { return &v }
|
||||
|
||||
func TestCalcAdvertiseRoutesForSet(t *testing.T) {
|
||||
pfx := netip.MustParsePrefix
|
||||
tests := []struct {
|
||||
name string
|
||||
setExit *bool
|
||||
setRoutes *string
|
||||
was []netip.Prefix
|
||||
want []netip.Prefix
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
},
|
||||
{
|
||||
name: "advertise-exit",
|
||||
setExit: ptrTo(true),
|
||||
want: tsaddr.ExitRoutes(),
|
||||
},
|
||||
{
|
||||
name: "advertise-exit/already-routes",
|
||||
was: []netip.Prefix{pfx("34.0.0.0/16")},
|
||||
setExit: ptrTo(true),
|
||||
want: []netip.Prefix{pfx("34.0.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()},
|
||||
},
|
||||
{
|
||||
name: "advertise-exit/already-exit",
|
||||
was: tsaddr.ExitRoutes(),
|
||||
setExit: ptrTo(true),
|
||||
want: tsaddr.ExitRoutes(),
|
||||
},
|
||||
{
|
||||
name: "stop-advertise-exit",
|
||||
was: tsaddr.ExitRoutes(),
|
||||
setExit: ptrTo(false),
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "stop-advertise-exit/with-routes",
|
||||
was: []netip.Prefix{pfx("34.0.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()},
|
||||
setExit: ptrTo(false),
|
||||
want: []netip.Prefix{pfx("34.0.0.0/16")},
|
||||
},
|
||||
{
|
||||
name: "advertise-routes",
|
||||
setRoutes: ptrTo("10.0.0.0/24,192.168.0.0/16"),
|
||||
want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16")},
|
||||
},
|
||||
{
|
||||
name: "advertise-routes/already-exit",
|
||||
was: tsaddr.ExitRoutes(),
|
||||
setRoutes: ptrTo("10.0.0.0/24,192.168.0.0/16"),
|
||||
want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()},
|
||||
},
|
||||
{
|
||||
name: "advertise-routes/already-diff-routes",
|
||||
was: []netip.Prefix{pfx("34.0.0.0/16")},
|
||||
setRoutes: ptrTo("10.0.0.0/24,192.168.0.0/16"),
|
||||
want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16")},
|
||||
},
|
||||
{
|
||||
name: "stop-advertise-routes",
|
||||
was: []netip.Prefix{pfx("34.0.0.0/16")},
|
||||
setRoutes: ptrTo(""),
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "stop-advertise-routes/already-exit",
|
||||
was: []netip.Prefix{pfx("34.0.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()},
|
||||
setRoutes: ptrTo(""),
|
||||
want: tsaddr.ExitRoutes(),
|
||||
},
|
||||
{
|
||||
name: "advertise-routes-and-exit",
|
||||
setExit: ptrTo(true),
|
||||
setRoutes: ptrTo("10.0.0.0/24,192.168.0.0/16"),
|
||||
want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()},
|
||||
},
|
||||
{
|
||||
name: "advertise-routes-and-exit/already-exit",
|
||||
was: tsaddr.ExitRoutes(),
|
||||
setExit: ptrTo(true),
|
||||
setRoutes: ptrTo("10.0.0.0/24,192.168.0.0/16"),
|
||||
want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()},
|
||||
},
|
||||
{
|
||||
name: "advertise-routes-and-exit/already-routes",
|
||||
was: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16")},
|
||||
setExit: ptrTo(true),
|
||||
setRoutes: ptrTo("10.0.0.0/24,192.168.0.0/16"),
|
||||
want: []netip.Prefix{pfx("10.0.0.0/24"), pfx("192.168.0.0/16"), tsaddr.AllIPv4(), tsaddr.AllIPv6()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
curPrefs := &ipn.Prefs{
|
||||
AdvertiseRoutes: tc.was,
|
||||
}
|
||||
sa := setArgsT{}
|
||||
if tc.setExit != nil {
|
||||
sa.advertiseDefaultRoute = *tc.setExit
|
||||
}
|
||||
if tc.setRoutes != nil {
|
||||
sa.advertiseRoutes = *tc.setRoutes
|
||||
}
|
||||
got, err := calcAdvertiseRoutesForSet(tc.setExit != nil, tc.setRoutes != nil, curPrefs, sa)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tsaddr.SortPrefixes(got)
|
||||
tsaddr.SortPrefixes(tc.want)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,23 @@ var sshCmd = &ffcli.Command{
|
||||
Name: "ssh",
|
||||
ShortUsage: "ssh [user@]<host> [args...]",
|
||||
ShortHelp: "SSH to a Tailscale machine",
|
||||
Exec: runSSH,
|
||||
LongHelp: strings.TrimSpace(`
|
||||
|
||||
The 'tailscale ssh' command is an optional wrapper around the system 'ssh'
|
||||
command that's useful in some cases. Tailscale SSH does not require its use;
|
||||
most users running the Tailscale SSH server will prefer to just use the normal
|
||||
'ssh' command or their normal SSH client.
|
||||
|
||||
The 'tailscale ssh' wrapper adds a few things:
|
||||
|
||||
* It resolves the destination server name in its arugments using MagicDNS,
|
||||
even if --accept-dns=false.
|
||||
* It works in userspace-networking mode, by supplying a ProxyCommand to the
|
||||
system 'ssh' command that connects via a pipe through tailscaled.
|
||||
* It automatically checks the destination server's SSH host key against the
|
||||
node's SSH host key as advertised via the Tailscale coordination server.
|
||||
`),
|
||||
Exec: runSSH,
|
||||
}
|
||||
|
||||
func runSSH(ctx context.Context, args []string) error {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !js && !windows
|
||||
// +build !js,!windows
|
||||
|
||||
package cli
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
func findSSH() (string, error) {
|
||||
// use C:\Windows\System32\OpenSSH\ssh.exe since unexpected behavior
|
||||
// occured with ssh.exe provided by msys2/cygwin and other environments.
|
||||
// occurred with ssh.exe provided by msys2/cygwin and other environments.
|
||||
if systemRoot := os.Getenv("SystemRoot"); systemRoot != "" {
|
||||
exe := filepath.Join(systemRoot, "System32", "OpenSSH", "ssh.exe")
|
||||
if st, err := os.Stat(exe); err == nil && !st.IsDir() {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !js && !windows
|
||||
// +build !js,!windows
|
||||
|
||||
package cli
|
||||
|
||||
|
||||
@@ -128,18 +128,21 @@ func runStatus(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// print health check information prior to checking LocalBackend state as
|
||||
// it may provide an explanation to the user if we choose to exit early
|
||||
if len(st.Health) > 0 {
|
||||
printHealth := func() {
|
||||
printf("# Health check:\n")
|
||||
for _, m := range st.Health {
|
||||
printf("# - %s\n", m)
|
||||
}
|
||||
outln()
|
||||
}
|
||||
|
||||
description, ok := isRunningOrStarting(st)
|
||||
if !ok {
|
||||
// print health check information if we're in a weird state, as it might
|
||||
// provide context about why we're in that weird state.
|
||||
if len(st.Health) > 0 && (st.BackendState == ipn.Starting.String() || st.BackendState == ipn.NoState.String()) {
|
||||
printHealth()
|
||||
outln()
|
||||
}
|
||||
outln(description)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -214,6 +217,10 @@ func runStatus(ctx context.Context, args []string) error {
|
||||
}
|
||||
}
|
||||
Stdout.Write(buf.Bytes())
|
||||
if len(st.Health) > 0 {
|
||||
outln()
|
||||
printHealth()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
122
cmd/tailscale/cli/switch.go
Normal file
122
cmd/tailscale/cli/switch.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/ipn"
|
||||
)
|
||||
|
||||
var switchCmd = &ffcli.Command{
|
||||
Name: "switch",
|
||||
ShortHelp: "Switches to a different Tailscale profile",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("switch", flag.ExitOnError)
|
||||
fs.BoolVar(&switchArgs.list, "list", false, "list available profiles")
|
||||
return fs
|
||||
}(),
|
||||
Exec: switchProfile,
|
||||
UsageFunc: func(*ffcli.Command) string {
|
||||
return `USAGE
|
||||
[ALPHA] switch <name>
|
||||
[ALPHA] switch --list
|
||||
|
||||
"tailscale switch" switches between logged in profiles.
|
||||
This command is currently in alpha and may change in the future.`
|
||||
},
|
||||
}
|
||||
|
||||
var switchArgs struct {
|
||||
list bool
|
||||
}
|
||||
|
||||
func listProfiles(ctx context.Context) error {
|
||||
curP, all, err := localClient.ProfileStatus(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, prof := range all {
|
||||
if prof.ID == curP.ID {
|
||||
fmt.Printf("%s *\n", prof.Name)
|
||||
} else {
|
||||
fmt.Println(prof.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func switchProfile(ctx context.Context, args []string) error {
|
||||
if switchArgs.list {
|
||||
return listProfiles(ctx)
|
||||
}
|
||||
if len(args) != 1 {
|
||||
outln("usage: tailscale profile switch NAME")
|
||||
os.Exit(1)
|
||||
}
|
||||
cp, all, err := localClient.ProfileStatus(ctx)
|
||||
if err != nil {
|
||||
errf("Failed to switch to profile: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var profID ipn.ProfileID
|
||||
for _, p := range all {
|
||||
if p.Name == args[0] {
|
||||
profID = p.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if profID == "" {
|
||||
errf("No profile named %q\n", args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
if profID == cp.ID {
|
||||
printf("Already on profile %q\n", args[0])
|
||||
os.Exit(0)
|
||||
}
|
||||
if err := localClient.SwitchProfile(ctx, profID); err != nil {
|
||||
errf("Failed to switch to profile: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
printf("Switching to profile %q\n", args[0])
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
errf("Timed out waiting for switch to complete.")
|
||||
os.Exit(1)
|
||||
default:
|
||||
}
|
||||
st, err := localClient.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
errf("Error getting status: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
switch st.BackendState {
|
||||
case "NoState", "Starting":
|
||||
// TODO(maisem): maybe add a way to subscribe to state changes to
|
||||
// LocalClient.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
case "NeedsLogin":
|
||||
outln("Logged out.")
|
||||
outln("To log in, run:")
|
||||
outln(" tailscale up")
|
||||
return nil
|
||||
case "Running":
|
||||
outln("Success.")
|
||||
return nil
|
||||
}
|
||||
// For all other states, use the default error message.
|
||||
if msg, ok := isRunningOrStarting(st); !ok {
|
||||
outln(msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
shellquote "github.com/kballard/go-shellquote"
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
"tailscale.com/health/healthmsg"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/tsaddr"
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/preftype"
|
||||
"tailscale.com/util/strs"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
@@ -57,7 +59,9 @@ considered settings that need to be re-specified when modifying
|
||||
settings.)
|
||||
`),
|
||||
FlagSet: upFlagSet,
|
||||
Exec: runUp,
|
||||
Exec: func(ctx context.Context, args []string) error {
|
||||
return runUp(ctx, "up", args, upArgsGlobal)
|
||||
},
|
||||
}
|
||||
|
||||
func effectiveGOOS() string {
|
||||
@@ -80,28 +84,29 @@ func acceptRouteDefault(goos string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgs)
|
||||
var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgsGlobal, "up")
|
||||
|
||||
func inTest() bool { return flag.Lookup("test.v") != nil }
|
||||
|
||||
func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet {
|
||||
upf := newFlagSet("up")
|
||||
// newUpFlagSet returns a new flag set for the "up" and "login" commands.
|
||||
func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
|
||||
if cmd != "up" && cmd != "login" {
|
||||
panic("cmd must be up or login")
|
||||
}
|
||||
upf := newFlagSet(cmd)
|
||||
|
||||
upf.BoolVar(&upArgs.qr, "qr", false, "show QR code for login URLs")
|
||||
upf.BoolVar(&upArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)")
|
||||
upf.BoolVar(&upArgs.forceReauth, "force-reauth", false, "force reauthentication")
|
||||
upf.BoolVar(&upArgs.reset, "reset", false, "reset unspecified settings to their default values")
|
||||
upf.StringVar(&upArgs.authKeyOrFile, "auth-key", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`)
|
||||
|
||||
upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server")
|
||||
upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", acceptRouteDefault(goos), "accept routes advertised by other Tailscale nodes")
|
||||
upf.BoolVar(&upArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel")
|
||||
upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, "install host routes to other Tailscale nodes")
|
||||
upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, "HIDDEN: install host routes to other Tailscale nodes")
|
||||
upf.StringVar(&upArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node")
|
||||
upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
|
||||
upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
|
||||
upf.BoolVar(&upArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy")
|
||||
upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request; each must start with \"tag:\" (e.g. \"tag:eng,tag:montreal,tag:ssh\")")
|
||||
upf.StringVar(&upArgs.authKeyOrFile, "auth-key", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`)
|
||||
upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
|
||||
upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes")
|
||||
upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
|
||||
@@ -116,7 +121,19 @@ func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet {
|
||||
upf.BoolVar(&upArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)")
|
||||
}
|
||||
upf.DurationVar(&upArgs.timeout, "timeout", 0, "maximum amount of time to wait for tailscaled to enter a Running state; default (0s) blocks forever")
|
||||
registerAcceptRiskFlag(upf)
|
||||
|
||||
if cmd == "login" {
|
||||
upf.StringVar(&upArgs.profileName, "nickname", "", "short name for the login profile")
|
||||
}
|
||||
|
||||
if cmd == "up" {
|
||||
// Some flags are only for "up", not "login".
|
||||
upf.BoolVar(&upArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)")
|
||||
upf.BoolVar(&upArgs.reset, "reset", false, "reset unspecified settings to their default values")
|
||||
upf.BoolVar(&upArgs.forceReauth, "force-reauth", false, "force reauthentication")
|
||||
registerAcceptRiskFlag(upf, &upArgs.acceptedRisks)
|
||||
}
|
||||
|
||||
return upf
|
||||
}
|
||||
|
||||
@@ -150,12 +167,13 @@ type upArgsT struct {
|
||||
opUser string
|
||||
json bool
|
||||
timeout time.Duration
|
||||
acceptedRisks string
|
||||
profileName string
|
||||
}
|
||||
|
||||
func (a upArgsT) getAuthKey() (string, error) {
|
||||
v := a.authKeyOrFile
|
||||
if strings.HasPrefix(v, "file:") {
|
||||
file := strings.TrimPrefix(v, "file:")
|
||||
if file, ok := strs.CutPrefix(v, "file:"); ok {
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -165,7 +183,7 @@ func (a upArgsT) getAuthKey() (string, error) {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
var upArgs upArgsT
|
||||
var upArgsGlobal upArgsT
|
||||
|
||||
// Fields output when `tailscale up --json` is used. Two JSON blocks will be output.
|
||||
//
|
||||
@@ -259,6 +277,9 @@ func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]
|
||||
routeMap[netip.MustParsePrefix("0.0.0.0/0")] = true
|
||||
routeMap[netip.MustParsePrefix("::/0")] = true
|
||||
}
|
||||
if len(routeMap) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
routes := make([]netip.Prefix, 0, len(routeMap))
|
||||
for r := range routeMap {
|
||||
routes = append(routes, r)
|
||||
@@ -277,7 +298,7 @@ func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]
|
||||
// Note that the parameters upArgs and warnf are named intentionally
|
||||
// to shadow the globals to prevent accidental misuse of them. This
|
||||
// function exists for testing and should have no side effects or
|
||||
// outside interactions (e.g. no making Tailscale local API calls).
|
||||
// outside interactions (e.g. no making Tailscale LocalAPI calls).
|
||||
func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goos string) (*ipn.Prefs, error) {
|
||||
routes, err := calcAdvertiseRoutes(upArgs.advertiseRoutes, upArgs.advertiseDefaultRoute)
|
||||
if err != nil {
|
||||
@@ -328,6 +349,7 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
|
||||
prefs.Hostname = upArgs.hostname
|
||||
prefs.ForceDaemon = upArgs.forceDaemon
|
||||
prefs.OperatorUser = upArgs.opUser
|
||||
prefs.ProfileName = upArgs.profileName
|
||||
|
||||
if goos == "linux" {
|
||||
prefs.NoSNAT = !upArgs.snat
|
||||
@@ -376,6 +398,13 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
|
||||
return false, nil, fmt.Errorf("can't change --login-server without --force-reauth")
|
||||
}
|
||||
|
||||
// Do this after validations to avoid the 5s delay if we're going to error
|
||||
// out anyway.
|
||||
wantSSH, haveSSH := env.upArgs.runSSH, curPrefs.RunSSH
|
||||
if err := presentSSHToggleRisk(wantSSH, haveSSH, env.upArgs.acceptedRisks); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
tagsChanged := !reflect.DeepEqual(curPrefs.AdvertiseTags, prefs.AdvertiseTags)
|
||||
|
||||
simpleUp = env.flagSet.NFlag() == 0 &&
|
||||
@@ -398,14 +427,24 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
|
||||
visitFlags = env.flagSet.VisitAll
|
||||
}
|
||||
visitFlags(func(f *flag.Flag) {
|
||||
updateMaskedPrefsFromUpFlag(justEditMP, f.Name)
|
||||
updateMaskedPrefsFromUpOrSetFlag(justEditMP, f.Name)
|
||||
})
|
||||
}
|
||||
|
||||
return simpleUp, justEditMP, nil
|
||||
}
|
||||
|
||||
func runUp(ctx context.Context, args []string) (retErr error) {
|
||||
func presentSSHToggleRisk(wantSSH, haveSSH bool, acceptedRisks string) error {
|
||||
if !isSSHOverTailscale() || wantSSH == haveSSH {
|
||||
return nil
|
||||
}
|
||||
if wantSSH {
|
||||
return presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will reroute SSH traffic to Tailscale SSH and will result in your session disconnecting.`, acceptedRisks)
|
||||
}
|
||||
return presentRiskToUser(riskLoseSSH, `You are connected using Tailscale SSH; this action will result in your session disconnecting.`, acceptedRisks)
|
||||
}
|
||||
|
||||
func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retErr error) {
|
||||
var egg bool
|
||||
if len(args) > 0 {
|
||||
egg = fmt.Sprint(args) == "[up down down left right left right b a]"
|
||||
@@ -464,6 +503,11 @@ func runUp(ctx context.Context, args []string) (retErr error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd == "up" {
|
||||
// "tailscale up" should not be able to change the
|
||||
// profile name.
|
||||
prefs.ProfileName = curPrefs.ProfileName
|
||||
}
|
||||
|
||||
env := upCheckEnv{
|
||||
goos: effectiveGOOS(),
|
||||
@@ -475,20 +519,9 @@ func runUp(ctx context.Context, args []string) (retErr error) {
|
||||
curExitNodeIP: exitNodeIP(curPrefs, st),
|
||||
}
|
||||
|
||||
if upArgs.runSSH != curPrefs.RunSSH && isSSHOverTailscale() {
|
||||
if upArgs.runSSH {
|
||||
err = presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will reroute SSH traffic to Tailscale SSH and will result in your session disconnecting.`)
|
||||
} else {
|
||||
err = presentRiskToUser(riskLoseSSH, `You are connected using Tailscale SSH; this action will result in your session disconnecting.`)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if retErr == nil {
|
||||
checkSSHUpWarnings(ctx)
|
||||
checkUpWarnings(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -497,7 +530,7 @@ func runUp(ctx context.Context, args []string) (retErr error) {
|
||||
fatalf("%s", err)
|
||||
}
|
||||
if justEditMP != nil {
|
||||
justEditMP.EggSet = true
|
||||
justEditMP.EggSet = egg
|
||||
_, err := localClient.EditPrefs(ctx, justEditMP)
|
||||
return err
|
||||
}
|
||||
@@ -627,27 +660,10 @@ func runUp(ctx context.Context, args []string) (retErr error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts := ipn.Options{
|
||||
StateKey: ipn.GlobalDaemonStateKey,
|
||||
bc.Start(ipn.Options{
|
||||
AuthKey: authKey,
|
||||
UpdatePrefs: prefs,
|
||||
}
|
||||
// On Windows, we still run in mostly the "legacy" way that
|
||||
// predated the server's StateStore. That is, we send an empty
|
||||
// StateKey and send the prefs directly. Although the Windows
|
||||
// supports server mode, though, the transition to StateStore
|
||||
// is only half complete. Only server mode uses it, and the
|
||||
// Windows service (~tailscaled) is the one that computes the
|
||||
// StateKey based on the connection identity. So for now, just
|
||||
// do as the Windows GUI's always done:
|
||||
if effectiveGOOS() == "windows" {
|
||||
// The Windows service will set this as needed based
|
||||
// on our connection's identity.
|
||||
opts.StateKey = ""
|
||||
opts.Prefs = prefs
|
||||
}
|
||||
|
||||
bc.Start(opts)
|
||||
})
|
||||
if upArgs.forceReauth {
|
||||
startLoginInteractive()
|
||||
}
|
||||
@@ -688,25 +704,39 @@ func runUp(ctx context.Context, args []string) (retErr error) {
|
||||
}
|
||||
}
|
||||
|
||||
func checkSSHUpWarnings(ctx context.Context) {
|
||||
if !upArgs.runSSH {
|
||||
return
|
||||
}
|
||||
st, err := localClient.Status(ctx)
|
||||
// upWorthWarning reports whether the health check message s is worth warning
|
||||
// about during "tailscale up". Many of the health checks are noisy or confusing
|
||||
// or very ephemeral and happen especially briefly at startup.
|
||||
//
|
||||
// TODO(bradfitz): change the server to send typed warnings with metadata about
|
||||
// the health check, rather than just a string.
|
||||
func upWorthyWarning(s string) bool {
|
||||
return strings.Contains(s, healthmsg.TailscaleSSHOnBut) ||
|
||||
strings.Contains(s, healthmsg.WarnAcceptRoutesOff)
|
||||
}
|
||||
|
||||
func checkUpWarnings(ctx context.Context) {
|
||||
st, err := localClient.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
// Ignore. Don't spam more.
|
||||
return
|
||||
}
|
||||
if len(st.Health) == 0 {
|
||||
var warn []string
|
||||
for _, w := range st.Health {
|
||||
if upWorthyWarning(w) {
|
||||
warn = append(warn, w)
|
||||
}
|
||||
}
|
||||
if len(warn) == 0 {
|
||||
return
|
||||
}
|
||||
if len(st.Health) == 1 && strings.Contains(st.Health[0], "SSH") {
|
||||
printf("%s\n", st.Health[0])
|
||||
if len(warn) == 1 {
|
||||
printf("%s\n", warn[0])
|
||||
return
|
||||
}
|
||||
printf("# Health check:\n")
|
||||
for _, m := range st.Health {
|
||||
printf(" - %s\n", m)
|
||||
printf("# Health check warnings:\n")
|
||||
for _, m := range warn {
|
||||
printf("# - %s\n", m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,6 +776,7 @@ func init() {
|
||||
addPrefFlagMapping("unattended", "ForceDaemon")
|
||||
addPrefFlagMapping("operator", "OperatorUser")
|
||||
addPrefFlagMapping("ssh", "RunSSH")
|
||||
addPrefFlagMapping("nickname", "ProfileName")
|
||||
}
|
||||
|
||||
func addPrefFlagMapping(flagName string, prefNames ...string) {
|
||||
@@ -769,7 +800,7 @@ func preflessFlag(flagName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func updateMaskedPrefsFromUpFlag(mp *ipn.MaskedPrefs, flagName string) {
|
||||
func updateMaskedPrefsFromUpOrSetFlag(mp *ipn.MaskedPrefs, flagName string) {
|
||||
if preflessFlag(flagName) {
|
||||
return
|
||||
}
|
||||
@@ -928,7 +959,7 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) {
|
||||
return env.curExitNodeIP.String()
|
||||
}
|
||||
|
||||
fs := newUpFlagSet(env.goos, new(upArgsT) /* dummy */)
|
||||
fs := newUpFlagSet(env.goos, new(upArgsT) /* dummy */, "up")
|
||||
fs.VisitAll(func(f *flag.Flag) {
|
||||
if preflessFlag(f.Name) {
|
||||
return
|
||||
@@ -1050,3 +1081,15 @@ func exitNodeIP(p *ipn.Prefs, st *ipnstate.Status) (ip netip.Addr) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func anyPeerAdvertisingRoutes(st *ipnstate.Status) bool {
|
||||
for _, ps := range st.Peer {
|
||||
if ps.PrimaryRoutes == nil {
|
||||
continue
|
||||
}
|
||||
if ps.PrimaryRoutes.Len() > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -225,8 +224,8 @@ func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResp
|
||||
"user": []string{user},
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: r.URL.Scheme,
|
||||
Host: r.URL.Host,
|
||||
Scheme: "http",
|
||||
Host: "127.0.0.1:8080",
|
||||
Path: "/cgi-bin/authLogin.cgi",
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
@@ -239,8 +238,8 @@ func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse,
|
||||
"sid": []string{sid},
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: r.URL.Scheme,
|
||||
Host: r.URL.Host,
|
||||
Scheme: "http",
|
||||
Host: "127.0.0.1:8080",
|
||||
Path: "/cgi-bin/authLogin.cgi",
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
@@ -254,7 +253,7 @@ func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, err := ioutil.ReadAll(resp.Body)
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -475,8 +474,8 @@ func tailscaleUp(ctx context.Context, prefs *ipn.Prefs, forceReauth bool) (authU
|
||||
authURL = *url
|
||||
cancel()
|
||||
}
|
||||
if !forceReauth && n.Prefs != nil {
|
||||
p1, p2 := *n.Prefs, *prefs
|
||||
if !forceReauth && n.Prefs != nil && n.Prefs.Valid() {
|
||||
p1, p2 := n.Prefs.AsStruct(), *prefs
|
||||
p1.Persist = nil
|
||||
p2.Persist = nil
|
||||
if p1.Equals(&p2) {
|
||||
@@ -497,9 +496,7 @@ func tailscaleUp(ctx context.Context, prefs *ipn.Prefs, forceReauth bool) (authU
|
||||
|
||||
bc.SetPrefs(prefs)
|
||||
|
||||
bc.Start(ipn.Options{
|
||||
StateKey: ipn.GlobalDaemonStateKey,
|
||||
})
|
||||
bc.Start(ipn.Options{})
|
||||
if forceReauth {
|
||||
bc.StartLoginInteractive()
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
D github.com/google/uuid from tailscale.com/util/quarantine
|
||||
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/internal/unix from github.com/jsimonetti/rtnetlink
|
||||
github.com/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli
|
||||
github.com/klauspost/compress/flate from nhooyr.io/websocket
|
||||
💣 github.com/mattn/go-colorable from tailscale.com/cmd/tailscale/cli
|
||||
💣 github.com/mattn/go-isatty from github.com/mattn/go-colorable+
|
||||
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
|
||||
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
|
||||
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
|
||||
@@ -37,6 +40,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
nhooyr.io/websocket from tailscale.com/derp/derphttp+
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
software.sslmate.com/src/go-pkcs12 from tailscale.com/cmd/tailscale/cli
|
||||
software.sslmate.com/src/go-pkcs12/internal/rc2 from software.sslmate.com/src/go-pkcs12
|
||||
tailscale.com from tailscale.com/version
|
||||
tailscale.com/atomicfile from tailscale.com/ipn+
|
||||
tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli+
|
||||
@@ -49,10 +54,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/derp/derphttp from tailscale.com/net/netcheck
|
||||
tailscale.com/disco from tailscale.com/derp
|
||||
tailscale.com/envknob from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/health/healthmsg from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/hostinfo from tailscale.com/net/interfaces+
|
||||
tailscale.com/ipn from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/cmd/tailscale/cli+
|
||||
💣 tailscale.com/metrics from tailscale.com/derp
|
||||
tailscale.com/metrics from tailscale.com/derp
|
||||
tailscale.com/net/dnscache from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp
|
||||
tailscale.com/net/flowtrack from tailscale.com/wgengine/filter+
|
||||
@@ -70,6 +76,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/tsaddr from tailscale.com/net/interfaces+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/wsconn from tailscale.com/control/controlhttp+
|
||||
tailscale.com/paths from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/safesocket from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/syncs from tailscale.com/net/netcheck+
|
||||
@@ -86,7 +93,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/types/netmap from tailscale.com/ipn
|
||||
tailscale.com/types/nettype from tailscale.com/net/netcheck+
|
||||
tailscale.com/types/opt from tailscale.com/net/netcheck+
|
||||
tailscale.com/types/pad32 from tailscale.com/derp
|
||||
tailscale.com/types/persist from tailscale.com/ipn
|
||||
tailscale.com/types/preftype from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/types/structs from tailscale.com/ipn+
|
||||
@@ -95,13 +101,16 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/util/clientmetric from tailscale.com/net/netcheck+
|
||||
tailscale.com/util/cloudenv from tailscale.com/net/dnscache+
|
||||
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
|
||||
W tailscale.com/util/endian from tailscale.com/net/netns
|
||||
tailscale.com/util/groupmember from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/lineread from tailscale.com/net/interfaces+
|
||||
tailscale.com/util/mak from tailscale.com/net/netcheck
|
||||
tailscale.com/util/mak from tailscale.com/net/netcheck+
|
||||
tailscale.com/util/multierr from tailscale.com/control/controlhttp
|
||||
tailscale.com/util/quarantine from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache
|
||||
L tailscale.com/util/strs from tailscale.com/hostinfo
|
||||
tailscale.com/util/strs from tailscale.com/hostinfo+
|
||||
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
tailscale.com/version from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/version/distro from tailscale.com/cmd/tailscale/cli+
|
||||
@@ -117,11 +126,14 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
golang.org/x/crypto/hkdf from crypto/tls+
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/pbkdf2 from software.sslmate.com/src/go-pkcs12
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/exp/constraints from golang.org/x/exp/slices
|
||||
golang.org/x/exp/slices from tailscale.com/net/tsaddr+
|
||||
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+
|
||||
golang.org/x/net/http/httpproxy from net/http
|
||||
golang.org/x/net/http/httpproxy from net/http+
|
||||
golang.org/x/net/http2/hpack from net/http
|
||||
golang.org/x/net/icmp from tailscale.com/net/ping
|
||||
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
|
||||
@@ -134,6 +146,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
LD golang.org/x/sys/unix from tailscale.com/net/netns+
|
||||
W golang.org/x/sys/windows from golang.org/x/sys/windows/registry+
|
||||
W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+
|
||||
W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+
|
||||
W golang.org/x/sys/windows/svc/mgr from tailscale.com/util/winutil
|
||||
golang.org/x/text/secure/bidirule from golang.org/x/net/idna
|
||||
golang.org/x/text/transform from golang.org/x/text/secure/bidirule+
|
||||
golang.org/x/text/unicode/bidi from golang.org/x/net/idna+
|
||||
@@ -166,6 +180,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
crypto/tls from github.com/tcnksm/go-httpstat+
|
||||
crypto/x509 from crypto/tls+
|
||||
crypto/x509/pkix from crypto/x509+
|
||||
D database/sql/driver from github.com/google/uuid
|
||||
embed from tailscale.com/cmd/tailscale/cli+
|
||||
encoding from encoding/json+
|
||||
encoding/asn1 from crypto/x509+
|
||||
|
||||
10
cmd/tailscale/generate.go
Normal file
10
cmd/tailscale/generate.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
//go:generate go run tailscale.com/cmd/mkmanifest amd64 windows-manifest.xml manifest_windows_amd64.syso
|
||||
//go:generate go run tailscale.com/cmd/mkmanifest 386 windows-manifest.xml manifest_windows_386.syso
|
||||
//go:generate go run tailscale.com/cmd/mkmanifest arm64 windows-manifest.xml manifest_windows_arm64.syso
|
||||
//go:generate go run tailscale.com/cmd/mkmanifest arm windows-manifest.xml manifest_windows_arm.syso
|
||||
BIN
cmd/tailscale/manifest_windows_386.syso
Normal file
BIN
cmd/tailscale/manifest_windows_386.syso
Normal file
Binary file not shown.
BIN
cmd/tailscale/manifest_windows_amd64.syso
Normal file
BIN
cmd/tailscale/manifest_windows_amd64.syso
Normal file
Binary file not shown.
BIN
cmd/tailscale/manifest_windows_arm.syso
Normal file
BIN
cmd/tailscale/manifest_windows_arm.syso
Normal file
Binary file not shown.
BIN
cmd/tailscale/manifest_windows_arm64.syso
Normal file
BIN
cmd/tailscale/manifest_windows_arm64.syso
Normal file
Binary file not shown.
13
cmd/tailscale/windows-manifest.xml
Normal file
13
cmd/tailscale/windows-manifest.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/> <!-- Windows 7 -->
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/> <!-- Windows 8 -->
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/> <!-- Windows 8.1 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/> <!-- Windows 10 -->
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
</assembly>
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
// +build go1.19
|
||||
|
||||
package main
|
||||
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -89,6 +87,8 @@ func runMonitor(ctx context.Context, loop bool) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer mon.Close()
|
||||
|
||||
mon.RegisterChangeCallback(func(changed bool, st *interfaces.State) {
|
||||
if !changed {
|
||||
log.Printf("Link monitor fired; no change")
|
||||
@@ -163,7 +163,7 @@ func getURL(ctx context.Context, urlStr string) error {
|
||||
return res.Write(os.Stdout)
|
||||
}
|
||||
|
||||
func checkDerp(ctx context.Context, derpRegion string) error {
|
||||
func checkDerp(ctx context.Context, derpRegion string) (err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", ipn.DefaultControlURL+"/derpmap/default", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create derp map request: %w", err)
|
||||
@@ -173,7 +173,7 @@ func checkDerp(ctx context.Context, derpRegion string) error {
|
||||
return fmt.Errorf("fetch derp map failed: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
b, err := ioutil.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
b, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch derp map failed: %w", err)
|
||||
}
|
||||
@@ -202,6 +202,12 @@ func checkDerp(ctx context.Context, derpRegion string) error {
|
||||
|
||||
c1 := derphttp.NewRegionClient(priv1, log.Printf, getRegion)
|
||||
c2 := derphttp.NewRegionClient(priv2, log.Printf, getRegion)
|
||||
defer func() {
|
||||
if err != nil {
|
||||
c1.Close()
|
||||
c2.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
c2.NotePreferred(true) // just to open it
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/header+
|
||||
github.com/hdevalence/ed25519consensus from tailscale.com/tka
|
||||
L 💣 github.com/illarion/gonotify from tailscale.com/net/dns
|
||||
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
|
||||
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
|
||||
L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4
|
||||
@@ -109,7 +110,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp
|
||||
github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+
|
||||
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
|
||||
L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router
|
||||
L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router+
|
||||
github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck
|
||||
LD github.com/u-root/u-root/pkg/termios from tailscale.com/ssh/tailssh
|
||||
L github.com/u-root/uio/rand from github.com/insomniacslk/dhcp/dhcpv4
|
||||
@@ -190,8 +191,11 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/derp from tailscale.com/derp/derphttp+
|
||||
tailscale.com/derp/derphttp from tailscale.com/net/netcheck+
|
||||
tailscale.com/disco from tailscale.com/derp+
|
||||
tailscale.com/doctor from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/doctor/routetable from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/envknob from tailscale.com/control/controlclient+
|
||||
tailscale.com/health from tailscale.com/control/controlclient+
|
||||
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/hostinfo from tailscale.com/control/controlclient+
|
||||
tailscale.com/ipn from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/ipn/ipnlocal from tailscale.com/ssh/tailssh+
|
||||
@@ -210,7 +214,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/logtail from tailscale.com/control/controlclient+
|
||||
tailscale.com/logtail/backoff from tailscale.com/control/controlclient+
|
||||
tailscale.com/logtail/filch from tailscale.com/logpolicy
|
||||
💣 tailscale.com/metrics from tailscale.com/derp+
|
||||
tailscale.com/metrics from tailscale.com/derp+
|
||||
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dns/publicdns from tailscale.com/net/dns/resolver+
|
||||
tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+
|
||||
@@ -224,12 +228,13 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
|
||||
tailscale.com/net/netknob from tailscale.com/net/netns+
|
||||
tailscale.com/net/netns from tailscale.com/derp/derphttp+
|
||||
💣 tailscale.com/net/netstat from tailscale.com/ipn/ipnserver
|
||||
💣 tailscale.com/net/netstat from tailscale.com/ipn/ipnserver+
|
||||
tailscale.com/net/netutil from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/packet from tailscale.com/net/tstun+
|
||||
tailscale.com/net/ping from tailscale.com/net/netcheck
|
||||
tailscale.com/net/portmapper from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/net/routetable from tailscale.com/doctor/routetable
|
||||
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/net/stun from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
||||
@@ -237,8 +242,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/tstun from tailscale.com/net/dns+
|
||||
tailscale.com/net/tunstats from tailscale.com/net/tstun
|
||||
tailscale.com/net/wsconn from tailscale.com/control/controlhttp+
|
||||
tailscale.com/paths from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/portlist from tailscale.com/ipn/ipnlocal
|
||||
💣 tailscale.com/portlist from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/safesocket from tailscale.com/client/tailscale+
|
||||
tailscale.com/smallzstd from tailscale.com/ipn/ipnserver+
|
||||
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
|
||||
@@ -257,10 +264,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
|
||||
tailscale.com/types/key from tailscale.com/control/controlbase+
|
||||
tailscale.com/types/logger from tailscale.com/control/controlclient+
|
||||
tailscale.com/types/netlogtype from tailscale.com/net/tstun+
|
||||
tailscale.com/types/netmap from tailscale.com/control/controlclient+
|
||||
tailscale.com/types/nettype from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/types/opt from tailscale.com/control/controlclient+
|
||||
tailscale.com/types/pad32 from tailscale.com/derp
|
||||
tailscale.com/types/persist from tailscale.com/control/controlclient+
|
||||
tailscale.com/types/preftype from tailscale.com/ipn+
|
||||
tailscale.com/types/structs from tailscale.com/control/controlclient+
|
||||
@@ -270,8 +277,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/util/cloudenv from tailscale.com/net/dns/resolver+
|
||||
LW tailscale.com/util/cmpver from tailscale.com/net/dns+
|
||||
💣 tailscale.com/util/deephash from tailscale.com/ipn/ipnlocal+
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+
|
||||
tailscale.com/util/dnsname from tailscale.com/hostinfo+
|
||||
LW tailscale.com/util/endian from tailscale.com/net/dns+
|
||||
tailscale.com/util/goroutines from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/groupmember from tailscale.com/ipn/ipnserver
|
||||
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
|
||||
tailscale.com/util/lineread from tailscale.com/hostinfo+
|
||||
@@ -283,7 +292,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/strs from tailscale.com/hostinfo+
|
||||
tailscale.com/util/systemd from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/uniq from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/util/uniq from tailscale.com/wgengine/magicsock+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
tailscale.com/version/distro from tailscale.com/hostinfo+
|
||||
@@ -292,6 +301,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/wgengine/filter from tailscale.com/control/controlclient+
|
||||
💣 tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/monitor from tailscale.com/control/controlclient+
|
||||
tailscale.com/wgengine/netlog from tailscale.com/wgengine
|
||||
tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/wgengine/router from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+
|
||||
@@ -299,7 +309,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
💣 tailscale.com/wgengine/wgint from tailscale.com/wgengine
|
||||
tailscale.com/wgengine/wglog from tailscale.com/wgengine
|
||||
W 💣 tailscale.com/wgengine/winnet from tailscale.com/wgengine/router
|
||||
golang.org/x/crypto/acme from tailscale.com/ipn/localapi
|
||||
golang.org/x/crypto/acme from tailscale.com/ipn/ipnlocal
|
||||
golang.org/x/crypto/argon2 from tailscale.com/tka
|
||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box+
|
||||
golang.org/x/crypto/blake2s from golang.zx2c4.com/wireguard/device+
|
||||
@@ -317,6 +327,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
LD golang.org/x/crypto/ssh from tailscale.com/ssh/tailssh+
|
||||
golang.org/x/exp/constraints from golang.org/x/exp/slices
|
||||
golang.org/x/exp/maps from tailscale.com/wgengine
|
||||
golang.org/x/exp/slices from tailscale.com/ipn/ipnlocal+
|
||||
golang.org/x/net/bpf from github.com/mdlayher/genetlink+
|
||||
golang.org/x/net/dns/dnsmessage from net+
|
||||
@@ -338,7 +349,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
W golang.org/x/sys/windows/registry from golang.org/x/sys/windows/svc/eventlog+
|
||||
W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+
|
||||
W golang.org/x/sys/windows/svc/eventlog from tailscale.com/cmd/tailscaled
|
||||
W golang.org/x/sys/windows/svc/mgr from tailscale.com/cmd/tailscaled
|
||||
W golang.org/x/sys/windows/svc/mgr from tailscale.com/cmd/tailscaled+
|
||||
golang.org/x/term from tailscale.com/logpolicy
|
||||
golang.org/x/text/secure/bidirule from golang.org/x/net/idna
|
||||
golang.org/x/text/transform from golang.org/x/text/secure/bidirule+
|
||||
|
||||
10
cmd/tailscaled/generate.go
Normal file
10
cmd/tailscaled/generate.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
//go:generate go run tailscale.com/cmd/mkmanifest amd64 windows-manifest.xml manifest_windows_amd64.syso
|
||||
//go:generate go run tailscale.com/cmd/mkmanifest 386 windows-manifest.xml manifest_windows_386.syso
|
||||
//go:generate go run tailscale.com/cmd/mkmanifest arm64 windows-manifest.xml manifest_windows_arm64.syso
|
||||
//go:generate go run tailscale.com/cmd/mkmanifest arm windows-manifest.xml manifest_windows_arm.syso
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
// +build go1.19
|
||||
|
||||
package main
|
||||
|
||||
@@ -11,7 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -84,6 +83,13 @@ func uninstallSystemDaemonDarwin(args []string) (ret error) {
|
||||
ret = err
|
||||
}
|
||||
}
|
||||
|
||||
// Do not delete targetBin if it's a symlink, which happens if it was installed via
|
||||
// Homebrew.
|
||||
if isSymlink(targetBin) {
|
||||
return ret
|
||||
}
|
||||
|
||||
if err := os.Remove(targetBin); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = nil
|
||||
@@ -108,41 +114,25 @@ func installSystemDaemonDarwin(args []string) (err error) {
|
||||
// Best effort:
|
||||
uninstallSystemDaemonDarwin(nil)
|
||||
|
||||
// Copy ourselves to /usr/local/bin/tailscaled.
|
||||
if err := os.MkdirAll(filepath.Dir(targetBin), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find our own executable path: %w", err)
|
||||
}
|
||||
tmpBin := targetBin + ".tmp"
|
||||
f, err := os.Create(tmpBin)
|
||||
|
||||
same, err := sameFile(exe, targetBin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self, err := os.Open(exe)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(f, self)
|
||||
self.Close()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpBin, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpBin, targetBin); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ioutil.WriteFile(sysPlist, []byte(darwinLaunchdPlist), 0700); err != nil {
|
||||
// Do not overwrite targetBin with the binary file if it it's already
|
||||
// pointing to it. This is primarily to handle Homebrew that writes
|
||||
// /usr/local/bin/tailscaled is a symlink to the actual binary.
|
||||
if !same {
|
||||
if err := copyBinary(exe, targetBin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(sysPlist, []byte(darwinLaunchdPlist), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -156,3 +146,55 @@ func installSystemDaemonDarwin(args []string) (err error) {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyBinary copies binary file `src` into `dst`.
|
||||
func copyBinary(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpBin := dst + ".tmp"
|
||||
f, err := os.Create(tmpBin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srcf, err := os.Open(src)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(f, srcf)
|
||||
srcf.Close()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpBin, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpBin, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSymlink(path string) bool {
|
||||
fi, err := os.Lstat(path)
|
||||
return err == nil && (fi.Mode()&os.ModeSymlink == os.ModeSymlink)
|
||||
}
|
||||
|
||||
// sameFile returns true if both file paths exist and resolve to the same file.
|
||||
func sameFile(path1, path2 string) (bool, error) {
|
||||
dst1, err := filepath.EvalSymlinks(path1)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return false, fmt.Errorf("EvalSymlinks(%s): %w", path1, err)
|
||||
}
|
||||
dst2, err := filepath.EvalSymlinks(path2)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return false, fmt.Errorf("EvalSymlinks(%s): %w", path2, err)
|
||||
}
|
||||
return dst1 == dst2, nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.19
|
||||
// +build go1.19
|
||||
|
||||
package main
|
||||
|
||||
|
||||
BIN
cmd/tailscaled/manifest_windows_386.syso
Normal file
BIN
cmd/tailscaled/manifest_windows_386.syso
Normal file
Binary file not shown.
BIN
cmd/tailscaled/manifest_windows_amd64.syso
Normal file
BIN
cmd/tailscaled/manifest_windows_amd64.syso
Normal file
Binary file not shown.
BIN
cmd/tailscaled/manifest_windows_arm.syso
Normal file
BIN
cmd/tailscaled/manifest_windows_arm.syso
Normal file
Binary file not shown.
BIN
cmd/tailscaled/manifest_windows_arm64.syso
Normal file
BIN
cmd/tailscaled/manifest_windows_arm64.syso
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user