Compare commits
304 Commits
irbekrm/fi
...
v1.72.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa448d5a99 | ||
|
|
ee976ad704 | ||
|
|
5cbbb48c2e | ||
|
|
ccf091e4a6 | ||
|
|
cc136a58ea | ||
|
|
d88be7cddf | ||
|
|
e107977f75 | ||
|
|
db4247f705 | ||
|
|
6c852fa817 | ||
|
|
f8f9f05ffe | ||
|
|
2f27319baf | ||
|
|
2dd71e64ac | ||
|
|
74b9fa1348 | ||
|
|
a15ff1bade | ||
|
|
4c2e978f1e | ||
|
|
2506bf5b06 | ||
|
|
b9f42814b5 | ||
|
|
b4e595621f | ||
|
|
c987cf1255 | ||
|
|
02581b1603 | ||
|
|
b358f489b9 | ||
|
|
d985da207f | ||
|
|
b26c53368d | ||
|
|
eae6a00651 | ||
|
|
b60a9fce4b | ||
|
|
f79e688e0d | ||
|
|
adbab25bac | ||
|
|
9f1d9d324d | ||
|
|
b7e48058c8 | ||
|
|
84adfa1ba3 | ||
|
|
10d0ce8dde | ||
|
|
10662c4282 | ||
|
|
67df9abdc6 | ||
|
|
a61825c7b8 | ||
|
|
b692985aef | ||
|
|
0686bc8b19 | ||
|
|
0dd9f5397b | ||
|
|
10c2bee9e1 | ||
|
|
7aec8d4e6b | ||
|
|
218110963d | ||
|
|
bc2744da4b | ||
|
|
2e32abc3e2 | ||
|
|
ce4413a0bc | ||
|
|
2a88428f24 | ||
|
|
44d634395b | ||
|
|
d4cc074187 | ||
|
|
d0e8375b53 | ||
|
|
072d1a4b77 | ||
|
|
194ff6ee3d | ||
|
|
730fec1cfd | ||
|
|
f47a5fe52b | ||
|
|
bb3e95c40d | ||
|
|
f8d23b3582 | ||
|
|
17a10f702f | ||
|
|
082e46b48d | ||
|
|
6798f8ea88 | ||
|
|
12764e9db4 | ||
|
|
1016aa045f | ||
|
|
8594292aa4 | ||
|
|
20691894f5 | ||
|
|
f23932bd98 | ||
|
|
a867a4869d | ||
|
|
c0c4791ce7 | ||
|
|
ad038f4046 | ||
|
|
46db698333 | ||
|
|
f79183dac7 | ||
|
|
1ed958fe23 | ||
|
|
6ca078c46e | ||
|
|
a93dc6cdb1 | ||
|
|
7bac5dffcb | ||
|
|
b3fc345aba | ||
|
|
9106187a95 | ||
|
|
9b08399d9e | ||
|
|
153a476957 | ||
|
|
227509547f | ||
|
|
e3f047618b | ||
|
|
91d2e1772d | ||
|
|
3b6849e362 | ||
|
|
0fd73746dd | ||
|
|
17c88a19be | ||
|
|
25f0a3fc8f | ||
|
|
a7a394e7d9 | ||
|
|
07e2487c1d | ||
|
|
1dd9c44d51 | ||
|
|
0a6eb12f05 | ||
|
|
f205efcf18 | ||
|
|
a917718353 | ||
|
|
4099a36468 | ||
|
|
d9d9d525d9 | ||
|
|
9939374c48 | ||
|
|
4055b63b9b | ||
|
|
f0230ce0b5 | ||
|
|
cc370314e7 | ||
|
|
655b4f8fc5 | ||
|
|
004dded0a8 | ||
|
|
0def4f8e38 | ||
|
|
7bc2ddaedc | ||
|
|
949b15d858 | ||
|
|
8a8ecac6a7 | ||
|
|
eead25560f | ||
|
|
1b64961320 | ||
|
|
32308fcf71 | ||
|
|
34de96d06e | ||
|
|
575feb486f | ||
|
|
2ab1d532e8 | ||
|
|
360046e5c3 | ||
|
|
35a8fca379 | ||
|
|
19b0c8a024 | ||
|
|
3088c6105e | ||
|
|
a21bf100f3 | ||
|
|
1bf7ed0348 | ||
|
|
c5623e0471 | ||
|
|
1bf82ddf84 | ||
|
|
6840f471c0 | ||
|
|
90be06bd5b | ||
|
|
cf97cff33b | ||
|
|
855da47777 | ||
|
|
43375c6efb | ||
|
|
ba7f2d129e | ||
|
|
57856fc0d5 | ||
|
|
9904421853 | ||
|
|
5d09649b0b | ||
|
|
d500a92926 | ||
|
|
1f94047475 | ||
|
|
bd54b61746 | ||
|
|
20562a4fb9 | ||
|
|
e7bf6e716b | ||
|
|
32ce18716b | ||
|
|
0f57b9340b | ||
|
|
b2c522ce95 | ||
|
|
54f58d1143 | ||
|
|
485018696a | ||
|
|
1608831c33 | ||
|
|
d3af54444c | ||
|
|
d97cddd876 | ||
|
|
f77821fd63 | ||
|
|
0b32adf9ec | ||
|
|
1ac14d7216 | ||
|
|
4ff276cf52 | ||
|
|
2742153f84 | ||
|
|
646990a7d0 | ||
|
|
8882c6b730 | ||
|
|
35d2efd692 | ||
|
|
fc074a6b9f | ||
|
|
014bf25c0a | ||
|
|
0834712c91 | ||
|
|
fec41e4904 | ||
|
|
fd0acc4faf | ||
|
|
380a3a0834 | ||
|
|
5d61d1c7b0 | ||
|
|
9609b26541 | ||
|
|
7403d8e9a8 | ||
|
|
f0b9d3f477 | ||
|
|
3f3edeec07 | ||
|
|
808b4139ee | ||
|
|
49bf63cdd0 | ||
|
|
d209b032ab | ||
|
|
fc28c8e7f3 | ||
|
|
b7c3cfe049 | ||
|
|
8d7b78f3f7 | ||
|
|
041733d3d1 | ||
|
|
874972b683 | ||
|
|
b546a6e758 | ||
|
|
c6af5bbfe8 | ||
|
|
e92f4c6af8 | ||
|
|
986d60a094 | ||
|
|
6a982faa7d | ||
|
|
c8f258a904 | ||
|
|
726d5d507d | ||
|
|
2238ca8a05 | ||
|
|
8bd442ba8c | ||
|
|
7b1c764088 | ||
|
|
b8af91403d | ||
|
|
e21d8768f9 | ||
|
|
5576972261 | ||
|
|
ba517ab388 | ||
|
|
2b638f550d | ||
|
|
9102a5bb73 | ||
|
|
c8fe9f0064 | ||
|
|
42dac7c5c2 | ||
|
|
d2fef01206 | ||
|
|
9df107f4f0 | ||
|
|
e181f12a7b | ||
|
|
c4b20c5411 | ||
|
|
01a7726cf7 | ||
|
|
309afa53cf | ||
|
|
42f01afe26 | ||
|
|
59936e6d4a | ||
|
|
732af2f6e0 | ||
|
|
458decdeb0 | ||
|
|
4e5ef5b628 | ||
|
|
012933635b | ||
|
|
da32468988 | ||
|
|
ddf94a7b39 | ||
|
|
b56058d7e3 | ||
|
|
d780755340 | ||
|
|
489b990240 | ||
|
|
d15250aae9 | ||
|
|
8965e87fa8 | ||
|
|
114d1caf55 | ||
|
|
b565a9faa7 | ||
|
|
781f79408d | ||
|
|
4651827f20 | ||
|
|
8f7588900a | ||
|
|
0bb82561ba | ||
|
|
2064dc20d4 | ||
|
|
23c5870bd3 | ||
|
|
18939df0a7 | ||
|
|
1d6ab9f9db | ||
|
|
210264f942 | ||
|
|
6b801a8e9e | ||
|
|
b3f91845dc | ||
|
|
46fda6bf4c | ||
|
|
9766f0e110 | ||
|
|
94defc4056 | ||
|
|
b292f7f9ac | ||
|
|
5f177090e3 | ||
|
|
0323dd01b2 | ||
|
|
8487fd2ec2 | ||
|
|
a6b13e6972 | ||
|
|
75254178a0 | ||
|
|
787ead835f | ||
|
|
6e55d8f6a1 | ||
|
|
30f8d8199a | ||
|
|
da078b4c09 | ||
|
|
53a5d00fff | ||
|
|
8161024176 | ||
|
|
a475c435ec | ||
|
|
27033c6277 | ||
|
|
d5e692f7e7 | ||
|
|
94415e8029 | ||
|
|
3485e4bf5a | ||
|
|
7eb8a77ac8 | ||
|
|
24a40f54d9 | ||
|
|
d91e5c25ce | ||
|
|
ded7734c36 | ||
|
|
200d92121f | ||
|
|
7dd76c3411 | ||
|
|
591979b95f | ||
|
|
91786ff958 | ||
|
|
5ffb2668ef | ||
|
|
d7a4f9d31c | ||
|
|
0d6e71df70 | ||
|
|
dcb0f189cc | ||
|
|
5ec01bf3ce | ||
|
|
07063bc5c7 | ||
|
|
fd3efd9bad | ||
|
|
bd50a3457d | ||
|
|
730f0368d0 | ||
|
|
24976b5bfd | ||
|
|
732605f961 | ||
|
|
0004827681 | ||
|
|
1023b2a82c | ||
|
|
d7619d273b | ||
|
|
25eeafde23 | ||
|
|
4b39b6f7ce | ||
|
|
21460a5b14 | ||
|
|
162d593514 | ||
|
|
9e0a5cc551 | ||
|
|
d6a8fb20e7 | ||
|
|
8eb15d3d2d | ||
|
|
a93173b56a | ||
|
|
d55b105dae | ||
|
|
bd93c3067e | ||
|
|
bfb775ce62 | ||
|
|
3099323976 | ||
|
|
45d2f4301f | ||
|
|
2cb408f9b1 | ||
|
|
87c5ad4c2c | ||
|
|
2db2d04a37 | ||
|
|
315f3d5df1 | ||
|
|
8cc2738609 | ||
|
|
674c998e93 | ||
|
|
be54dde0eb | ||
|
|
a1ab7f7c94 | ||
|
|
1f6645b19f | ||
|
|
20a5f939ba | ||
|
|
bf2d13cfa0 | ||
|
|
86e0f9b912 | ||
|
|
36b1b4af2f | ||
|
|
d4220a76da | ||
|
|
10e8a2a05c | ||
|
|
64ac64fb66 | ||
|
|
491483d599 | ||
|
|
7574f586aa | ||
|
|
21ed31e33a | ||
|
|
e2c0d69c9c | ||
|
|
7bc9d453c2 | ||
|
|
c32efd9118 | ||
|
|
7354547bd8 | ||
|
|
a8ee83e2c5 | ||
|
|
e8ca30a5c7 | ||
|
|
bd2a6d5386 | ||
|
|
9189fe007b | ||
|
|
85ad0c276c | ||
|
|
65888d95c9 | ||
|
|
6908fb0de3 | ||
|
|
72c8f7700b | ||
|
|
88f2d234a4 | ||
|
|
ccdd2e6650 | ||
|
|
d7fdc01f7f | ||
|
|
02e3c046aa | ||
|
|
d0f1a838a6 | ||
|
|
5f121396e9 |
10
.github/workflows/checklocks.yml
vendored
10
.github/workflows/checklocks.yml
vendored
@@ -24,5 +24,11 @@ jobs:
|
||||
run: ./tool/go build -o /tmp/checklocks gvisor.dev/gvisor/tools/checklocks/cmd/checklocks
|
||||
|
||||
- name: Run checklocks vet
|
||||
# TODO: remove || true once we have applied checklocks annotations everywhere.
|
||||
run: ./tool/go vet -vettool=/tmp/checklocks ./... || true
|
||||
# TODO(#12625): add more packages as we add annotations
|
||||
run: |-
|
||||
./tool/go vet -vettool=/tmp/checklocks \
|
||||
./envknob \
|
||||
./ipn/store/mem \
|
||||
./net/stun/stuntest \
|
||||
./net/wsconn \
|
||||
./proxymap
|
||||
|
||||
5
.github/workflows/installer.yml
vendored
5
.github/workflows/installer.yml
vendored
@@ -67,6 +67,11 @@ jobs:
|
||||
image: ${{ matrix.image }}
|
||||
options: --user root
|
||||
steps:
|
||||
- name: install dependencies (pacman)
|
||||
# Refresh the package databases to ensure that the tailscale package is
|
||||
# defined.
|
||||
run: pacman -Sy
|
||||
if: contains(matrix.image, 'archlinux')
|
||||
- name: install dependencies (yum)
|
||||
# tar and gzip are needed by the actions/checkout below.
|
||||
run: yum install -y --allowerasing tar gzip ${{ matrix.deps }}
|
||||
|
||||
23
.github/workflows/ssh-integrationtest.yml
vendored
Normal file
23
.github/workflows/ssh-integrationtest.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Run the ssh integration tests with `make sshintegrationtest`.
|
||||
# These tests can also be running locally.
|
||||
name: "ssh-integrationtest"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "ssh/**"
|
||||
- "tempfork/gliderlabs/ssh/**"
|
||||
- ".github/workflows/ssh-integrationtest"
|
||||
jobs:
|
||||
ssh-integrationtest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Run SSH integration tests
|
||||
run: |
|
||||
make sshintegrationtest
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -194,7 +194,7 @@ jobs:
|
||||
- name: chown
|
||||
run: chown -R $(id -u):$(id -g) $PWD
|
||||
- name: privileged tests
|
||||
run: ./tool/go test ./util/linuxfw
|
||||
run: ./tool/go test ./util/linuxfw ./derp/xdp
|
||||
|
||||
vm:
|
||||
runs-on: ["self-hosted", "linux", "vm"]
|
||||
@@ -480,7 +480,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: check that 'go generate' is clean
|
||||
run: |
|
||||
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator')
|
||||
pkgs=$(./tool/go list ./... | grep -Ev 'dnsfallback|k8s-operator|xdp')
|
||||
./tool/go generate $pkgs
|
||||
echo
|
||||
echo
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -1,17 +1,13 @@
|
||||
# Copyright (c) Tailscale Inc & AUTHORS
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
############################################################################
|
||||
# Note that this Dockerfile is currently NOT used to build any of the published
|
||||
# Tailscale container images and may have drifted from the image build mechanism
|
||||
# we use.
|
||||
# Tailscale images are currently built using https://github.com/tailscale/mkctr,
|
||||
# and the build script can be found in ./build_docker.sh.
|
||||
#
|
||||
# WARNING: Tailscale is not yet officially supported in container
|
||||
# environments, such as Docker and Kubernetes. Though it should work, we
|
||||
# don't regularly test it, and we know there are some feature limitations.
|
||||
#
|
||||
# See current bugs tagged "containers":
|
||||
# https://github.com/tailscale/tailscale/labels/containers
|
||||
#
|
||||
############################################################################
|
||||
|
||||
# This Dockerfile includes all the tailscale binaries.
|
||||
#
|
||||
# To build the Dockerfile:
|
||||
@@ -46,7 +42,7 @@ RUN go install \
|
||||
gvisor.dev/gvisor/pkg/tcpip/stack \
|
||||
golang.org/x/crypto/ssh \
|
||||
golang.org/x/crypto/acme \
|
||||
nhooyr.io/websocket \
|
||||
github.com/coder/websocket \
|
||||
github.com/mdlayher/netlink
|
||||
|
||||
COPY . .
|
||||
|
||||
6
Makefile
6
Makefile
@@ -21,6 +21,7 @@ updatedeps: ## Update depaware deps
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper \
|
||||
tailscale.com/cmd/k8s-operator \
|
||||
tailscale.com/cmd/stund
|
||||
|
||||
depaware: ## Run depaware checks
|
||||
@@ -30,6 +31,7 @@ depaware: ## Run depaware checks
|
||||
tailscale.com/cmd/tailscaled \
|
||||
tailscale.com/cmd/tailscale \
|
||||
tailscale.com/cmd/derper \
|
||||
tailscale.com/cmd/k8s-operator \
|
||||
tailscale.com/cmd/stund
|
||||
|
||||
buildwindows: ## Build tailscale CLI for windows/amd64
|
||||
@@ -110,8 +112,8 @@ publishdevnameserver: ## Build and publish k8s-nameserver image to location spec
|
||||
|
||||
.PHONY: sshintegrationtest
|
||||
sshintegrationtest: ## Run the SSH integration tests in various Docker containers
|
||||
@GOOS=linux GOARCH=amd64 go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
|
||||
GOOS=linux GOARCH=amd64 go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
|
||||
@GOOS=linux GOARCH=amd64 ./tool/go test -tags integrationtest -c ./ssh/tailssh -o ssh/tailssh/testcontainers/tailssh.test && \
|
||||
GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \
|
||||
echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \
|
||||
echo "Testing on ubuntu:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.67.0
|
||||
1.72.0
|
||||
|
||||
3
api.md
3
api.md
@@ -1,3 +1,6 @@
|
||||
> [!IMPORTANT]
|
||||
> The Tailscale API documentation has moved to https://tailscale.com/api
|
||||
|
||||
# Tailscale API
|
||||
|
||||
The Tailscale API documentation is located in **[tailscale/publicapi](./publicapi/readme.md#tailscale-api)**.
|
||||
|
||||
@@ -11,6 +11,7 @@ package appc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/execqueue"
|
||||
"tailscale.com/util/mak"
|
||||
@@ -78,6 +80,42 @@ type RouteAdvertiser interface {
|
||||
UnadvertiseRoute(...netip.Prefix) error
|
||||
}
|
||||
|
||||
var (
|
||||
metricStoreRoutesRateBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000}
|
||||
metricStoreRoutesNBuckets = []int64{1, 2, 3, 4, 5, 10, 100, 1000, 10000}
|
||||
metricStoreRoutesRate []*clientmetric.Metric
|
||||
metricStoreRoutesN []*clientmetric.Metric
|
||||
)
|
||||
|
||||
func initMetricStoreRoutes() {
|
||||
for _, n := range metricStoreRoutesRateBuckets {
|
||||
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_rate_%d", n)))
|
||||
}
|
||||
metricStoreRoutesRate = append(metricStoreRoutesRate, clientmetric.NewCounter("appc_store_routes_rate_over"))
|
||||
for _, n := range metricStoreRoutesNBuckets {
|
||||
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter(fmt.Sprintf("appc_store_routes_n_routes_%d", n)))
|
||||
}
|
||||
metricStoreRoutesN = append(metricStoreRoutesN, clientmetric.NewCounter("appc_store_routes_n_routes_over"))
|
||||
}
|
||||
|
||||
func recordMetric(val int64, buckets []int64, metrics []*clientmetric.Metric) {
|
||||
if len(buckets) < 1 {
|
||||
return
|
||||
}
|
||||
// finds the first bucket where val <=, or len(buckets) if none match
|
||||
// for bucket values of 1, 10, 100; 0-1 goes to [0], 2-10 goes to [1], 11-100 goes to [2], 101+ goes to [3]
|
||||
bucket, _ := slices.BinarySearch(buckets, val)
|
||||
metrics[bucket].Add(1)
|
||||
}
|
||||
|
||||
func metricStoreRoutes(rate, nRoutes int64) {
|
||||
if len(metricStoreRoutesRate) == 0 {
|
||||
initMetricStoreRoutes()
|
||||
}
|
||||
recordMetric(rate, metricStoreRoutesRateBuckets, metricStoreRoutesRate)
|
||||
recordMetric(nRoutes, metricStoreRoutesNBuckets, metricStoreRoutesN)
|
||||
}
|
||||
|
||||
// RouteInfo is a data structure used to persist the in memory state of an AppConnector
|
||||
// so that we can know, even after a restart, which routes came from ACLs and which were
|
||||
// learned from domains.
|
||||
@@ -141,6 +179,7 @@ func NewAppConnector(logf logger.Logf, routeAdvertiser RouteAdvertiser, routeInf
|
||||
}
|
||||
ac.writeRateMinute = newRateLogger(time.Now, time.Minute, func(c int64, s time.Time, l int64) {
|
||||
ac.logf("routeInfo write rate: %d in minute starting at %v (%d routes)", c, s, l)
|
||||
metricStoreRoutes(c, l)
|
||||
})
|
||||
ac.writeRateDay = newRateLogger(time.Now, 24*time.Hour, func(c int64, s time.Time, l int64) {
|
||||
ac.logf("routeInfo write rate: %d in 24 hours starting at %v (%d routes)", c, s, l)
|
||||
@@ -442,8 +481,10 @@ func (e *AppConnector) ObserveDNSResponse(res []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
|
||||
e.scheduleAdvertisement(domain, toAdvertise...)
|
||||
if len(toAdvertise) > 0 {
|
||||
e.logf("[v2] observed new routes for %s: %s", domain, toAdvertise)
|
||||
e.scheduleAdvertisement(domain, toAdvertise...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/appc/appctest"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
@@ -569,3 +570,35 @@ func TestRateLogger(t *testing.T) {
|
||||
t.Fatalf("wasCalled: got false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteStoreMetrics(t *testing.T) {
|
||||
metricStoreRoutes(1, 1)
|
||||
metricStoreRoutes(1, 1) // the 1 buckets value should be 2
|
||||
metricStoreRoutes(5, 5) // the 5 buckets value should be 1
|
||||
metricStoreRoutes(6, 6) // the 10 buckets value should be 1
|
||||
metricStoreRoutes(10001, 10001) // the over buckets value should be 1
|
||||
wanted := map[string]int64{
|
||||
"appc_store_routes_n_routes_1": 2,
|
||||
"appc_store_routes_rate_1": 2,
|
||||
"appc_store_routes_n_routes_5": 1,
|
||||
"appc_store_routes_rate_5": 1,
|
||||
"appc_store_routes_n_routes_10": 1,
|
||||
"appc_store_routes_rate_10": 1,
|
||||
"appc_store_routes_n_routes_over": 1,
|
||||
"appc_store_routes_rate_over": 1,
|
||||
}
|
||||
for _, x := range clientmetric.Metrics() {
|
||||
if x.Value() != wanted[x.Name()] {
|
||||
t.Errorf("%s: want: %d, got: %d", x.Name(), wanted[x.Name()], x.Value())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricBucketsAreSorted(t *testing.T) {
|
||||
if !slices.IsSorted(metricStoreRoutesRateBuckets) {
|
||||
t.Errorf("metricStoreRoutesRateBuckets must be in order")
|
||||
}
|
||||
if !slices.IsSorted(metricStoreRoutesNBuckets) {
|
||||
t.Errorf("metricStoreRoutesNBuckets must be in order")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package appctest contains code to help test App Connectors.
|
||||
package appctest
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Runs `go build` with flags configured for docker distribution. All
|
||||
# it does differently from `go build` is burn git commit and version
|
||||
# information into the binaries inside docker, so that we can track down user
|
||||
# issues.
|
||||
#
|
||||
############################################################################
|
||||
#
|
||||
# WARNING: Tailscale is not yet officially supported in container
|
||||
# environments, such as Docker and Kubernetes. Though it should work, we
|
||||
# don't regularly test it, and we know there are some feature limitations.
|
||||
#
|
||||
# See current bugs tagged "containers":
|
||||
# https://github.com/tailscale/tailscale/labels/containers
|
||||
#
|
||||
############################################################################
|
||||
# This script builds Tailscale container images using
|
||||
# github.com/tailscale/mkctr.
|
||||
# By default the images will be tagged with the current version and git
|
||||
# hash of this repository as produced by ./cmd/mkversion.
|
||||
# This is the image build mechanim used to build the official Tailscale
|
||||
# container images.
|
||||
|
||||
set -eu
|
||||
|
||||
@@ -49,7 +39,7 @@ case "$TARGET" in
|
||||
-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--gotags="ts_kube" \
|
||||
--gotags="ts_kube,ts_package_container" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}" \
|
||||
--target="${PLATFORM}" \
|
||||
|
||||
@@ -37,6 +37,16 @@ type ACLTest struct {
|
||||
Allow []string `json:"allow,omitempty"` // old name for accept
|
||||
}
|
||||
|
||||
// NodeAttrGrant defines additional string attributes that apply to specific devices.
|
||||
type NodeAttrGrant struct {
|
||||
// Target specifies which nodes the attributes apply to. The nodes can be a
|
||||
// tag (tag:server), user (alice@example.com), group (group:kids), or *.
|
||||
Target []string `json:"target,omitempty"`
|
||||
|
||||
// Attr are the attributes to set on Target(s).
|
||||
Attr []string `json:"attr,omitempty"`
|
||||
}
|
||||
|
||||
// ACLDetails contains all the details for an ACL.
|
||||
type ACLDetails struct {
|
||||
Tests []ACLTest `json:"tests,omitempty"`
|
||||
@@ -44,6 +54,7 @@ type ACLDetails struct {
|
||||
Groups map[string][]string `json:"groups,omitempty"`
|
||||
TagOwners map[string][]string `json:"tagowners,omitempty"`
|
||||
Hosts map[string]string `json:"hosts,omitempty"`
|
||||
NodeAttrs []NodeAttrGrant `json:"nodeAttrs,omitempty"`
|
||||
}
|
||||
|
||||
// ACL contains an ACLDetails and metadata.
|
||||
@@ -150,7 +161,12 @@ func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
// ACLTestFailureSummary specifies the JSON format sent to the
|
||||
// JavaScript client to be rendered in the HTML.
|
||||
type ACLTestFailureSummary struct {
|
||||
User string `json:"user,omitempty"`
|
||||
// User is the source ("src") value of the ACL test that failed.
|
||||
// The name "user" is a legacy holdover from the original naming and
|
||||
// is kept for compatibility but it may also contain any value
|
||||
// that's valid in a ACL test "src" field.
|
||||
User string `json:"user,omitempty"`
|
||||
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
@@ -270,6 +286,9 @@ type UserRuleMatch struct {
|
||||
Users []string `json:"users"`
|
||||
Ports []string `json:"ports"`
|
||||
LineNumber int `json:"lineNumber"`
|
||||
// Via is the list of targets through which Users can access Ports.
|
||||
// See https://tailscale.com/kb/1378/via for more information.
|
||||
Via []string `json:"via,omitempty"`
|
||||
|
||||
// Postures is a list of posture policies that are
|
||||
// associated with this match. The rules can be looked
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
@@ -39,6 +40,7 @@ type Device struct {
|
||||
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
|
||||
Addresses []string `json:"addresses"`
|
||||
DeviceID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
@@ -213,6 +215,9 @@ func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("RESP: %di, path: %s", resp.StatusCode, path)
|
||||
|
||||
// If status code was not successful, return the error.
|
||||
// TODO: Change the check for the StatusCode to include other 2XX success codes.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -69,6 +69,14 @@ type LocalClient struct {
|
||||
// connecting to the GUI client variants.
|
||||
UseSocketOnly bool
|
||||
|
||||
// OmitAuth, if true, omits sending the local Tailscale daemon any
|
||||
// authentication token that might be required by the platform.
|
||||
//
|
||||
// As of 2024-08-12, only macOS uses an authentication token. OmitAuth is
|
||||
// meant for when Dial is set and the LocalAPI is being proxied to a
|
||||
// different operating system, such as in integration tests.
|
||||
OmitAuth bool
|
||||
|
||||
// tsClient does HTTP requests to the local Tailscale daemon.
|
||||
// It's lazily initialized on first use.
|
||||
tsClient *http.Client
|
||||
@@ -103,7 +111,7 @@ func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string)
|
||||
return d.DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
|
||||
}
|
||||
}
|
||||
return safesocket.Connect(lc.socket())
|
||||
return safesocket.ConnectContext(ctx, lc.socket())
|
||||
}
|
||||
|
||||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||||
@@ -124,8 +132,10 @@ func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error)
|
||||
},
|
||||
}
|
||||
})
|
||||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
req.SetBasicAuth("", token)
|
||||
if !lc.OmitAuth {
|
||||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
req.SetBasicAuth("", token)
|
||||
}
|
||||
}
|
||||
return lc.tsClient.Do(req)
|
||||
}
|
||||
@@ -253,11 +263,16 @@ func (lc *LocalClient) sendWithHeaders(
|
||||
}
|
||||
if res.StatusCode != wantStatus {
|
||||
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
|
||||
return nil, nil, bestError(err, slurp)
|
||||
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
|
||||
}
|
||||
return slurp, res.Header, nil
|
||||
}
|
||||
|
||||
type httpStatusError struct {
|
||||
error
|
||||
HTTPStatus int
|
||||
}
|
||||
|
||||
func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) {
|
||||
return lc.send(ctx, "GET", path, 200, nil)
|
||||
}
|
||||
@@ -278,9 +293,50 @@ func decodeJSON[T any](b []byte) (ret T, err error) {
|
||||
}
|
||||
|
||||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||||
//
|
||||
// If not found, the error is ErrPeerNotFound.
|
||||
//
|
||||
// For connections proxied by tailscaled, this looks up the owner of the given
|
||||
// address as TCP first, falling back to UDP; if you want to only check a
|
||||
// specific address family, use WhoIsProto.
|
||||
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
|
||||
return nil, ErrPeerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
}
|
||||
|
||||
// ErrPeerNotFound is returned by WhoIs and WhoIsNodeKey when a peer is not found.
|
||||
var ErrPeerNotFound = errors.New("peer not found")
|
||||
|
||||
// WhoIsNodeKey returns the owner of the given wireguard public key.
|
||||
//
|
||||
// If not found, the error is ErrPeerNotFound.
|
||||
func (lc *LocalClient) WhoIsNodeKey(ctx context.Context, key key.NodePublic) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(key.String()))
|
||||
if err != nil {
|
||||
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
|
||||
return nil, ErrPeerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
}
|
||||
|
||||
// WhoIsProto returns the owner of the remoteAddr, which must be an IP or
|
||||
// IP:port, for the given protocol (tcp or udp).
|
||||
//
|
||||
// If not found, the error is ErrPeerNotFound.
|
||||
func (lc *LocalClient) WhoIsProto(ctx context.Context, proto, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?proto="+url.QueryEscape(proto)+"&addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
if hs, ok := err.(httpStatusError); ok && hs.HTTPStatus == http.StatusNotFound {
|
||||
return nil, ErrPeerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return decodeJSON[*apitype.WhoIsResponse](body)
|
||||
@@ -887,7 +943,20 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *LocalClient) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
res, err := lc.send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil)
|
||||
return lc.CertPairWithValidity(ctx, domain, 0)
|
||||
}
|
||||
|
||||
// CertPairWithValidity returns a cert and private key for the provided DNS
|
||||
// domain.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
// When minValidity is non-zero, the returned certificate will be valid for at
|
||||
// least the given duration, if permitted by the CA. If the certificate is
|
||||
// valid, but for less than minValidity, it will be synchronously renewed.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *LocalClient) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
|
||||
res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -6,9 +6,14 @@
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tstest/deptest"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
@@ -30,6 +35,32 @@ func TestGetServeConfigFromJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoIsPeerNotFound(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
lc := &LocalClient{
|
||||
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var std net.Dialer
|
||||
return std.DialContext(ctx, network, ts.Listener.Addr().(*net.TCPAddr).String())
|
||||
},
|
||||
}
|
||||
var k key.NodePublic
|
||||
if err := k.UnmarshalText([]byte("nodekey:5c8f86d5fc70d924e55f02446165a5dae8f822994ad26bcf4b08fd841f9bf261")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := lc.WhoIsNodeKey(context.Background(), k)
|
||||
if err != ErrPeerNotFound {
|
||||
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
|
||||
}
|
||||
res, err = lc.WhoIs(context.Background(), "1.2.3.4:5678")
|
||||
if err != ErrPeerNotFound {
|
||||
t.Errorf("got (%v, %v), want ErrPeerNotFound", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeps(t *testing.T) {
|
||||
deptest.DepChecker{
|
||||
BadDeps: map[string]string{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "18.16.1",
|
||||
"node": "18.20.4",
|
||||
"yarn": "1.22.19"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -37,11 +37,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
CurrentTrack = ""
|
||||
StableTrack = "stable"
|
||||
UnstableTrack = "unstable"
|
||||
)
|
||||
|
||||
var CurrentTrack = func() string {
|
||||
if version.IsUnstableBuild() {
|
||||
return UnstableTrack
|
||||
} else {
|
||||
return StableTrack
|
||||
}
|
||||
}()
|
||||
|
||||
func versionToTrack(v string) (string, error) {
|
||||
_, rest, ok := strings.Cut(v, ".")
|
||||
if !ok {
|
||||
@@ -106,7 +113,7 @@ func (args Arguments) validate() error {
|
||||
return fmt.Errorf("only one of Version(%q) or Track(%q) can be set", args.Version, args.Track)
|
||||
}
|
||||
switch args.Track {
|
||||
case StableTrack, UnstableTrack, CurrentTrack:
|
||||
case StableTrack, UnstableTrack, "":
|
||||
// All valid values.
|
||||
default:
|
||||
return fmt.Errorf("unsupported track %q", args.Track)
|
||||
@@ -119,11 +126,17 @@ type Updater struct {
|
||||
// Update is a platform-specific method that updates the installation. May be
|
||||
// nil (not all platforms support updates from within Tailscale).
|
||||
Update func() error
|
||||
|
||||
// currentVersion is the short form of the current client version as
|
||||
// returned by version.Short(), typically "x.y.z". Used for tests to
|
||||
// override the actual current version.
|
||||
currentVersion string
|
||||
}
|
||||
|
||||
func NewUpdater(args Arguments) (*Updater, error) {
|
||||
up := Updater{
|
||||
Arguments: args,
|
||||
Arguments: args,
|
||||
currentVersion: version.Short(),
|
||||
}
|
||||
if up.Stdout == nil {
|
||||
up.Stdout = os.Stdout
|
||||
@@ -139,18 +152,15 @@ func NewUpdater(args Arguments) (*Updater, error) {
|
||||
if args.ForAutoUpdate && !canAutoUpdate {
|
||||
return nil, errors.ErrUnsupported
|
||||
}
|
||||
if up.Track == CurrentTrack {
|
||||
switch {
|
||||
case up.Version != "":
|
||||
if up.Track == "" {
|
||||
if up.Version != "" {
|
||||
var err error
|
||||
up.Track, err = versionToTrack(args.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case version.IsUnstableBuild():
|
||||
up.Track = UnstableTrack
|
||||
default:
|
||||
up.Track = StableTrack
|
||||
} else {
|
||||
up.Track = CurrentTrack
|
||||
}
|
||||
}
|
||||
if up.Arguments.PkgsAddr == "" {
|
||||
@@ -238,6 +248,11 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
|
||||
// CanAutoUpdate reports whether auto-updating via the clientupdate package
|
||||
// is supported for the current os/distro.
|
||||
func CanAutoUpdate() bool {
|
||||
if version.IsMacSysExt() {
|
||||
// Macsys uses Sparkle for auto-updates, which doesn't have an update
|
||||
// function in this package.
|
||||
return true
|
||||
}
|
||||
_, canAutoUpdate := (&Updater{}).getUpdateFunction()
|
||||
return canAutoUpdate
|
||||
}
|
||||
@@ -259,13 +274,16 @@ func Update(args Arguments) error {
|
||||
}
|
||||
|
||||
func (up *Updater) confirm(ver string) bool {
|
||||
switch cmpver.Compare(version.Short(), ver) {
|
||||
case 0:
|
||||
up.Logf("already running %v version %v; no update needed", up.Track, ver)
|
||||
return false
|
||||
case 1:
|
||||
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.Track, version.Short(), ver)
|
||||
return false
|
||||
// Only check version when we're not switching tracks.
|
||||
if up.Track == "" || up.Track == CurrentTrack {
|
||||
switch c := cmpver.Compare(up.currentVersion, ver); {
|
||||
case c == 0:
|
||||
up.Logf("already running %v version %v; no update needed", up.Track, ver)
|
||||
return false
|
||||
case c > 0:
|
||||
up.Logf("installed %v version %v is newer than the latest available version %v; no update needed", up.Track, up.currentVersion, ver)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if up.Confirm != nil {
|
||||
return up.Confirm(ver)
|
||||
@@ -681,7 +699,7 @@ func parseAlpinePackageVersion(out []byte) (string, error) {
|
||||
return "", fmt.Errorf("malformed info line: %q", line)
|
||||
}
|
||||
ver := parts[1]
|
||||
if cmpver.Compare(ver, maxVer) == 1 {
|
||||
if cmpver.Compare(ver, maxVer) > 0 {
|
||||
maxVer = ver
|
||||
}
|
||||
}
|
||||
@@ -880,7 +898,7 @@ func (up *Updater) installMSI(msi string) error {
|
||||
break
|
||||
}
|
||||
up.Logf("Install attempt failed: %v", err)
|
||||
uninstallVersion := version.Short()
|
||||
uninstallVersion := up.currentVersion
|
||||
if v := os.Getenv("TS_DEBUG_UNINSTALL_VERSION"); v != "" {
|
||||
uninstallVersion = v
|
||||
}
|
||||
@@ -1331,12 +1349,8 @@ func requestedTailscaleVersion(ver, track string) (string, error) {
|
||||
// LatestTailscaleVersion returns the latest released version for the given
|
||||
// track from pkgs.tailscale.com.
|
||||
func LatestTailscaleVersion(track string) (string, error) {
|
||||
if track == CurrentTrack {
|
||||
if version.IsUnstableBuild() {
|
||||
track = UnstableTrack
|
||||
} else {
|
||||
track = StableTrack
|
||||
}
|
||||
if track == "" {
|
||||
track = CurrentTrack
|
||||
}
|
||||
|
||||
latest, err := latestPackages(track)
|
||||
|
||||
@@ -846,3 +846,107 @@ func TestParseUnraidPluginVersion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirm(t *testing.T) {
|
||||
curTrack := CurrentTrack
|
||||
defer func() { CurrentTrack = curTrack }()
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
fromTrack string
|
||||
toTrack string
|
||||
fromVer string
|
||||
toVer string
|
||||
confirm func(string) bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
desc: "on latest stable",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.66.0",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
desc: "stable upgrade",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.68.0",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "unstable upgrade",
|
||||
fromTrack: UnstableTrack,
|
||||
toTrack: UnstableTrack,
|
||||
fromVer: "1.67.1",
|
||||
toVer: "1.67.2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "from stable to unstable",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: UnstableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.67.1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "from unstable to stable",
|
||||
fromTrack: UnstableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.67.1",
|
||||
toVer: "1.66.0",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "confirm callback rejects",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.66.1",
|
||||
confirm: func(string) bool {
|
||||
return false
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
desc: "confirm callback allows",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.0",
|
||||
toVer: "1.66.1",
|
||||
confirm: func(string) bool {
|
||||
return true
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
desc: "downgrade",
|
||||
fromTrack: StableTrack,
|
||||
toTrack: StableTrack,
|
||||
fromVer: "1.66.1",
|
||||
toVer: "1.66.0",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
CurrentTrack = tt.fromTrack
|
||||
up := Updater{
|
||||
currentVersion: tt.fromVer,
|
||||
Arguments: Arguments{
|
||||
Track: tt.toTrack,
|
||||
Confirm: tt.confirm,
|
||||
Logf: t.Logf,
|
||||
},
|
||||
}
|
||||
|
||||
if got := up.confirm(tt.toVer); got != tt.want {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,11 @@ func main() {
|
||||
w(" return false")
|
||||
w("}")
|
||||
}
|
||||
cloneOutput := pkg.Name + "_clone.go"
|
||||
cloneOutput := pkg.Name + "_clone"
|
||||
if *flagBuildTags == "test" {
|
||||
cloneOutput += "_test"
|
||||
}
|
||||
cloneOutput += ".go"
|
||||
if err := codegen.WritePackageFile("tailscale.com/cmd/cloner", pkg, cloneOutput, it, buf); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -91,16 +95,19 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
}
|
||||
|
||||
name := typ.Obj().Name()
|
||||
typeParams := typ.Origin().TypeParams()
|
||||
_, typeParamNames := codegen.FormatTypeParams(typeParams, it)
|
||||
nameWithParams := name + typeParamNames
|
||||
fmt.Fprintf(buf, "// Clone makes a deep copy of %s.\n", name)
|
||||
fmt.Fprintf(buf, "// The result aliases no memory with the original.\n")
|
||||
fmt.Fprintf(buf, "func (src *%s) Clone() *%s {\n", name, name)
|
||||
fmt.Fprintf(buf, "func (src *%s) Clone() *%s {\n", nameWithParams, nameWithParams)
|
||||
writef := func(format string, args ...any) {
|
||||
fmt.Fprintf(buf, "\t"+format+"\n", args...)
|
||||
}
|
||||
writef("if src == nil {")
|
||||
writef("\treturn nil")
|
||||
writef("}")
|
||||
writef("dst := new(%s)", name)
|
||||
writef("dst := new(%s)", nameWithParams)
|
||||
writef("*dst = *src")
|
||||
for i := range t.NumFields() {
|
||||
fname := t.Field(i).Name()
|
||||
@@ -126,16 +133,23 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
|
||||
writef("for i := range dst.%s {", fname)
|
||||
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
|
||||
if _, isBasic := ptr.Elem().Underlying().(*types.Basic); isBasic {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname)
|
||||
writef("\tdst.%s[i] = ptr.To(*src.%s[i])", fname, fname)
|
||||
writef("}")
|
||||
writef("if src.%s[i] == nil { dst.%s[i] = nil } else {", fname, fname)
|
||||
if codegen.ContainsPointers(ptr.Elem()) {
|
||||
if _, isIface := ptr.Elem().Underlying().(*types.Interface); isIface {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("\tdst.%s[i] = ptr.To((*src.%s[i]).Clone())", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
} else {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("\tdst.%s[i] = ptr.To(*src.%s[i])", fname, fname)
|
||||
}
|
||||
writef("}")
|
||||
} else if ft.Elem().String() == "encoding/json.RawMessage" {
|
||||
writef("\tdst.%s[i] = append(src.%s[i][:0:0], src.%s[i]...)", fname, fname, fname)
|
||||
} else if _, isIface := ft.Elem().Underlying().(*types.Interface); isIface {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
@@ -145,14 +159,19 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("dst.%s = append(src.%s[:0:0], src.%s...)", fname, fname, fname)
|
||||
}
|
||||
case *types.Pointer:
|
||||
if named, _ := ft.Elem().(*types.Named); named != nil && codegen.ContainsPointers(ft.Elem()) {
|
||||
base := ft.Elem()
|
||||
hasPtrs := codegen.ContainsPointers(base)
|
||||
if named, _ := base.(*types.Named); named != nil && hasPtrs {
|
||||
writef("dst.%s = src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = ptr.To(*src.%s)", fname, fname)
|
||||
if codegen.ContainsPointers(ft.Elem()) {
|
||||
if _, isIface := base.Underlying().(*types.Interface); isIface && hasPtrs {
|
||||
writef("\tdst.%s = ptr.To((*src.%s).Clone())", fname, fname)
|
||||
} else if !hasPtrs {
|
||||
writef("\tdst.%s = ptr.To(*src.%s)", fname, fname)
|
||||
} else {
|
||||
writef("\t" + `panic("TODO pointers in pointers")`)
|
||||
}
|
||||
writef("}")
|
||||
@@ -172,18 +191,50 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(elem))
|
||||
writef("\tfor k, v := range src.%s {", fname)
|
||||
switch elem.(type) {
|
||||
|
||||
switch elem := elem.Underlying().(type) {
|
||||
case *types.Pointer:
|
||||
writef("\t\tdst.%s[k] = v.Clone()", fname)
|
||||
writef("\t\tif v == nil { dst.%s[k] = nil } else {", fname)
|
||||
if base := elem.Elem().Underlying(); codegen.ContainsPointers(base) {
|
||||
if _, isIface := base.(*types.Interface); isIface {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("\t\t\tdst.%s[k] = ptr.To((*v).Clone())", fname)
|
||||
} else {
|
||||
writef("\t\t\tdst.%s[k] = v.Clone()", fname)
|
||||
}
|
||||
} else {
|
||||
it.Import("tailscale.com/types/ptr")
|
||||
writef("\t\t\tdst.%s[k] = ptr.To(*v)", fname)
|
||||
}
|
||||
writef("}")
|
||||
case *types.Interface:
|
||||
if cloneResultType := methodResultType(elem, "Clone"); cloneResultType != nil {
|
||||
if _, isPtr := cloneResultType.(*types.Pointer); isPtr {
|
||||
writef("\t\tdst.%s[k] = *(v.Clone())", fname)
|
||||
} else {
|
||||
writef("\t\tdst.%s[k] = v.Clone()", fname)
|
||||
}
|
||||
} else {
|
||||
writef(`panic("%s (%v) does not have a Clone method")`, fname, elem)
|
||||
}
|
||||
default:
|
||||
writef("\t\tdst.%s[k] = *(v.Clone())", fname)
|
||||
}
|
||||
|
||||
writef("\t}")
|
||||
writef("}")
|
||||
} else {
|
||||
it.Import("maps")
|
||||
writef("\tdst.%s = maps.Clone(src.%s)", fname, fname)
|
||||
}
|
||||
case *types.Interface:
|
||||
// If ft is an interface with a "Clone() ft" method, it can be used to clone the field.
|
||||
// This includes scenarios where ft is a constrained type parameter.
|
||||
if cloneResultType := methodResultType(ft, "Clone"); cloneResultType.Underlying() == ft {
|
||||
writef("dst.%s = src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
writef(`panic("%s (%v) does not have a compatible Clone method")`, fname, ft)
|
||||
default:
|
||||
writef(`panic("TODO: %s (%T)")`, fname, ft)
|
||||
}
|
||||
@@ -191,7 +242,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
writef("return dst")
|
||||
fmt.Fprintf(buf, "}\n\n")
|
||||
|
||||
buf.Write(codegen.AssertStructUnchanged(t, name, "Clone", it))
|
||||
buf.Write(codegen.AssertStructUnchanged(t, name, typeParams, "Clone", it))
|
||||
}
|
||||
|
||||
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
|
||||
@@ -203,3 +254,15 @@ func hasBasicUnderlying(typ types.Type) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func methodResultType(typ types.Type, method string) types.Type {
|
||||
viewMethod := codegen.LookupMethod(typ, method)
|
||||
if viewMethod == nil {
|
||||
return nil
|
||||
}
|
||||
sig, ok := viewMethod.Type().(*types.Signature)
|
||||
if !ok || sig.Results().Len() != 1 {
|
||||
return nil
|
||||
}
|
||||
return sig.Results().At(0).Type()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type SliceContainer
|
||||
|
||||
// Package clonerex is an example package for the cloner tool.
|
||||
package clonerex
|
||||
|
||||
type SliceContainer struct {
|
||||
|
||||
@@ -19,22 +19,20 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// storeDeviceInfo writes deviceID into the "device_id" data field of the kube
|
||||
// secret secretName.
|
||||
func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID, fqdn string, addresses []netip.Prefix) error {
|
||||
// First check if the secret exists at all. Even if running on
|
||||
// kubernetes, we do not necessarily store state in a k8s secret.
|
||||
if _, err := kc.GetSecret(ctx, secretName); err != nil {
|
||||
if s, ok := err.(*kube.Status); ok {
|
||||
if s.Code >= 400 && s.Code <= 499 {
|
||||
// Assume the secret doesn't exist, or we don't have
|
||||
// permission to access it.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
// storeDeviceID writes deviceID to 'device_id' data field of the named
|
||||
// Kubernetes Secret.
|
||||
func storeDeviceID(ctx context.Context, secretName string, deviceID tailcfg.StableNodeID) error {
|
||||
s := &kube.Secret{
|
||||
Data: map[string][]byte{
|
||||
"device_id": []byte(deviceID),
|
||||
},
|
||||
}
|
||||
return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container")
|
||||
}
|
||||
|
||||
// storeDeviceEndpoints writes device's tailnet IPs and MagicDNS name to fields
|
||||
// 'device_ips', 'device_fqdn' of the named Kubernetes Secret.
|
||||
func storeDeviceEndpoints(ctx context.Context, secretName string, fqdn string, addresses []netip.Prefix) error {
|
||||
var ips []string
|
||||
for _, addr := range addresses {
|
||||
ips = append(ips, addr.Addr().String())
|
||||
@@ -44,14 +42,13 @@ func storeDeviceInfo(ctx context.Context, secretName string, deviceID tailcfg.St
|
||||
return err
|
||||
}
|
||||
|
||||
m := &kube.Secret{
|
||||
s := &kube.Secret{
|
||||
Data: map[string][]byte{
|
||||
"device_id": []byte(deviceID),
|
||||
"device_fqdn": []byte(fqdn),
|
||||
"device_ips": deviceIPs,
|
||||
},
|
||||
}
|
||||
return kc.StrategicMergePatchSecret(ctx, secretName, m, "tailscale-container")
|
||||
return kc.StrategicMergePatchSecret(ctx, secretName, s, "tailscale-container")
|
||||
}
|
||||
|
||||
// deleteAuthKey deletes the 'authkey' field of the given kube
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN.
|
||||
// It cannot be used in conjunction with TS_DEST_IP. The file is watched for changes,
|
||||
// and will be re-applied when it changes.
|
||||
// - TS_HEALTHCHECK_ADDR_PORT: if specified, an HTTP health endpoint will be
|
||||
// served at /healthz at the provided address, which should be in form [<address>]:<port>.
|
||||
// If not set, no health check will be run. If set to :<port>, addr will default to 0.0.0.0
|
||||
// The health endpoint will return 200 OK if this node has at least one tailnet IP address,
|
||||
// otherwise returns 503.
|
||||
// NB: the health criteria might change in the future.
|
||||
// - TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR: if specified, a path to a
|
||||
// directory that containers tailscaled config in file. The config file needs to be
|
||||
// named cap-<current-tailscaled-cap>.hujson. If this is set, TS_HOSTNAME,
|
||||
@@ -95,6 +101,7 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -158,6 +165,7 @@ func main() {
|
||||
AllowProxyingClusterTrafficViaIngress: defaultBool("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS", false),
|
||||
PodIP: defaultEnv("POD_IP", ""),
|
||||
EnableForwardingOptimizations: defaultBool("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS", false),
|
||||
HealthCheckAddrPort: defaultEnv("TS_HEALTHCHECK_ADDR_PORT", ""),
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
@@ -321,7 +329,7 @@ authLoop:
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch && isTwoStepConfigAuthOnce(cfg) {
|
||||
if hasKubeStateStore(cfg) && isTwoStepConfigAuthOnce(cfg) {
|
||||
// We were told to only auth once, so any secret-bound
|
||||
// authkey is no longer needed. We don't strictly need to
|
||||
// wipe it, but it's good hygiene.
|
||||
@@ -337,11 +345,10 @@ authLoop:
|
||||
}
|
||||
|
||||
var (
|
||||
wantProxy = cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress
|
||||
wantDeviceInfo = cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch
|
||||
startupTasksDone = false
|
||||
currentIPs deephash.Sum // tailscale IPs assigned to device
|
||||
currentDeviceInfo deephash.Sum // device ID and fqdn
|
||||
startupTasksDone = false
|
||||
currentIPs deephash.Sum // tailscale IPs assigned to device
|
||||
currentDeviceID deephash.Sum // device ID
|
||||
currentDeviceEndpoints deephash.Sum // device FQDN and IPs
|
||||
|
||||
currentEgressIPs deephash.Sum
|
||||
|
||||
@@ -350,12 +357,15 @@ authLoop:
|
||||
|
||||
certDomain = new(atomic.Pointer[string])
|
||||
certDomainChanged = make(chan bool, 1)
|
||||
|
||||
h = &healthz{} // http server for the healthz endpoint
|
||||
healthzRunner = sync.OnceFunc(func() { runHealthz(cfg.HealthCheckAddrPort, h) })
|
||||
)
|
||||
if cfg.ServeConfigPath != "" {
|
||||
go watchServeConfigChanges(ctx, cfg.ServeConfigPath, certDomainChanged, certDomain, client)
|
||||
}
|
||||
var nfr linuxfw.NetfilterRunner
|
||||
if wantProxy {
|
||||
if isL3Proxy(cfg) {
|
||||
nfr, err = newNetfilterRunner(log.Printf)
|
||||
if err != nil {
|
||||
log.Fatalf("error creating new netfilter runner: %v", err)
|
||||
@@ -440,6 +450,20 @@ runLoop:
|
||||
newCurrentIPs := deephash.Hash(&addrs)
|
||||
ipsHaveChanged := newCurrentIPs != currentIPs
|
||||
|
||||
// Store device ID in a Kubernetes Secret before
|
||||
// setting up any routing rules. This ensures
|
||||
// that, for containerboot instances that are
|
||||
// Kubernetes operator proxies, the operator is
|
||||
// able to retrieve the device ID from the
|
||||
// Kubernetes Secret to clean up tailnet nodes
|
||||
// for proxies whose route setup continuously
|
||||
// fails.
|
||||
deviceID := n.NetMap.SelfNode.StableID()
|
||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceID, &deviceID) {
|
||||
if err := storeDeviceID(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID()); err != nil {
|
||||
log.Fatalf("storing device ID in Kubernetes Secret: %v", err)
|
||||
}
|
||||
}
|
||||
if cfg.TailnetTargetFQDN != "" {
|
||||
var (
|
||||
egressAddrs []netip.Prefix
|
||||
@@ -463,18 +487,20 @@ runLoop:
|
||||
newCurentEgressIPs = deephash.Hash(&egressAddrs)
|
||||
egressIPsHaveChanged = newCurentEgressIPs != currentEgressIPs
|
||||
if egressIPsHaveChanged && len(egressAddrs) != 0 {
|
||||
var rulesInstalled bool
|
||||
for _, egressAddr := range egressAddrs {
|
||||
ea := egressAddr.Addr()
|
||||
// TODO (irbekrm): make it work for IPv6 too.
|
||||
if ea.Is6() {
|
||||
log.Println("Not installing egress forwarding rules for IPv6 as this is currently not supported")
|
||||
continue
|
||||
}
|
||||
log.Printf("Installing forwarding rules for destination %v", ea.String())
|
||||
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
|
||||
log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err)
|
||||
if ea.Is4() || (ea.Is6() && nfr.HasIPV6NAT()) {
|
||||
rulesInstalled = true
|
||||
log.Printf("Installing forwarding rules for destination %v", ea.String())
|
||||
if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
|
||||
log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !rulesInstalled {
|
||||
log.Fatalf("no forwarding rules for egress addresses %v, host supports IPv6: %v", egressAddrs, nfr.HasIPV6NAT())
|
||||
}
|
||||
}
|
||||
currentEgressIPs = newCurentEgressIPs
|
||||
}
|
||||
@@ -533,15 +559,43 @@ runLoop:
|
||||
}
|
||||
currentIPs = newCurrentIPs
|
||||
|
||||
deviceInfo := []any{n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name()}
|
||||
if cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != "" && deephash.Update(¤tDeviceInfo, &deviceInfo) {
|
||||
if err := storeDeviceInfo(ctx, cfg.KubeSecret, n.NetMap.SelfNode.StableID(), n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
|
||||
log.Fatalf("storing device ID in kube secret: %v", err)
|
||||
// Only store device FQDN and IP addresses to
|
||||
// Kubernetes Secret when any required proxy
|
||||
// route setup has succeeded. IPs and FQDN are
|
||||
// read from the Secret by the Tailscale
|
||||
// Kubernetes operator and, for some proxy
|
||||
// types, such as Tailscale Ingress, advertized
|
||||
// on the Ingress status. Writing them to the
|
||||
// Secret only after the proxy routing has been
|
||||
// set up ensures that the operator does not
|
||||
// advertize endpoints of broken proxies.
|
||||
// TODO (irbekrm): instead of using the IP and FQDN, have some other mechanism for the proxy signal that it is 'Ready'.
|
||||
deviceEndpoints := []any{n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses()}
|
||||
if hasKubeStateStore(cfg) && deephash.Update(¤tDeviceEndpoints, &deviceEndpoints) {
|
||||
if err := storeDeviceEndpoints(ctx, cfg.KubeSecret, n.NetMap.SelfNode.Name(), n.NetMap.SelfNode.Addresses().AsSlice()); err != nil {
|
||||
log.Fatalf("storing device IPs and FQDN in Kubernetes Secret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.HealthCheckAddrPort != "" {
|
||||
h.Lock()
|
||||
h.hasAddrs = len(addrs) != 0
|
||||
h.Unlock()
|
||||
healthzRunner()
|
||||
}
|
||||
}
|
||||
if !startupTasksDone {
|
||||
if (!wantProxy || currentIPs != deephash.Sum{}) && (!wantDeviceInfo || currentDeviceInfo != deephash.Sum{}) {
|
||||
// For containerboot instances that act as TCP
|
||||
// proxies (proxying traffic to an endpoint
|
||||
// passed via one of the env vars that
|
||||
// containerbot reads) and store state in a
|
||||
// Kubernetes Secret, we consider startup tasks
|
||||
// done at the point when device info has been
|
||||
// successfully stored to state Secret.
|
||||
// For all other containerboot instances, if we
|
||||
// just get to this point the startup tasks can
|
||||
// be considered done.
|
||||
if !isL3Proxy(cfg) || !hasKubeStateStore(cfg) || (currentDeviceEndpoints != deephash.Sum{} && currentDeviceID != deephash.Sum{}) {
|
||||
// This log message is used in tests to detect when all
|
||||
// post-auth configuration is done.
|
||||
log.Println("Startup complete, waiting for shutdown signal")
|
||||
@@ -907,7 +961,7 @@ func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
func installEgressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
|
||||
dst, err := netip.ParseAddr(dstStr)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1116,7 +1170,8 @@ type settings struct {
|
||||
// PodIP is the IP of the Pod if running in Kubernetes. This is used
|
||||
// when setting up rules to proxy cluster traffic to cluster ingress
|
||||
// target.
|
||||
PodIP string
|
||||
PodIP string
|
||||
HealthCheckAddrPort string
|
||||
}
|
||||
|
||||
func (s *settings) validate() error {
|
||||
@@ -1165,6 +1220,11 @@ func (s *settings) validate() error {
|
||||
if s.EnableForwardingOptimizations && s.UserspaceMode {
|
||||
return errors.New("TS_EXPERIMENTAL_ENABLE_FORWARDING_OPTIMIZATIONS is not supported in userspace mode")
|
||||
}
|
||||
if s.HealthCheckAddrPort != "" {
|
||||
if _, err := netip.ParseAddrPort(s.HealthCheckAddrPort); err != nil {
|
||||
return fmt.Errorf("error parsing TS_HEALTH_CHECK_ADDR_PORT value %q: %w", s.HealthCheckAddrPort, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1287,6 +1347,19 @@ func isOneStepConfig(cfg *settings) bool {
|
||||
return cfg.TailscaledConfigFilePath != ""
|
||||
}
|
||||
|
||||
// isL3Proxy returns true if the Tailscale node needs to be configured to act
|
||||
// as an L3 proxy, proxying to an endpoint provided via one of the config env
|
||||
// vars.
|
||||
func isL3Proxy(cfg *settings) bool {
|
||||
return cfg.ProxyTargetIP != "" || cfg.ProxyTargetDNSName != "" || cfg.TailnetTargetIP != "" || cfg.TailnetTargetFQDN != "" || cfg.AllowProxyingClusterTrafficViaIngress
|
||||
}
|
||||
|
||||
// hasKubeStateStore returns true if the state must be stored in a Kubernetes
|
||||
// Secret.
|
||||
func hasKubeStateStore(cfg *settings) bool {
|
||||
return cfg.InKubernetes && cfg.KubernetesCanPatch && cfg.KubeSecret != ""
|
||||
}
|
||||
|
||||
// tailscaledConfigFilePath returns the path to the tailscaled config file that
|
||||
// should be used for the current capability version. It is determined by the
|
||||
// TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR environment variable and looks for a
|
||||
@@ -1325,3 +1398,41 @@ func tailscaledConfigFilePath() string {
|
||||
log.Printf("Using tailscaled config file %q for capability version %q", maxCompatVer, tailcfg.CurrentCapabilityVersion)
|
||||
return path.Join(dir, kubeutils.TailscaledConfigFileNameForCap(maxCompatVer))
|
||||
}
|
||||
|
||||
// healthz is a simple health check server, if enabled it returns 200 OK if
|
||||
// this tailscale node currently has at least one tailnet IP address else
|
||||
// returns 503.
|
||||
type healthz struct {
|
||||
sync.Mutex
|
||||
hasAddrs bool
|
||||
}
|
||||
|
||||
func (h *healthz) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.Lock()
|
||||
defer h.Unlock()
|
||||
if h.hasAddrs {
|
||||
w.Write([]byte("ok"))
|
||||
} else {
|
||||
http.Error(w, "node currently has no tailscale IPs", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// runHealthz runs a simple HTTP health endpoint on /healthz, listening on the
|
||||
// provided address. A containerized tailscale instance is considered healthy if
|
||||
// it has at least one tailnet IP address.
|
||||
func runHealthz(addr string, h *healthz) {
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Fatalf("error listening on the provided health endpoint address %q: %v", addr, err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", h)
|
||||
log.Printf("Running healthcheck endpoint at %s/healthz", addr)
|
||||
hs := &http.Server{Handler: mux}
|
||||
|
||||
go func() {
|
||||
if err := hs.Serve(lis); err != nil {
|
||||
log.Fatalf("failed running health endpoint: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestContainerBoot(t *testing.T) {
|
||||
}
|
||||
defer kube.Close()
|
||||
|
||||
tailscaledConf := &ipn.ConfigVAlpha{AuthKey: func(s string) *string { return &s }("foo"), Version: "alpha0"}
|
||||
tailscaledConf := &ipn.ConfigVAlpha{AuthKey: ptr.To("foo"), Version: "alpha0"}
|
||||
tailscaledConfBytes, err := json.Marshal(tailscaledConf)
|
||||
if err != nil {
|
||||
t.Fatalf("error unmarshaling tailscaled config: %v", err)
|
||||
@@ -116,6 +116,9 @@ func TestContainerBoot(t *testing.T) {
|
||||
// WantFiles files that should exist in the container and their
|
||||
// contents.
|
||||
WantFiles map[string]string
|
||||
// WantFatalLog is the fatal log message we expect from containerboot.
|
||||
// If set for a phase, the test will finish on that phase.
|
||||
WantFatalLog string
|
||||
}
|
||||
runningNotify := &ipn.Notify{
|
||||
State: ptr.To(ipn.Running),
|
||||
@@ -349,12 +352,57 @@ func TestContainerBoot(t *testing.T) {
|
||||
"/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",
|
||||
},
|
||||
WantFiles: map[string]string{
|
||||
"proc/sys/net/ipv4/ip_forward": "1",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: runningNotify,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "egress_proxy_fqdn_ipv6_target_on_ipv4_host",
|
||||
Env: map[string]string{
|
||||
"TS_AUTHKEY": "tskey-key",
|
||||
"TS_TAILNET_TARGET_FQDN": "ipv6-node.test.ts.net", // resolves to IPv6 address
|
||||
"TS_USERSPACE": "false",
|
||||
"TS_TEST_FAKE_NETFILTER_6": "false",
|
||||
},
|
||||
Phases: []phase{
|
||||
{
|
||||
WantCmds: []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",
|
||||
},
|
||||
WantFiles: map[string]string{
|
||||
"proc/sys/net/ipv4/ip_forward": "1",
|
||||
"proc/sys/net/ipv6/conf/all/forwarding": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
Notify: &ipn.Notify{
|
||||
State: ptr.To(ipn.Running),
|
||||
NetMap: &netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("myID"),
|
||||
Name: "test-node.test.ts.net",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
|
||||
}).View(),
|
||||
Peers: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
StableID: tailcfg.StableNodeID("ipv6ID"),
|
||||
Name: "ipv6-node.test.ts.net",
|
||||
Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
|
||||
}).View(),
|
||||
},
|
||||
},
|
||||
},
|
||||
WantFatalLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authkey_once",
|
||||
Env: map[string]string{
|
||||
@@ -697,6 +745,25 @@ func TestContainerBoot(t *testing.T) {
|
||||
var wantCmds []string
|
||||
for i, p := range test.Phases {
|
||||
lapi.Notify(p.Notify)
|
||||
if p.WantFatalLog != "" {
|
||||
err := tstest.WaitFor(2*time.Second, func() error {
|
||||
state, err := cmd.Process.Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.ExitCode() != 1 {
|
||||
return fmt.Errorf("process exited with code %d but wanted %d", state.ExitCode(), 1)
|
||||
}
|
||||
waitLogLine(t, time.Second, cbOut, p.WantFatalLog)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Early test return, we don't expect the successful startup log message.
|
||||
return
|
||||
}
|
||||
wantCmds = append(wantCmds, p.WantCmds...)
|
||||
waitArgs(t, 2*time.Second, d, argFile, strings.Join(wantCmds, "\n"))
|
||||
err := tstest.WaitFor(2*time.Second, func() error {
|
||||
|
||||
109
cmd/derper/README.md
Normal file
109
cmd/derper/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# DERP
|
||||
|
||||
This is the code for the [Tailscale DERP server](https://tailscale.com/kb/1232/derp-servers).
|
||||
|
||||
In general, you should not need to or want to run this code. The overwhelming
|
||||
majority of Tailscale users (both individuals and companies) do not.
|
||||
|
||||
In the happy path, Tailscale establishes direct connections between peers and
|
||||
data plane traffic flows directly between them, without using DERP for more than
|
||||
acting as a low bandwidth side channel to bootstrap the NAT traversal. If you
|
||||
find yourself wanting DERP for more bandwidth, the real problem is usually the
|
||||
network configuration of your Tailscale node(s), making sure that Tailscale can
|
||||
get direction connections via some mechanism.
|
||||
|
||||
If you've decided or been advised to run your own `derper`, then read on.
|
||||
|
||||
## Caveats
|
||||
|
||||
* Node sharing and other cross-Tailnet features don't work when using custom
|
||||
DERP servers.
|
||||
|
||||
* DERP servers only see encrypted WireGuard packets and thus are not useful for
|
||||
network-level debugging.
|
||||
|
||||
* The Tailscale control plane does certain geo-level steering features and
|
||||
optimizations that are not available when using custom DERP servers.
|
||||
|
||||
## Guide to running `cmd/derper`
|
||||
|
||||
* You must build and update the `cmd/derper` binary yourself. There are no
|
||||
packages. Use `go install tailscale.com/cmd/derper@latest` with the latest
|
||||
version of Go. You should update this binary approximately as regularly as
|
||||
you update Tailscale nodes. If using `--verify-clients`, the `derper` binary
|
||||
and `tailscaled` binary on the machine must be built from the same git revision.
|
||||
(It might work otherwise, but they're developed and only tested together.)
|
||||
|
||||
* The DERP protocol does a protocol switch inside TLS from HTTP to a custom
|
||||
bidirectional binary protocol. It is thus incompatible with many HTTP proxies.
|
||||
Do not put `derper` behind another HTTP proxy.
|
||||
|
||||
* The `tailscaled` client does its own selection of the fastest/nearest DERP
|
||||
server based on latency measurements. Do not put `derper` behind a global load
|
||||
balancer.
|
||||
|
||||
* DERP servers should ideally have both a static IPv4 and static IPv6 address.
|
||||
Both of those should be listed in the DERP map so the client doesn't need to
|
||||
rely on its DNS which might be broken and dependent on DERP to get back up.
|
||||
|
||||
* A DERP server should not share an IP address with any other DERP server.
|
||||
|
||||
* Avoid having multiple DERP nodes in a region. If you must, they all need to be
|
||||
meshed with each other and monitored. Having two one-node "regions" in the
|
||||
same datacenter is usually easier and more reliable than meshing, at the cost
|
||||
of more required connections from clients in some cases. If your clients
|
||||
aren't mobile (battery constrained), one node regions are definitely
|
||||
preferred. If you really need multiple nodes in a region for HA reasons, two
|
||||
is sufficient.
|
||||
|
||||
* Monitor your DERP servers with [`cmd/derpprobe`](../derpprobe/).
|
||||
|
||||
* If using `--verify-clients`, a `tailscaled` must be running alongside the
|
||||
`derper`, and all clients must be visible to the derper tailscaled in the ACL.
|
||||
|
||||
* If using `--verify-clients`, a `tailscaled` must also be running alongside
|
||||
your `derpprobe`, and `derpprobe` needs to use `--derp-map=local`.
|
||||
|
||||
* The firewall on the `derper` should permit TCP ports 80 and 443 and UDP port
|
||||
3478.
|
||||
|
||||
* Only LetsEncrypt certs are rotated automatically. Other cert updates require a
|
||||
restart.
|
||||
|
||||
* Don't use a firewall in front of `derper` that suppresses `RST`s upon
|
||||
receiving traffic to a dead or unknown connection.
|
||||
|
||||
* Don't rate-limit UDP STUN packets.
|
||||
|
||||
* Don't rate-limit outbound TCP traffic (only inbound).
|
||||
|
||||
## Diagnostics
|
||||
|
||||
This is not a complete guide on DERP diagnostics.
|
||||
|
||||
Running your own DERP services requires exeprtise in multi-layer network and
|
||||
application diagnostics. As the DERP runs multiple protocols at multiple layers
|
||||
and is not a regular HTTP(s) server you will need expertise in correlative
|
||||
analysis to diagnose the most tricky problems. There is no "plain text" or
|
||||
"open" mode of operation for DERP.
|
||||
|
||||
* The debug handler is accessible at URL path `/debug/`. It is only accessible
|
||||
over localhost or from a Tailscale IP address.
|
||||
|
||||
* Go pprof can be accessed via the debug handler at `/debug/pprof/`
|
||||
|
||||
* Prometheus compatible metrics can be gathered from the debug handler at
|
||||
`/debug/varz`.
|
||||
|
||||
* `cmd/stunc` in the Tailscale repository provides a basic tool for diagnosing
|
||||
issues with STUN.
|
||||
|
||||
* `cmd/derpprobe` provides a service for monitoring DERP cluster health.
|
||||
|
||||
* `tailscale debug derp` and `tailscale netcheck` provide additional client
|
||||
driven diagnostic information for DERP communications.
|
||||
|
||||
* Tailscale logs may provide insight for certain problems, such as if DERPs are
|
||||
unreachable or peers are regularly not reachable in their DERP home regions.
|
||||
There are many possible misconfiguration causes for these problems, but
|
||||
regular log entries are a good first indicator that there is a problem.
|
||||
@@ -7,9 +7,19 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
||||
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
|
||||
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
|
||||
github.com/coder/websocket from tailscale.com/cmd/derper+
|
||||
github.com/coder/websocket/internal/errd from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/util from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
|
||||
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
|
||||
W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
github.com/go-json-experiment/json from tailscale.com/types/opt+
|
||||
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
L github.com/google/nftables from tailscale.com/util/linuxfw
|
||||
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
|
||||
@@ -46,7 +56,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
L github.com/vishvananda/netns from github.com/tailscale/netlink+
|
||||
github.com/x448/float16 from github.com/fxamacker/cbor/v2
|
||||
💣 go4.org/mem from tailscale.com/client/tailscale+
|
||||
go4.org/netipx from tailscale.com/net/tsaddr+
|
||||
go4.org/netipx from tailscale.com/net/tsaddr
|
||||
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/netmon+
|
||||
google.golang.org/protobuf/encoding/protodelim from github.com/prometheus/common/expfmt
|
||||
google.golang.org/protobuf/encoding/prototext from github.com/prometheus/common/expfmt+
|
||||
@@ -76,10 +86,6 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
google.golang.org/protobuf/runtime/protoiface from google.golang.org/protobuf/internal/impl+
|
||||
google.golang.org/protobuf/runtime/protoimpl from github.com/prometheus/client_model/go+
|
||||
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
|
||||
nhooyr.io/websocket from tailscale.com/cmd/derper+
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/util from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
tailscale.com from tailscale.com/version
|
||||
tailscale.com/atomicfile from tailscale.com/cmd/derper+
|
||||
tailscale.com/client/tailscale from tailscale.com/derp
|
||||
@@ -95,14 +101,12 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
|
||||
tailscale.com/metrics from tailscale.com/cmd/derper+
|
||||
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/flowtrack from tailscale.com/net/packet+
|
||||
tailscale.com/net/ktimeout from tailscale.com/cmd/derper
|
||||
tailscale.com/net/netaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/netknob from tailscale.com/net/netns
|
||||
💣 tailscale.com/net/netmon from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/netns from tailscale.com/derp/derphttp
|
||||
💣 tailscale.com/net/netns from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/netutil from tailscale.com/client/tailscale
|
||||
tailscale.com/net/packet from tailscale.com/wgengine/filter
|
||||
tailscale.com/net/sockstats from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/stun from tailscale.com/net/stunserver
|
||||
tailscale.com/net/stunserver from tailscale.com/cmd/derper
|
||||
@@ -116,16 +120,16 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/syncs from tailscale.com/cmd/derper+
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale+
|
||||
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||
W tailscale.com/tsconst from tailscale.com/net/netmon
|
||||
W tailscale.com/tsconst from tailscale.com/net/netmon+
|
||||
tailscale.com/tstime from tailscale.com/derp+
|
||||
tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||
tailscale.com/tstime/rate from tailscale.com/derp+
|
||||
tailscale.com/tstime/rate from tailscale.com/derp
|
||||
tailscale.com/tsweb from tailscale.com/cmd/derper
|
||||
tailscale.com/tsweb/promvarz from tailscale.com/tsweb
|
||||
tailscale.com/tsweb/varz from tailscale.com/tsweb+
|
||||
tailscale.com/types/dnstype from tailscale.com/tailcfg
|
||||
tailscale.com/types/empty from tailscale.com/ipn
|
||||
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
|
||||
tailscale.com/types/ipproto from tailscale.com/tailcfg+
|
||||
tailscale.com/types/key from tailscale.com/client/tailscale+
|
||||
tailscale.com/types/lazy from tailscale.com/version+
|
||||
tailscale.com/types/logger from tailscale.com/cmd/derper+
|
||||
@@ -142,9 +146,11 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/util/cloudenv from tailscale.com/hostinfo+
|
||||
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
|
||||
tailscale.com/util/ctxkey from tailscale.com/tsweb+
|
||||
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/hostinfo+
|
||||
tailscale.com/util/fastuuid from tailscale.com/tsweb
|
||||
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
|
||||
tailscale.com/util/httpm from tailscale.com/client/tailscale
|
||||
tailscale.com/util/lineread from tailscale.com/hostinfo+
|
||||
L tailscale.com/util/linuxfw from tailscale.com/net/netns
|
||||
@@ -155,12 +161,14 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache
|
||||
tailscale.com/util/slicesx from tailscale.com/cmd/derper+
|
||||
tailscale.com/util/syspolicy from tailscale.com/ipn
|
||||
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/vizerror from tailscale.com/tailcfg+
|
||||
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
tailscale.com/version/distro from tailscale.com/envknob+
|
||||
tailscale.com/wgengine/filter from tailscale.com/types/netmap
|
||||
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap
|
||||
golang.org/x/crypto/acme from golang.org/x/crypto/acme/autocert
|
||||
golang.org/x/crypto/acme/autocert from tailscale.com/cmd/derper
|
||||
golang.org/x/crypto/argon2 from tailscale.com/tka
|
||||
@@ -176,6 +184,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
W golang.org/x/exp/constraints from tailscale.com/util/winutil
|
||||
golang.org/x/exp/maps from tailscale.com/util/syspolicy/setting
|
||||
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
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The derper binary is a simple DERP server.
|
||||
//
|
||||
// For more information, see:
|
||||
//
|
||||
// - About: https://tailscale.com/kb/1232/derp-servers
|
||||
// - Protocol & Go docs: https://pkg.go.dev/tailscale.com/derp
|
||||
// - Running a DERP server: https://github.com/tailscale/tailscale/tree/main/cmd/derper#derp
|
||||
package main // import "tailscale.com/cmd/derper"
|
||||
|
||||
import (
|
||||
@@ -22,6 +28,9 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
runtimemetrics "runtime/metrics"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -206,11 +215,16 @@ func main() {
|
||||
io.WriteString(w, `<html><body>
|
||||
<h1>DERP</h1>
|
||||
<p>
|
||||
This is a
|
||||
<a href="https://tailscale.com/">Tailscale</a>
|
||||
<a href="https://pkg.go.dev/tailscale.com/derp">DERP</a>
|
||||
server.
|
||||
This is a <a href="https://tailscale.com/">Tailscale</a> DERP server.
|
||||
</p>
|
||||
<p>
|
||||
Documentation:
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="https://tailscale.com/kb/1232/derp-servers">About DERP</a></li>
|
||||
<li><a href="https://pkg.go.dev/tailscale.com/derp">Protocol & Go docs</a></li>
|
||||
<li><a href="https://github.com/tailscale/tailscale/tree/main/cmd/derper#derp">How to run a DERP server</a></li>
|
||||
</ul>
|
||||
`)
|
||||
if !*runDERP {
|
||||
io.WriteString(w, `<p>Status: <b>disabled</b></p>`)
|
||||
@@ -223,7 +237,7 @@ func main() {
|
||||
tsweb.AddBrowserHeaders(w)
|
||||
io.WriteString(w, "User-agent: *\nDisallow: /\n")
|
||||
}))
|
||||
mux.Handle("/generate_204", http.HandlerFunc(serveNoContent))
|
||||
mux.Handle("/generate_204", http.HandlerFunc(derphttp.ServeNoContent))
|
||||
debug := tsweb.Debugger(mux)
|
||||
debug.KV("TLS hostname", *hostname)
|
||||
debug.KV("Mesh key", s.HasMeshKey())
|
||||
@@ -236,6 +250,20 @@ func main() {
|
||||
}
|
||||
}))
|
||||
debug.Handle("traffic", "Traffic check", http.HandlerFunc(s.ServeDebugTraffic))
|
||||
debug.Handle("set-mutex-profile-fraction", "SetMutexProfileFraction", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s := r.FormValue("rate")
|
||||
if s == "" || r.Header.Get("Sec-Debug") != "derp" {
|
||||
http.Error(w, "To set, use: curl -HSec-Debug:derp 'http://derp/debug/set-mutex-profile-fraction?rate=100'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
http.Error(w, "bad rate value", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
old := runtime.SetMutexProfileFraction(v)
|
||||
fmt.Fprintf(w, "mutex changed from %v to %v\n", old, v)
|
||||
}))
|
||||
|
||||
// Longer lived DERP connections send an application layer keepalive. Note
|
||||
// if the keepalive is hit, the user timeout will take precedence over the
|
||||
@@ -309,7 +337,7 @@ func main() {
|
||||
if *httpPort > -1 {
|
||||
go func() {
|
||||
port80mux := http.NewServeMux()
|
||||
port80mux.HandleFunc("/generate_204", serveNoContent)
|
||||
port80mux.HandleFunc("/generate_204", derphttp.ServeNoContent)
|
||||
port80mux.Handle("/", certManager.HTTPHandler(tsweb.Port80Handler{Main: mux}))
|
||||
port80srv := &http.Server{
|
||||
Addr: net.JoinHostPort(listenHost, fmt.Sprintf("%d", *httpPort)),
|
||||
@@ -350,31 +378,6 @@ 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 == '_'
|
||||
}
|
||||
|
||||
var validProdHostname = regexp.MustCompile(`^derp([^.]*)\.tailscale\.com\.?$`)
|
||||
|
||||
func prodAutocertHostPolicy(_ context.Context, host string) error {
|
||||
@@ -452,3 +455,16 @@ func (l *rateLimitedListener) Accept() (net.Conn, error) {
|
||||
l.numAccepts.Add(1)
|
||||
return cn, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
expvar.Publish("go_sync_mutex_wait_seconds", expvar.Func(func() any {
|
||||
const name = "/sync/mutex/wait/total:seconds" // Go 1.20+
|
||||
var s [1]runtimemetrics.Sample
|
||||
s[0].Name = name
|
||||
runtimemetrics.Read(s[:])
|
||||
if v := s[0].Value; v.Kind() == runtimemetrics.KindFloat64 {
|
||||
return v.Float64()
|
||||
}
|
||||
return 0
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/derp/derphttp"
|
||||
"tailscale.com/tstest/deptest"
|
||||
)
|
||||
|
||||
@@ -76,20 +77,20 @@ func TestNoContent(t *testing.T) {
|
||||
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)
|
||||
req.Header.Set(derphttp.NoContentChallengeHeader, tt.input)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
serveNoContent(w, req)
|
||||
derphttp.ServeNoContent(w, req)
|
||||
resp := w.Result()
|
||||
|
||||
if tt.want == "" {
|
||||
if h, found := resp.Header[noContentResponseHeader]; found {
|
||||
if h, found := resp.Header[derphttp.NoContentResponseHeader]; found {
|
||||
t.Errorf("got %+v; expected no response header", h)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if got := resp.Header.Get(noContentResponseHeader); got != tt.want {
|
||||
if got := resp.Header.Get(derphttp.NoContentResponseHeader); got != tt.want {
|
||||
t.Errorf("got %q; want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -104,6 +105,8 @@ func TestDeps(t *testing.T) {
|
||||
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"tailscale.com/net/packet": "not needed in derper",
|
||||
"github.com/gaissmai/bart": "not needed in derper",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
|
||||
@@ -9,14 +9,12 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/derp/derphttp"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
@@ -71,8 +69,8 @@ func startMeshWithHost(s *derp.Server, host string) error {
|
||||
return d.DialContext(ctx, network, addr)
|
||||
})
|
||||
|
||||
add := func(k key.NodePublic, _ netip.AddrPort) { s.AddPacketForwarder(k, c) }
|
||||
remove := func(k key.NodePublic) { s.RemovePacketForwarder(k, c) }
|
||||
add := func(m derp.PeerPresentMessage) { s.AddPacketForwarder(m.Key, c) }
|
||||
remove := func(m derp.PeerGoneMessage) { s.RemovePacketForwarder(m.Peer, c) }
|
||||
go c.RunWatchConnectionLoop(context.Background(), s.PublicKey(), logf, add, remove)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
"github.com/coder/websocket"
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/net/wsconn"
|
||||
)
|
||||
|
||||
@@ -7,8 +7,6 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
@@ -70,8 +68,13 @@ func main() {
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
tsweb.Debugger(mux)
|
||||
mux.HandleFunc("/", http.HandlerFunc(serveFunc(p)))
|
||||
d := tsweb.Debugger(mux)
|
||||
d.Handle("probe-run", "Run a probe", tsweb.StdHandler(tsweb.ReturnHandlerFunc(p.RunHandler), tsweb.HandlerOptions{Logf: log.Printf}))
|
||||
mux.Handle("/", tsweb.StdHandler(p.StatusHandler(
|
||||
prober.WithTitle("DERP Prober"),
|
||||
prober.WithPageLink("Prober metrics", "/debug/varz"),
|
||||
prober.WithProbeLink("Run Probe", "/debug/probe-run?name={{.Name}}"),
|
||||
), tsweb.HandlerOptions{Logf: log.Printf}))
|
||||
log.Printf("Listening on %s", *listen)
|
||||
log.Fatal(http.ListenAndServe(*listen, mux))
|
||||
}
|
||||
@@ -105,26 +108,3 @@ func getOverallStatus(p *prober.Prober) (o overallStatus) {
|
||||
sort.Strings(o.good)
|
||||
return
|
||||
}
|
||||
|
||||
func serveFunc(p *prober.Prober) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
st := getOverallStatus(p)
|
||||
summary := "All good"
|
||||
if (float64(len(st.bad)) / float64(len(st.bad)+len(st.good))) > 0.25 {
|
||||
// Returning a 500 allows monitoring this server externally and configuring
|
||||
// an alert on HTTP response code.
|
||||
w.WriteHeader(500)
|
||||
summary = fmt.Sprintf("%d problems", len(st.bad))
|
||||
}
|
||||
|
||||
io.WriteString(w, "<html><head><style>.bad { font-weight: bold; color: #700; }</style></head>\n")
|
||||
fmt.Fprintf(w, "<body><h1>derp probe</h1>\n%s:<ul>", summary)
|
||||
for _, s := range st.bad {
|
||||
fmt.Fprintf(w, "<li class=bad>%s</li>\n", html.EscapeString(s))
|
||||
}
|
||||
for _, s := range st.good {
|
||||
fmt.Fprintf(w, "<li>%s</li>\n", html.EscapeString(s))
|
||||
}
|
||||
io.WriteString(w, "</ul></body></html>\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,19 +28,20 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
rootFlagSet = flag.NewFlagSet("gitops-pusher", flag.ExitOnError)
|
||||
policyFname = rootFlagSet.String("policy-file", "./policy.hujson", "filename for policy file")
|
||||
cacheFname = rootFlagSet.String("cache-file", "./version-cache.json", "filename for the previous known version hash")
|
||||
timeout = rootFlagSet.Duration("timeout", 5*time.Minute, "timeout for the entire CI run")
|
||||
githubSyntax = rootFlagSet.Bool("github-syntax", true, "use GitHub Action error syntax (https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message)")
|
||||
apiServer = rootFlagSet.String("api-server", "api.tailscale.com", "API server to contact")
|
||||
rootFlagSet = flag.NewFlagSet("gitops-pusher", flag.ExitOnError)
|
||||
policyFname = rootFlagSet.String("policy-file", "./policy.hujson", "filename for policy file")
|
||||
cacheFname = rootFlagSet.String("cache-file", "./version-cache.json", "filename for the previous known version hash")
|
||||
timeout = rootFlagSet.Duration("timeout", 5*time.Minute, "timeout for the entire CI run")
|
||||
githubSyntax = rootFlagSet.Bool("github-syntax", true, "use GitHub Action error syntax (https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message)")
|
||||
apiServer = rootFlagSet.String("api-server", "api.tailscale.com", "API server to contact")
|
||||
failOnManualEdits = rootFlagSet.Bool("fail-on-manual-edits", false, "fail if manual edits to the ACLs in the admin panel are detected; when set to false (the default) only a warning is printed")
|
||||
)
|
||||
|
||||
func modifiedExternallyError() {
|
||||
func modifiedExternallyError() error {
|
||||
if *githubSyntax {
|
||||
fmt.Printf("::warning file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.\n", *policyFname)
|
||||
return fmt.Errorf("::warning file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.", *policyFname)
|
||||
} else {
|
||||
fmt.Printf("The policy file was modified externally in the admin console.\n")
|
||||
return fmt.Errorf("The policy file was modified externally in the admin console.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,16 +66,22 @@ func apply(cache *Cache, client *http.Client, tailnet, apiKey string) func(conte
|
||||
log.Printf("local: %s", localEtag)
|
||||
log.Printf("cache: %s", cache.PrevETag)
|
||||
|
||||
if cache.PrevETag != controlEtag {
|
||||
modifiedExternallyError()
|
||||
}
|
||||
|
||||
if controlEtag == localEtag {
|
||||
cache.PrevETag = localEtag
|
||||
log.Println("no update needed, doing nothing")
|
||||
return nil
|
||||
}
|
||||
|
||||
if cache.PrevETag != controlEtag {
|
||||
if err := modifiedExternallyError(); err != nil {
|
||||
if *failOnManualEdits {
|
||||
return err
|
||||
} else {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := applyNewACL(ctx, client, tailnet, apiKey, *policyFname, controlEtag); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -106,15 +113,21 @@ func test(cache *Cache, client *http.Client, tailnet, apiKey string) func(contex
|
||||
log.Printf("local: %s", localEtag)
|
||||
log.Printf("cache: %s", cache.PrevETag)
|
||||
|
||||
if cache.PrevETag != controlEtag {
|
||||
modifiedExternallyError()
|
||||
}
|
||||
|
||||
if controlEtag == localEtag {
|
||||
log.Println("no updates found, doing nothing")
|
||||
return nil
|
||||
}
|
||||
|
||||
if cache.PrevETag != controlEtag {
|
||||
if err := modifiedExternallyError(); err != nil {
|
||||
if *failOnManualEdits {
|
||||
return err
|
||||
} else {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := testNewACLs(ctx, client, tailnet, apiKey, *policyFname); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -33,11 +33,8 @@ import (
|
||||
|
||||
const (
|
||||
reasonConnectorCreationFailed = "ConnectorCreationFailed"
|
||||
|
||||
reasonConnectorCreated = "ConnectorCreated"
|
||||
reasonConnectorCleanupFailed = "ConnectorCleanupFailed"
|
||||
reasonConnectorCleanupInProgress = "ConnectorCleanupInProgress"
|
||||
reasonConnectorInvalid = "ConnectorInvalid"
|
||||
reasonConnectorCreated = "ConnectorCreated"
|
||||
reasonConnectorInvalid = "ConnectorInvalid"
|
||||
|
||||
messageConnectorCreationFailed = "Failed creating Connector: %v"
|
||||
messageConnectorInvalid = "Connector is invalid: %v"
|
||||
@@ -108,7 +105,7 @@ func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Reque
|
||||
}
|
||||
|
||||
oldCnStatus := cn.Status.DeepCopy()
|
||||
setStatus := func(cn *tsapi.Connector, _ tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
setStatus := func(cn *tsapi.Connector, _ tsapi.ConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
tsoperator.SetConnectorCondition(cn, tsapi.ConnectorReady, status, reason, message, cn.Generation, a.clock, logger)
|
||||
if !apiequality.Semantic.DeepEqual(oldCnStatus, cn.Status) {
|
||||
// An error encountered here should get returned by the Reconcile function.
|
||||
|
||||
@@ -272,9 +272,9 @@ func TestConnectorWithProxyClass(t *testing.T) {
|
||||
// its resources.
|
||||
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
|
||||
pc.Status = tsapi.ProxyClassStatus{
|
||||
Conditions: []tsapi.ConnectorCondition{{
|
||||
Conditions: []metav1.Condition{{
|
||||
Status: metav1.ConditionTrue,
|
||||
Type: tsapi.ProxyClassready,
|
||||
Type: string(tsapi.ProxyClassready),
|
||||
ObservedGeneration: pc.Generation,
|
||||
}}}
|
||||
})
|
||||
|
||||
1001
cmd/k8s-operator/depaware.txt
Normal file
1001
cmd/k8s-operator/depaware.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,9 @@ spec:
|
||||
value: "{{ .Values.apiServerProxyConfig.mode }}"
|
||||
- name: PROXY_FIREWALL_MODE
|
||||
value: {{ .Values.proxyConfig.firewallMode }}
|
||||
{{- with .Values.operatorConfig.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: oauth
|
||||
mountPath: /oauth
|
||||
|
||||
@@ -48,6 +48,13 @@ operatorConfig:
|
||||
|
||||
securityContext: {}
|
||||
|
||||
extraEnv: []
|
||||
# - name: EXTRA_VAR1
|
||||
# value: "value1"
|
||||
# - name: EXTRA_VAR2
|
||||
# value: "value2"
|
||||
|
||||
|
||||
# proxyConfig contains configuraton that will be applied to any ingress/egress
|
||||
# proxies created by the operator.
|
||||
# https://tailscale.com/kb/1236/kubernetes-operator/#cluster-ingress
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
|
||||
name: connectors.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
@@ -31,48 +31,95 @@ spec:
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: 'Connector defines a Tailscale node that will be deployed in the cluster. The node can be configured to act as a Tailscale subnet router and/or a Tailscale exit node. Connector is a cluster-scoped resource. More info: https://tailscale.com/kb/1236/kubernetes-operator#deploying-exit-nodes-and-subnet-routers-on-kubernetes-using-connector-custom-resource'
|
||||
description: |-
|
||||
Connector defines a Tailscale node that will be deployed in the cluster. The
|
||||
node can be configured to act as a Tailscale subnet router and/or a Tailscale
|
||||
exit node.
|
||||
Connector is a cluster-scoped resource.
|
||||
More info:
|
||||
https://tailscale.com/kb/1236/kubernetes-operator#deploying-exit-nodes-and-subnet-routers-on-kubernetes-using-connector-custom-resource
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
properties:
|
||||
apiVersion:
|
||||
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: 'ConnectorSpec describes the desired Tailscale component. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status'
|
||||
description: |-
|
||||
ConnectorSpec describes the desired Tailscale component.
|
||||
More info:
|
||||
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
|
||||
type: object
|
||||
properties:
|
||||
exitNode:
|
||||
description: ExitNode defines whether the Connector node should act as a Tailscale exit node. Defaults to false. https://tailscale.com/kb/1103/exit-nodes
|
||||
description: |-
|
||||
ExitNode defines whether the Connector node should act as a
|
||||
Tailscale exit node. Defaults to false.
|
||||
https://tailscale.com/kb/1103/exit-nodes
|
||||
type: boolean
|
||||
hostname:
|
||||
description: Hostname is the tailnet hostname that should be assigned to the Connector node. If unset, hostname defaults to <connector name>-connector. Hostname can contain lower case letters, numbers and dashes, it must not start or end with a dash and must be between 2 and 63 characters long.
|
||||
description: |-
|
||||
Hostname is the tailnet hostname that should be assigned to the
|
||||
Connector node. If unset, hostname defaults to <connector
|
||||
name>-connector. Hostname can contain lower case letters, numbers and
|
||||
dashes, it must not start or end with a dash and must be between 2
|
||||
and 63 characters long.
|
||||
type: string
|
||||
pattern: ^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$
|
||||
proxyClass:
|
||||
description: ProxyClass is the name of the ProxyClass custom resource that contains configuration options that should be applied to the resources created for this Connector. If unset, the operator will create resources with the default configuration.
|
||||
description: |-
|
||||
ProxyClass is the name of the ProxyClass custom resource that
|
||||
contains configuration options that should be applied to the
|
||||
resources created for this Connector. If unset, the operator will
|
||||
create resources with the default configuration.
|
||||
type: string
|
||||
subnetRouter:
|
||||
description: SubnetRouter defines subnet routes that the Connector node should expose to tailnet. If unset, none are exposed. https://tailscale.com/kb/1019/subnets/
|
||||
description: |-
|
||||
SubnetRouter defines subnet routes that the Connector node should
|
||||
expose to tailnet. If unset, none are exposed.
|
||||
https://tailscale.com/kb/1019/subnets/
|
||||
type: object
|
||||
required:
|
||||
- advertiseRoutes
|
||||
properties:
|
||||
advertiseRoutes:
|
||||
description: AdvertiseRoutes refer to CIDRs that the subnet router should make available. Route values must be strings that represent a valid IPv4 or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes. https://tailscale.com/kb/1201/4via6-subnets/
|
||||
description: |-
|
||||
AdvertiseRoutes refer to CIDRs that the subnet router should make
|
||||
available. Route values must be strings that represent a valid IPv4
|
||||
or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes.
|
||||
https://tailscale.com/kb/1201/4via6-subnets/
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: string
|
||||
format: cidr
|
||||
tags:
|
||||
description: Tags that the Tailscale node will be tagged with. Defaults to [tag:k8s]. To autoapprove the subnet routes or exit node defined by a Connector, you can configure Tailscale ACLs to give these tags the necessary permissions. See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes. If you specify custom tags here, you must also make the operator an owner of these tags. See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. Tags cannot be changed once a Connector node has been created. Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
|
||||
description: |-
|
||||
Tags that the Tailscale node will be tagged with.
|
||||
Defaults to [tag:k8s].
|
||||
To autoapprove the subnet routes or exit node defined by a Connector,
|
||||
you can configure Tailscale ACLs to give these tags the necessary
|
||||
permissions.
|
||||
See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes.
|
||||
If you specify custom tags here, you must also make the operator an owner of these tags.
|
||||
See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
|
||||
Tags cannot be changed once a Connector node has been created.
|
||||
Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -81,53 +128,90 @@ spec:
|
||||
- rule: has(self.subnetRouter) || self.exitNode == true
|
||||
message: A Connector needs to be either an exit node or a subnet router, or both.
|
||||
status:
|
||||
description: ConnectorStatus describes the status of the Connector. This is set and managed by the Tailscale operator.
|
||||
description: |-
|
||||
ConnectorStatus describes the status of the Connector. This is set
|
||||
and managed by the Tailscale operator.
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
description: List of status conditions to indicate the status of the Connector. Known condition types are `ConnectorReady`.
|
||||
description: |-
|
||||
List of status conditions to indicate the status of the Connector.
|
||||
Known condition types are `ConnectorReady`.
|
||||
type: array
|
||||
items:
|
||||
description: ConnectorCondition contains condition information for a Connector.
|
||||
description: Condition contains details for one aspect of the current state of this API Resource.
|
||||
type: object
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
type: string
|
||||
format: date-time
|
||||
message:
|
||||
description: Message is a human readable description of the details of the last transition, complementing reason.
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
type: string
|
||||
maxLength: 32768
|
||||
observedGeneration:
|
||||
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
reason:
|
||||
description: Reason is a brief machine readable explanation for the condition's last transition.
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
type: string
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
status:
|
||||
description: Status of the condition, one of ('True', 'False', 'Unknown').
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
type: string
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type:
|
||||
description: Type of the condition, known values are (`SubnetRouterReady`).
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
type: string
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
hostname:
|
||||
description: Hostname is the fully qualified domain name of the Connector node. If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the node.
|
||||
description: |-
|
||||
Hostname is the fully qualified domain name of the Connector node.
|
||||
If MagicDNS is enabled in your tailnet, it is the MagicDNS name of the
|
||||
node.
|
||||
type: string
|
||||
isExitNode:
|
||||
description: IsExitNode is set to true if the Connector acts as an exit node.
|
||||
type: boolean
|
||||
subnetRoutes:
|
||||
description: SubnetRoutes are the routes currently exposed to tailnet via this Connector instance.
|
||||
description: |-
|
||||
SubnetRoutes are the routes currently exposed to tailnet via this
|
||||
Connector instance.
|
||||
type: string
|
||||
tailnetIPs:
|
||||
description: TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6) assigned to the Connector node.
|
||||
description: |-
|
||||
TailnetIPs is the set of tailnet IP addresses (both IPv4 and IPv6)
|
||||
assigned to the Connector node.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
controller-gen.kubebuilder.io/version: v0.15.1-0.20240618033008-7824932b0cab
|
||||
name: dnsconfigs.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
@@ -23,27 +23,69 @@ spec:
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: 'DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS names resolvable by cluster workloads. Use this if: A) you need to refer to tailnet services, exposed to cluster via Tailscale Kubernetes operator egress proxies by the MagicDNS names of those tailnet services (usually because the services run over HTTPS) B) you have exposed a cluster workload to the tailnet using Tailscale Ingress and you also want to refer to the workload from within the cluster over the Ingress''s MagicDNS name (usually because you have some callback component that needs to use the same URL as that used by a non-cluster client on tailnet). When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will deploy a nameserver for ts.net DNS names and automatically populate it with records for any Tailscale egress or Ingress proxies deployed to that cluster. Currently you must manually update your cluster DNS configuration to add the IP address of the deployed nameserver as a ts.net stub nameserver. Instructions for how to do it: https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS), https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns). Tailscale Kubernetes operator will write the address of a Service fronting the nameserver to dsnconfig.status.nameserver.ip. DNSConfig is a singleton - you must not create more than one. NB: if you want cluster workloads to be able to refer to Tailscale Ingress using its MagicDNS name, you must also annotate the Ingress resource with tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to ensure that the proxy created for the Ingress listens on its Pod IP address. NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported.'
|
||||
description: |-
|
||||
DNSConfig can be deployed to cluster to make a subset of Tailscale MagicDNS
|
||||
names resolvable by cluster workloads. Use this if: A) you need to refer to
|
||||
tailnet services, exposed to cluster via Tailscale Kubernetes operator egress
|
||||
proxies by the MagicDNS names of those tailnet services (usually because the
|
||||
services run over HTTPS)
|
||||
B) you have exposed a cluster workload to the tailnet using Tailscale Ingress
|
||||
and you also want to refer to the workload from within the cluster over the
|
||||
Ingress's MagicDNS name (usually because you have some callback component
|
||||
that needs to use the same URL as that used by a non-cluster client on
|
||||
tailnet).
|
||||
When a DNSConfig is applied to a cluster, Tailscale Kubernetes operator will
|
||||
deploy a nameserver for ts.net DNS names and automatically populate it with records
|
||||
for any Tailscale egress or Ingress proxies deployed to that cluster.
|
||||
Currently you must manually update your cluster DNS configuration to add the
|
||||
IP address of the deployed nameserver as a ts.net stub nameserver.
|
||||
Instructions for how to do it:
|
||||
https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/#configuration-of-stub-domain-and-upstream-nameserver-using-coredns (for CoreDNS),
|
||||
https://cloud.google.com/kubernetes-engine/docs/how-to/kube-dns (for kube-dns).
|
||||
Tailscale Kubernetes operator will write the address of a Service fronting
|
||||
the nameserver to dsnconfig.status.nameserver.ip.
|
||||
DNSConfig is a singleton - you must not create more than one.
|
||||
NB: if you want cluster workloads to be able to refer to Tailscale Ingress
|
||||
using its MagicDNS name, you must also annotate the Ingress resource with
|
||||
tailscale.com/experimental-forward-cluster-traffic-via-ingress annotation to
|
||||
ensure that the proxy created for the Ingress listens on its Pod IP address.
|
||||
NB: Clusters where Pods get assigned IPv6 addresses only are currently not supported.
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
properties:
|
||||
apiVersion:
|
||||
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: 'Spec describes the desired DNS configuration. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status'
|
||||
description: |-
|
||||
Spec describes the desired DNS configuration.
|
||||
More info:
|
||||
https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
|
||||
type: object
|
||||
required:
|
||||
- nameserver
|
||||
properties:
|
||||
nameserver:
|
||||
description: Configuration for a nameserver that can resolve ts.net DNS names associated with in-cluster proxies for Tailscale egress Services and Tailscale Ingresses. The operator will always deploy this nameserver when a DNSConfig is applied.
|
||||
description: |-
|
||||
Configuration for a nameserver that can resolve ts.net DNS names
|
||||
associated with in-cluster proxies for Tailscale egress Services and
|
||||
Tailscale Ingresses. The operator will always deploy this nameserver
|
||||
when a DNSConfig is applied.
|
||||
type: object
|
||||
properties:
|
||||
image:
|
||||
@@ -57,38 +99,66 @@ spec:
|
||||
description: Tag defaults to operator's own tag.
|
||||
type: string
|
||||
status:
|
||||
description: Status describes the status of the DNSConfig. This is set and managed by the Tailscale operator.
|
||||
description: |-
|
||||
Status describes the status of the DNSConfig. This is set
|
||||
and managed by the Tailscale operator.
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
type: array
|
||||
items:
|
||||
description: ConnectorCondition contains condition information for a Connector.
|
||||
description: Condition contains details for one aspect of the current state of this API Resource.
|
||||
type: object
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
type: string
|
||||
format: date-time
|
||||
message:
|
||||
description: Message is a human readable description of the details of the last transition, complementing reason.
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
type: string
|
||||
maxLength: 32768
|
||||
observedGeneration:
|
||||
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
reason:
|
||||
description: Reason is a brief machine readable explanation for the condition's last transition.
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
type: string
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
status:
|
||||
description: Status of the condition, one of ('True', 'False', 'Unknown').
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
type: string
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type:
|
||||
description: Type of the condition, known values are (`SubnetRouterReady`).
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
type: string
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
@@ -97,7 +167,13 @@ spec:
|
||||
type: object
|
||||
properties:
|
||||
ip:
|
||||
description: IP is the ClusterIP of the Service fronting the deployed ts.net nameserver. Currently you must manually update your cluster DNS config to add this address as a stub nameserver for ts.net for cluster workloads to be able to resolve MagicDNS names associated with egress or Ingress proxies. The IP address will change if you delete and recreate the DNSConfig.
|
||||
description: |-
|
||||
IP is the ClusterIP of the Service fronting the deployed ts.net nameserver.
|
||||
Currently you must manually update your cluster DNS config to add
|
||||
this address as a stub nameserver for ts.net for cluster workloads to be
|
||||
able to resolve MagicDNS names associated with egress or Ingress
|
||||
proxies.
|
||||
The IP address will change if you delete and recreate the DNSConfig.
|
||||
type: string
|
||||
served: true
|
||||
storage: true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,6 @@
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// tailscale-operator provides a way to expose services running in a Kubernetes
|
||||
// cluster to your Tailnet and to make Tailscale nodes available to cluster
|
||||
// workloads
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -27,6 +24,7 @@ import (
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -170,36 +168,49 @@ func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, headlessS
|
||||
}
|
||||
}
|
||||
|
||||
// Get the Pod IP addresses for the proxy from the EndpointSlice for the
|
||||
// headless Service.
|
||||
// Get the Pod IP addresses for the proxy from the EndpointSlices for
|
||||
// the headless Service. The Service can have multiple EndpointSlices
|
||||
// associated with it, for example in dual-stack clusters.
|
||||
labels := map[string]string{discoveryv1.LabelServiceName: headlessSvc.Name} // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership
|
||||
eps, err := getSingleObject[discoveryv1.EndpointSlice](ctx, dnsRR.Client, dnsRR.tsNamespace, labels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting the EndpointSlice for the proxy's headless Service: %w", err)
|
||||
var eps = new(discoveryv1.EndpointSliceList)
|
||||
if err := dnsRR.List(ctx, eps, client.InNamespace(dnsRR.tsNamespace), client.MatchingLabels(labels)); err != nil {
|
||||
return fmt.Errorf("error listing EndpointSlices for the proxy's headless Service: %w", err)
|
||||
}
|
||||
if eps == nil {
|
||||
if len(eps.Items) == 0 {
|
||||
logger.Debugf("proxy's headless Service EndpointSlice does not yet exist. We will reconcile again once it's created")
|
||||
return nil
|
||||
}
|
||||
// An EndpointSlice for a Service can have a list of endpoints that each
|
||||
// Each EndpointSlice for a Service can have a list of endpoints that each
|
||||
// can have multiple addresses - these are the IP addresses of any Pods
|
||||
// selected by that Service. Pick all the IPv4 addresses.
|
||||
ips := make([]string, 0)
|
||||
for _, ep := range eps.Endpoints {
|
||||
for _, ip := range ep.Addresses {
|
||||
if !net.IsIPv4String(ip) {
|
||||
logger.Infof("EndpointSlice contains IP address %q that is not IPv4, ignoring. Currently only IPv4 is supported", ip)
|
||||
} else {
|
||||
ips = append(ips, ip)
|
||||
// It is also possible that multiple EndpointSlices have overlapping addresses.
|
||||
// https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#duplicate-endpoints
|
||||
ips := make(set.Set[string], 0)
|
||||
for _, slice := range eps.Items {
|
||||
if slice.AddressType != discoveryv1.AddressTypeIPv4 {
|
||||
logger.Infof("EndpointSlice is for AddressType %s, currently only IPv4 address type is supported", slice.AddressType)
|
||||
continue
|
||||
}
|
||||
for _, ep := range slice.Endpoints {
|
||||
if !epIsReady(&ep) {
|
||||
logger.Debugf("Endpoint with addresses %v appears not ready to receive traffic %v", ep.Addresses, ep.Conditions.String())
|
||||
continue
|
||||
}
|
||||
for _, ip := range ep.Addresses {
|
||||
if !net.IsIPv4String(ip) {
|
||||
logger.Infof("EndpointSlice contains IP address %q that is not IPv4, ignoring. Currently only IPv4 is supported", ip)
|
||||
} else {
|
||||
ips.Add(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
if ips.Len() == 0 {
|
||||
logger.Debugf("EndpointSlice for the Service contains no IPv4 addresses. We will reconcile again once they are created.")
|
||||
return nil
|
||||
}
|
||||
updateFunc := func(rec *operatorutils.Records) {
|
||||
mak.Set(&rec.IP4, fqdn, ips)
|
||||
mak.Set(&rec.IP4, fqdn, ips.Slice())
|
||||
}
|
||||
if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
|
||||
return fmt.Errorf("error updating DNS records: %w", err)
|
||||
@@ -207,6 +218,17 @@ func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, headlessS
|
||||
return nil
|
||||
}
|
||||
|
||||
// epIsReady reports whether the endpoint is currently in a state to receive new
|
||||
// traffic. As per kube docs, only explicitly set 'false' for 'Ready' or
|
||||
// 'Serving' conditions or explicitly set 'true' for 'Terminating' condition
|
||||
// means that the Endpoint is NOT ready.
|
||||
// https://github.com/kubernetes/kubernetes/blob/60c4c2b2521fb454ce69dee737e3eb91a25e0535/pkg/apis/discovery/types.go#L109-L131
|
||||
func epIsReady(ep *discoveryv1.Endpoint) bool {
|
||||
return (ep.Conditions.Ready == nil || *ep.Conditions.Ready) &&
|
||||
(ep.Conditions.Serving == nil || *ep.Conditions.Serving) &&
|
||||
(ep.Conditions.Terminating == nil || !*ep.Conditions.Terminating)
|
||||
}
|
||||
|
||||
// maybeCleanup ensures that the DNS record for the proxy has been removed from
|
||||
// dnsrecords ConfigMap and the tailscale.com/dns-records-reconciler finalizer
|
||||
// has been removed from the Service. If the record is not found in the
|
||||
|
||||
@@ -8,6 +8,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -87,13 +88,16 @@ func TestDNSRecordsReconciler(t *testing.T) {
|
||||
},
|
||||
}
|
||||
headlessForEgressSvcFQDN := headlessSvcForParent(egressSvcFQDN, "svc") // create the proxy headless Service
|
||||
ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7")
|
||||
ep := endpointSliceForService(headlessForEgressSvcFQDN, "10.9.8.7", discoveryv1.AddressTypeIPv4)
|
||||
epv6 := endpointSliceForService(headlessForEgressSvcFQDN, "2600:1900:4011:161:0:d:0:d", discoveryv1.AddressTypeIPv6)
|
||||
|
||||
mustCreate(t, fc, egressSvcFQDN)
|
||||
mustCreate(t, fc, headlessForEgressSvcFQDN)
|
||||
mustCreate(t, fc, ep)
|
||||
mustCreate(t, fc, epv6)
|
||||
expectReconciled(t, dnsRR, "tailscale", "egress-fqdn") // dns-records-reconciler reconcile the headless Service
|
||||
// ConfigMap should now have a record for foo.bar.ts.net -> 10.8.8.7
|
||||
wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}}
|
||||
wantHosts := map[string][]string{"foo.bar.ts.net": {"10.9.8.7"}} // IPv6 endpoint is currently ignored
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
|
||||
// 2. DNS record is updated if tailscale.com/tailnet-fqdn annotation's
|
||||
@@ -106,7 +110,7 @@ func TestDNSRecordsReconciler(t *testing.T) {
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
|
||||
// 3. DNS record is updated if the IP address of the proxy Pod changes.
|
||||
ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4")
|
||||
ep = endpointSliceForService(headlessForEgressSvcFQDN, "10.6.5.4", discoveryv1.AddressTypeIPv4)
|
||||
mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
|
||||
ep.Endpoints[0].Addresses = []string{"10.6.5.4"}
|
||||
})
|
||||
@@ -116,7 +120,7 @@ func TestDNSRecordsReconciler(t *testing.T) {
|
||||
|
||||
// 4. DNS record is created for an ingress proxy configured via Ingress
|
||||
headlessForIngress := headlessSvcForParent(ing, "ingress")
|
||||
ep = endpointSliceForService(headlessForIngress, "10.9.8.7")
|
||||
ep = endpointSliceForService(headlessForIngress, "10.9.8.7", discoveryv1.AddressTypeIPv4)
|
||||
mustCreate(t, fc, headlessForIngress)
|
||||
mustCreate(t, fc, ep)
|
||||
expectReconciled(t, dnsRR, "tailscale", "ts-ingress") // dns-records-reconciler should reconcile the headless Service
|
||||
@@ -140,6 +144,17 @@ func TestDNSRecordsReconciler(t *testing.T) {
|
||||
expectReconciled(t, dnsRR, "tailscale", "ts-ingress")
|
||||
wantHosts["another.ingress.ts.net"] = []string{"7.8.9.10"}
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
|
||||
// 7. A not-ready Endpoint is removed from DNS config.
|
||||
mustUpdate(t, fc, ep.Namespace, ep.Name, func(ep *discoveryv1.EndpointSlice) {
|
||||
ep.Endpoints[0].Conditions.Ready = ptr.To(false)
|
||||
ep.Endpoints = append(ep.Endpoints, discoveryv1.Endpoint{
|
||||
Addresses: []string{"1.2.3.4"},
|
||||
})
|
||||
})
|
||||
expectReconciled(t, dnsRR, "tailscale", "ts-ingress")
|
||||
wantHosts["another.ingress.ts.net"] = []string{"1.2.3.4"}
|
||||
expectHostsRecords(t, fc, wantHosts)
|
||||
}
|
||||
|
||||
func headlessSvcForParent(o client.Object, typ string) *corev1.Service {
|
||||
@@ -162,15 +177,21 @@ func headlessSvcForParent(o client.Object, typ string) *corev1.Service {
|
||||
}
|
||||
}
|
||||
|
||||
func endpointSliceForService(svc *corev1.Service, ip string) *discoveryv1.EndpointSlice {
|
||||
func endpointSliceForService(svc *corev1.Service, ip string, fam discoveryv1.AddressType) *discoveryv1.EndpointSlice {
|
||||
return &discoveryv1.EndpointSlice{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: svc.Name,
|
||||
Name: fmt.Sprintf("%s-%s", svc.Name, string(fam)),
|
||||
Namespace: svc.Namespace,
|
||||
Labels: map[string]string{discoveryv1.LabelServiceName: svc.Name},
|
||||
},
|
||||
AddressType: fam,
|
||||
Endpoints: []discoveryv1.Endpoint{{
|
||||
Addresses: []string{ip},
|
||||
Conditions: discoveryv1.EndpointConditions{
|
||||
Ready: ptr.To(true),
|
||||
Serving: ptr.To(true),
|
||||
Terminating: ptr.To(false),
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// The generate command creates tailscale.com CRDs.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -248,9 +248,9 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
||||
// created proxy resources.
|
||||
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
|
||||
pc.Status = tsapi.ProxyClassStatus{
|
||||
Conditions: []tsapi.ConnectorCondition{{
|
||||
Conditions: []metav1.Condition{{
|
||||
Status: metav1.ConditionTrue,
|
||||
Type: tsapi.ProxyClassready,
|
||||
Type: string(tsapi.ProxyClassready),
|
||||
ObservedGeneration: pc.Generation,
|
||||
}}}
|
||||
})
|
||||
|
||||
@@ -101,7 +101,7 @@ func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Requ
|
||||
}
|
||||
|
||||
oldCnStatus := dnsCfg.Status.DeepCopy()
|
||||
setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
tsoperator.SetDNSConfigCondition(dnsCfg, tsapi.NameserverReady, status, reason, message, dnsCfg.Generation, a.clock, logger)
|
||||
if !apiequality.Semantic.DeepEqual(oldCnStatus, dnsCfg.Status) {
|
||||
// An error encountered here should get returned by the Reconcile function.
|
||||
|
||||
@@ -81,12 +81,12 @@ func TestNameserverReconciler(t *testing.T) {
|
||||
IP: "1.2.3.4",
|
||||
}
|
||||
dnsCfg.Finalizers = []string{FinalizerName}
|
||||
dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, tsapi.ConnectorCondition{
|
||||
Type: tsapi.NameserverReady,
|
||||
dnsCfg.Status.Conditions = append(dnsCfg.Status.Conditions, metav1.Condition{
|
||||
Type: string(tsapi.NameserverReady),
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: reasonNameserverCreated,
|
||||
Message: reasonNameserverCreated,
|
||||
LastTransitionTime: &metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
})
|
||||
expectEqual(t, fc, dnsCfg, nil)
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ import (
|
||||
// Generate static manifests for deploying Tailscale operator on Kubernetes from the operator's Helm chart.
|
||||
//go:generate go run tailscale.com/cmd/k8s-operator/generate staticmanifests
|
||||
|
||||
// Generate CRD docs from the yamls
|
||||
//go:generate go run fybrik.io/crdoc --resources=./deploy/crds --output=../../k8s-operator/api.md
|
||||
// Generate CRD API docs.
|
||||
//go:generate go run github.com/elastic/crd-ref-docs --renderer=markdown --source-path=../../k8s-operator/apis/ --config=../../k8s-operator/api-docs-config.yaml --output-path=../../k8s-operator/api.md
|
||||
|
||||
func main() {
|
||||
// Required to use our client API. We're fine with the instability since the
|
||||
@@ -278,6 +278,7 @@ func runReconcilers(opts reconcilerOpts) {
|
||||
isDefaultLoadBalancer: opts.proxyActAsDefaultLoadBalancer,
|
||||
recorder: eventRecorder,
|
||||
tsNamespace: opts.tailscaleNamespace,
|
||||
clock: tstime.DefaultClock{},
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create service reconciler: %v", err)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.uber.org/zap"
|
||||
@@ -17,10 +18,13 @@ import (
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/net/dns/resolvconffile"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/mak"
|
||||
@@ -33,6 +37,7 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -42,11 +47,13 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
operatorNamespace: "operator-ns",
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
recorder: record.NewFakeRecorder(100),
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
// of objects looks right.
|
||||
// Create a service that we should manage, but start with a miconfiguration
|
||||
// in the annotations.
|
||||
mustCreate(t, fc, &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
@@ -55,6 +62,9 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
// doesn't. So, set it explicitly because other code later depends
|
||||
// on it being set.
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetFQDN: "invalid.example.com",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
@@ -65,6 +75,46 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
// The expected value of .status.conditions[0].LastTransitionTime until the
|
||||
// proxy becomes ready.
|
||||
t0 := conditionTime(clock)
|
||||
|
||||
// Should have an error about invalid config.
|
||||
want := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
UID: types.UID("1234-UID"),
|
||||
Annotations: map[string]string{
|
||||
AnnotationTailnetTargetFQDN: "invalid.example.com",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: []metav1.Condition{{
|
||||
Type: string(tsapi.ProxyReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
LastTransitionTime: t0,
|
||||
Reason: reasonProxyInvalid,
|
||||
Message: `unable to provision proxy resources: invalid Service: invalid value of annotation tailscale.com/tailnet-fqdn: "invalid.example.com" does not appear to be a valid MagicDNS name`,
|
||||
}},
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
// Delete the misconfiguration so the proxy starts getting created on the
|
||||
// next reconcile.
|
||||
mustUpdate(t, fc, "default", "test", func(s *corev1.Service) {
|
||||
s.ObjectMeta.Annotations = nil
|
||||
})
|
||||
|
||||
clock.Advance(time.Second)
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
|
||||
fullName, shortName := findGenName(t, fc, "default", "test", "svc")
|
||||
opts := configOpts{
|
||||
stsName: shortName,
|
||||
@@ -79,6 +129,19 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, opts), removeHashAnnotation)
|
||||
|
||||
want.Annotations = nil
|
||||
want.ObjectMeta.Finalizers = []string{"tailscale.com/finalizer"}
|
||||
want.Status = corev1.ServiceStatus{
|
||||
Conditions: []metav1.Condition{{
|
||||
Type: string(tsapi.ProxyReady),
|
||||
Status: metav1.ConditionFalse,
|
||||
LastTransitionTime: t0, // Status is still false, no update to transition time
|
||||
Reason: reasonProxyPending,
|
||||
Message: "no Tailscale hostname known yet, waiting for proxy pod to finish auth",
|
||||
}},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
// Normally the Tailscale proxy pod would come up here and write its info
|
||||
// into the secret. Simulate that, then verify reconcile again and verify
|
||||
// that we get to the end.
|
||||
@@ -90,33 +153,16 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
s.Data["device_fqdn"] = []byte("tailscale.device.name.")
|
||||
s.Data["device_ips"] = []byte(`["100.99.98.97", "2c0a:8083:94d4:2012:3165:34a5:3616:5fdf"]`)
|
||||
})
|
||||
clock.Advance(time.Second)
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Finalizers: []string{"tailscale.com/finalizer"},
|
||||
UID: types.UID("1234-UID"),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeLoadBalancer,
|
||||
LoadBalancerClass: ptr.To("tailscale"),
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
LoadBalancer: corev1.LoadBalancerStatus{
|
||||
Ingress: []corev1.LoadBalancerIngress{
|
||||
{
|
||||
Hostname: "tailscale.device.name",
|
||||
},
|
||||
{
|
||||
IP: "100.99.98.97",
|
||||
},
|
||||
},
|
||||
want.Status.Conditions = proxyCreatedCondition(clock)
|
||||
want.Status.LoadBalancer = corev1.LoadBalancerStatus{
|
||||
Ingress: []corev1.LoadBalancerIngress{
|
||||
{
|
||||
Hostname: "tailscale.device.name",
|
||||
},
|
||||
{
|
||||
IP: "100.99.98.97",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -144,11 +190,9 @@ func TestLoadBalancerClass(t *testing.T) {
|
||||
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
|
||||
// Note that the Tailscale-specific condition status should be gone now.
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -170,6 +214,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tailnetTargetFQDN := "foo.bar.ts.net."
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -180,6 +225,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -220,10 +266,6 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -238,6 +280,9 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
||||
Type: corev1.ServiceTypeExternalName,
|
||||
Selector: nil,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
|
||||
@@ -280,6 +325,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tailnetTargetIP := "100.66.66.66"
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -290,6 +336,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -330,10 +377,6 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -348,6 +391,9 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
||||
Type: corev1.ServiceTypeExternalName,
|
||||
Selector: nil,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
expectEqual(t, fc, expectedSecret(t, fc, o), nil)
|
||||
@@ -389,6 +435,7 @@ func TestAnnotations(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -399,6 +446,7 @@ func TestAnnotations(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -437,10 +485,6 @@ func TestAnnotations(t *testing.T) {
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -454,6 +498,9 @@ func TestAnnotations(t *testing.T) {
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
@@ -473,10 +520,6 @@ func TestAnnotations(t *testing.T) {
|
||||
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -497,6 +540,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -507,6 +551,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -558,10 +603,6 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -575,6 +616,9 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
@@ -592,10 +636,6 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
// ... but the service should have a LoadBalancer status.
|
||||
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -618,6 +658,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
@@ -630,6 +671,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -640,6 +682,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -689,10 +732,6 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
})
|
||||
expectReconciled(t, sr, "default", "test")
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -715,6 +754,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
@@ -740,10 +780,6 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -757,6 +793,9 @@ func TestLBIntoAnnotation(t *testing.T) {
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
}
|
||||
@@ -768,6 +807,7 @@ func TestCustomHostname(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -778,6 +818,7 @@ func TestCustomHostname(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -817,10 +858,6 @@ func TestCustomHostname(t *testing.T) {
|
||||
expectEqual(t, fc, expectedHeadlessService(shortName, "svc"), nil)
|
||||
expectEqual(t, fc, expectedSTS(t, fc, o), removeHashAnnotation)
|
||||
want := &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -835,6 +872,9 @@ func TestCustomHostname(t *testing.T) {
|
||||
ClusterIP: "10.20.30.40",
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
Status: corev1.ServiceStatus{
|
||||
Conditions: proxyCreatedCondition(clock),
|
||||
},
|
||||
}
|
||||
expectEqual(t, fc, want, nil)
|
||||
|
||||
@@ -854,10 +894,6 @@ func TestCustomHostname(t *testing.T) {
|
||||
expectMissing[corev1.Service](t, fc, "operator-ns", shortName)
|
||||
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
|
||||
want = &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
@@ -881,6 +917,7 @@ func TestCustomPriorityClassName(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -892,6 +929,7 @@ func TestCustomPriorityClassName(t *testing.T) {
|
||||
proxyPriorityClassName: "custom-priority-class-name",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// Create a service that we should manage, and check that the initial round
|
||||
@@ -954,6 +992,7 @@ func TestProxyClassForService(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -964,6 +1003,7 @@ func TestProxyClassForService(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// 1. A new tailscale LoadBalancer Service is created without any
|
||||
@@ -1012,9 +1052,9 @@ func TestProxyClassForService(t *testing.T) {
|
||||
// applied to the proxy resources.
|
||||
mustUpdateStatus(t, fc, "", "custom-metadata", func(pc *tsapi.ProxyClass) {
|
||||
pc.Status = tsapi.ProxyClassStatus{
|
||||
Conditions: []tsapi.ConnectorCondition{{
|
||||
Conditions: []metav1.Condition{{
|
||||
Status: metav1.ConditionTrue,
|
||||
Type: tsapi.ProxyClassready,
|
||||
Type: string(tsapi.ProxyClassready),
|
||||
ObservedGeneration: pc.Generation,
|
||||
}}}
|
||||
})
|
||||
@@ -1041,6 +1081,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -1051,6 +1092,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
isDefaultLoadBalancer: true,
|
||||
}
|
||||
|
||||
@@ -1095,6 +1137,7 @@ func TestProxyFirewallMode(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -1106,6 +1149,7 @@ func TestProxyFirewallMode(t *testing.T) {
|
||||
tsFirewallMode: "nftables",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
isDefaultLoadBalancer: true,
|
||||
}
|
||||
|
||||
@@ -1148,6 +1192,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -1158,6 +1203,7 @@ func TestTailscaledConfigfileHash(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
isDefaultLoadBalancer: true,
|
||||
}
|
||||
|
||||
@@ -1439,6 +1485,7 @@ func Test_externalNameService(t *testing.T) {
|
||||
|
||||
// 1. A External name Service that should be exposed via Tailscale gets
|
||||
// created.
|
||||
clock := tstest.NewClock(tstest.ClockOpts{})
|
||||
sr := &ServiceReconciler{
|
||||
Client: fc,
|
||||
ssr: &tailscaleSTSReconciler{
|
||||
@@ -1449,6 +1496,7 @@ func Test_externalNameService(t *testing.T) {
|
||||
proxyImage: "tailscale/tailscale",
|
||||
},
|
||||
logger: zl.Sugar(),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
// 1. Create an ExternalName Service that we should manage, and check that the initial round
|
||||
@@ -1504,3 +1552,18 @@ func toFQDN(t *testing.T, s string) dnsname.FQDN {
|
||||
}
|
||||
return fqdn
|
||||
}
|
||||
|
||||
func proxyCreatedCondition(clock tstime.Clock) []metav1.Condition {
|
||||
return []metav1.Condition{{
|
||||
Type: string(tsapi.ProxyReady),
|
||||
Status: metav1.ConditionTrue,
|
||||
ObservedGeneration: 0,
|
||||
LastTransitionTime: conditionTime(clock),
|
||||
Reason: reasonProxyCreated,
|
||||
Message: reasonProxyCreated,
|
||||
}}
|
||||
}
|
||||
|
||||
func conditionTime(clock tstime.Clock) metav1.Time {
|
||||
return metav1.NewTime(clock.Now().Truncate(time.Second))
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/transport"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
ksr "tailscale.com/k8s-operator/sessionrecording"
|
||||
tskube "tailscale.com/kube"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
@@ -30,10 +33,26 @@ import (
|
||||
|
||||
var whoIsKey = ctxkey.New("", (*apitype.WhoIsResponse)(nil))
|
||||
|
||||
var counterNumRequestsProxied = clientmetric.NewCounter("k8s_auth_proxy_requests_proxied")
|
||||
var (
|
||||
// counterNumRequestsproxies counts the number of API server requests proxied via this proxy.
|
||||
counterNumRequestsProxied = clientmetric.NewCounter("k8s_auth_proxy_requests_proxied")
|
||||
)
|
||||
|
||||
type apiServerProxyMode int
|
||||
|
||||
func (a apiServerProxyMode) String() string {
|
||||
switch a {
|
||||
case apiserverProxyModeDisabled:
|
||||
return "disabled"
|
||||
case apiserverProxyModeEnabled:
|
||||
return "auth"
|
||||
case apiserverProxyModeNoAuth:
|
||||
return "noauth"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
apiserverProxyModeDisabled apiServerProxyMode = iota
|
||||
apiserverProxyModeEnabled
|
||||
@@ -97,26 +116,7 @@ func maybeLaunchAPIServerProxy(zlog *zap.SugaredLogger, restConfig *rest.Config,
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not get rest.TransportConfig(): %v", err)
|
||||
}
|
||||
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy"), mode)
|
||||
}
|
||||
|
||||
// apiserverProxy is an http.Handler that authenticates requests using the Tailscale
|
||||
// LocalAPI and then proxies them to the Kubernetes API.
|
||||
type apiserverProxy struct {
|
||||
log *zap.SugaredLogger
|
||||
lc *tailscale.LocalClient
|
||||
rp *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
who, err := h.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
if err != nil {
|
||||
h.log.Errorf("failed to authenticate caller: %v", err)
|
||||
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
counterNumRequestsProxied.Add(1)
|
||||
h.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
|
||||
go runAPIServerProxy(s, rt, zlog.Named("apiserver-proxy"), mode, restConfig.Host)
|
||||
}
|
||||
|
||||
// runAPIServerProxy runs an HTTP server that authenticates requests using the
|
||||
@@ -133,64 +133,43 @@ func (h *apiserverProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// are passed through to the Kubernetes API.
|
||||
//
|
||||
// It never returns.
|
||||
func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLogger, mode apiServerProxyMode) {
|
||||
func runAPIServerProxy(ts *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLogger, mode apiServerProxyMode, host string) {
|
||||
if mode == apiserverProxyModeDisabled {
|
||||
return
|
||||
}
|
||||
ln, err := s.Listen("tcp", ":443")
|
||||
ln, err := ts.Listen("tcp", ":443")
|
||||
if err != nil {
|
||||
log.Fatalf("could not listen on :443: %v", err)
|
||||
}
|
||||
u, err := url.Parse(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
log.Fatalf("runAPIServerProxy: failed to parse URL %v", err)
|
||||
}
|
||||
|
||||
lc, err := s.LocalClient()
|
||||
lc, err := ts.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatalf("could not get local client: %v", err)
|
||||
}
|
||||
|
||||
ap := &apiserverProxy{
|
||||
log: log,
|
||||
lc: lc,
|
||||
rp: &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
// Replace the URL with the Kubernetes APIServer.
|
||||
|
||||
r.Out.URL.Scheme = u.Scheme
|
||||
r.Out.URL.Host = u.Host
|
||||
if mode == apiserverProxyModeNoAuth {
|
||||
// If we are not providing authentication, then we are just
|
||||
// proxying to the Kubernetes API, so we don't need to do
|
||||
// anything else.
|
||||
return
|
||||
}
|
||||
|
||||
// We want to proxy to the Kubernetes API, but we want to use
|
||||
// the caller's identity to do so. We do this by impersonating
|
||||
// the caller using the Kubernetes User Impersonation feature:
|
||||
// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation
|
||||
|
||||
// Out of paranoia, remove all authentication headers that might
|
||||
// have been set by the client.
|
||||
r.Out.Header.Del("Authorization")
|
||||
r.Out.Header.Del("Impersonate-Group")
|
||||
r.Out.Header.Del("Impersonate-User")
|
||||
r.Out.Header.Del("Impersonate-Uid")
|
||||
for k := range r.Out.Header {
|
||||
if strings.HasPrefix(k, "Impersonate-Extra-") {
|
||||
r.Out.Header.Del(k)
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the impersonation headers that we want.
|
||||
if err := addImpersonationHeaders(r.Out, log); err != nil {
|
||||
panic("failed to add impersonation headers: " + err.Error())
|
||||
}
|
||||
},
|
||||
Transport: rt,
|
||||
},
|
||||
log: log,
|
||||
lc: lc,
|
||||
mode: mode,
|
||||
upstreamURL: u,
|
||||
ts: ts,
|
||||
}
|
||||
ap.rp = &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
ap.addImpersonationHeadersAsRequired(pr.Out)
|
||||
},
|
||||
Transport: rt,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", ap.serveDefault)
|
||||
mux.HandleFunc("POST /api/v1/namespaces/{namespace}/pods/{pod}/exec", ap.serveExecSPDY)
|
||||
mux.HandleFunc("GET /api/v1/namespaces/{namespace}/pods/{pod}/exec", ap.serveExecWS)
|
||||
|
||||
hs := &http.Server{
|
||||
// Kubernetes uses SPDY for exec and port-forward, however SPDY is
|
||||
// incompatible with HTTP/2; so disable HTTP/2 in the proxy.
|
||||
@@ -199,14 +178,145 @@ func runAPIServerProxy(s *tsnet.Server, rt http.RoundTripper, log *zap.SugaredLo
|
||||
NextProtos: []string{"http/1.1"},
|
||||
},
|
||||
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
|
||||
Handler: ap,
|
||||
Handler: mux,
|
||||
}
|
||||
log.Infof("listening on %s", ln.Addr())
|
||||
log.Infof("API server proxy in %q mode is listening on %s", mode, ln.Addr())
|
||||
if err := hs.ServeTLS(ln, "", ""); err != nil {
|
||||
log.Fatalf("runAPIServerProxy: failed to serve %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// apiserverProxy is an [net/http.Handler] that authenticates requests using the Tailscale
|
||||
// LocalAPI and then proxies them to the Kubernetes API.
|
||||
type apiserverProxy struct {
|
||||
log *zap.SugaredLogger
|
||||
lc *tailscale.LocalClient
|
||||
rp *httputil.ReverseProxy
|
||||
|
||||
mode apiServerProxyMode
|
||||
ts *tsnet.Server
|
||||
upstreamURL *url.URL
|
||||
}
|
||||
|
||||
// serveDefault is the default handler for Kubernetes API server requests.
|
||||
func (ap *apiserverProxy) serveDefault(w http.ResponseWriter, r *http.Request) {
|
||||
who, err := ap.whoIs(r)
|
||||
if err != nil {
|
||||
ap.authError(w, err)
|
||||
return
|
||||
}
|
||||
counterNumRequestsProxied.Add(1)
|
||||
ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
|
||||
}
|
||||
|
||||
// serveExecSPDY serves 'kubectl exec' requests for sessions streamed over SPDY,
|
||||
// optionally configuring the kubectl exec sessions to be recorded.
|
||||
func (ap *apiserverProxy) serveExecSPDY(w http.ResponseWriter, r *http.Request) {
|
||||
ap.execForProto(w, r, ksr.SPDYProtocol)
|
||||
}
|
||||
|
||||
// serveExecWS serves 'kubectl exec' requests for sessions streamed over WebSocket,
|
||||
// optionally configuring the kubectl exec sessions to be recorded.
|
||||
func (ap *apiserverProxy) serveExecWS(w http.ResponseWriter, r *http.Request) {
|
||||
ap.execForProto(w, r, ksr.WSProtocol)
|
||||
}
|
||||
|
||||
func (ap *apiserverProxy) execForProto(w http.ResponseWriter, r *http.Request, proto ksr.Protocol) {
|
||||
who, err := ap.whoIs(r)
|
||||
if err != nil {
|
||||
ap.authError(w, err)
|
||||
return
|
||||
}
|
||||
counterNumRequestsProxied.Add(1)
|
||||
failOpen, addrs, err := determineRecorderConfig(who)
|
||||
if err != nil {
|
||||
ap.log.Errorf("error trying to determine whether the 'kubectl exec' session needs to be recorded: %v", err)
|
||||
return
|
||||
}
|
||||
if failOpen && len(addrs) == 0 { // will not record
|
||||
ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
|
||||
return
|
||||
}
|
||||
ksr.CounterSessionRecordingsAttempted.Add(1) // at this point we know that users intended for this session to be recorded
|
||||
if !failOpen && len(addrs) == 0 {
|
||||
msg := "forbidden: 'kubectl exec' session must be recorded, but no recorders are available."
|
||||
ap.log.Error(msg)
|
||||
http.Error(w, msg, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
wantsHeader := upgradeHeaderForProto[proto]
|
||||
if h := r.Header.Get("Upgrade"); h != wantsHeader {
|
||||
msg := fmt.Sprintf("[unexpected] unable to verify that streaming protocol is %s, wants Upgrade header %q, got: %q", proto, wantsHeader, h)
|
||||
if failOpen {
|
||||
msg = msg + "; failure mode is 'fail open'; continuing session without recording."
|
||||
ap.log.Warn(msg)
|
||||
ap.rp.ServeHTTP(w, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
|
||||
return
|
||||
}
|
||||
ap.log.Error(msg)
|
||||
msg += "; failure mode is 'fail closed'; closing connection."
|
||||
http.Error(w, msg, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
opts := ksr.HijackerOpts{
|
||||
Req: r,
|
||||
W: w,
|
||||
Proto: proto,
|
||||
TS: ap.ts,
|
||||
Who: who,
|
||||
Addrs: addrs,
|
||||
FailOpen: failOpen,
|
||||
Pod: r.PathValue("pod"),
|
||||
Namespace: r.PathValue("namespace"),
|
||||
Log: ap.log,
|
||||
}
|
||||
h := ksr.New(opts)
|
||||
|
||||
ap.rp.ServeHTTP(h, r.WithContext(whoIsKey.WithValue(r.Context(), who)))
|
||||
}
|
||||
|
||||
func (h *apiserverProxy) addImpersonationHeadersAsRequired(r *http.Request) {
|
||||
r.URL.Scheme = h.upstreamURL.Scheme
|
||||
r.URL.Host = h.upstreamURL.Host
|
||||
if h.mode == apiserverProxyModeNoAuth {
|
||||
// If we are not providing authentication, then we are just
|
||||
// proxying to the Kubernetes API, so we don't need to do
|
||||
// anything else.
|
||||
return
|
||||
}
|
||||
|
||||
// We want to proxy to the Kubernetes API, but we want to use
|
||||
// the caller's identity to do so. We do this by impersonating
|
||||
// the caller using the Kubernetes User Impersonation feature:
|
||||
// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation
|
||||
|
||||
// Out of paranoia, remove all authentication headers that might
|
||||
// have been set by the client.
|
||||
r.Header.Del("Authorization")
|
||||
r.Header.Del("Impersonate-Group")
|
||||
r.Header.Del("Impersonate-User")
|
||||
r.Header.Del("Impersonate-Uid")
|
||||
for k := range r.Header {
|
||||
if strings.HasPrefix(k, "Impersonate-Extra-") {
|
||||
r.Header.Del(k)
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the impersonation headers that we want.
|
||||
if err := addImpersonationHeaders(r, h.log); err != nil {
|
||||
log.Printf("failed to add impersonation headers: " + err.Error())
|
||||
}
|
||||
}
|
||||
func (ap *apiserverProxy) whoIs(r *http.Request) (*apitype.WhoIsResponse, error) {
|
||||
return ap.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
}
|
||||
func (ap *apiserverProxy) authError(w http.ResponseWriter, err error) {
|
||||
ap.log.Errorf("failed to authenticate caller: %v", err)
|
||||
http.Error(w, "failed to authenticate caller", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
const (
|
||||
// oldCapabilityName is a legacy form of
|
||||
// tailfcg.PeerCapabilityKubernetes capability. The only capability rule
|
||||
@@ -266,3 +376,39 @@ func addImpersonationHeaders(r *http.Request, log *zap.SugaredLogger) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// determineRecorderConfig determines recorder config from requester's peer
|
||||
// capabilities. Determines whether a 'kubectl exec' session from this requester
|
||||
// needs to be recorded and what recorders the recording should be sent to.
|
||||
func determineRecorderConfig(who *apitype.WhoIsResponse) (failOpen bool, recorderAddresses []netip.AddrPort, _ error) {
|
||||
if who == nil {
|
||||
return false, nil, errors.New("[unexpected] cannot determine caller")
|
||||
}
|
||||
failOpen = true
|
||||
rules, err := tailcfg.UnmarshalCapJSON[tskube.KubernetesCapRule](who.CapMap, tailcfg.PeerCapabilityKubernetes)
|
||||
if err != nil {
|
||||
return failOpen, nil, fmt.Errorf("failed to unmarshal Kubernetes capability: %w", err)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return failOpen, nil, nil
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if len(rule.RecorderAddrs) != 0 {
|
||||
// TODO (irbekrm): here or later determine if the
|
||||
// recorders behind those addrs are online - else we
|
||||
// spend 30s trying to reach a recorder whose tailscale
|
||||
// status is offline.
|
||||
recorderAddresses = append(recorderAddresses, rule.RecorderAddrs...)
|
||||
}
|
||||
if rule.EnforceRecorder {
|
||||
failOpen = false
|
||||
}
|
||||
}
|
||||
return failOpen, recorderAddresses, nil
|
||||
}
|
||||
|
||||
var upgradeHeaderForProto = map[ksr.Protocol]string{
|
||||
ksr.SPDYProtocol: "SPDY/3.1",
|
||||
ksr.WSProtocol: "websocket",
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@@ -126,3 +128,72 @@ func TestImpersonationHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Test_determineRecorderConfig(t *testing.T) {
|
||||
addr1, addr2 := netip.MustParseAddrPort("[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"), netip.MustParseAddrPort("100.99.99.99:80")
|
||||
tests := []struct {
|
||||
name string
|
||||
wantFailOpen bool
|
||||
wantRecorderAddresses []netip.AddrPort
|
||||
who *apitype.WhoIsResponse
|
||||
}{
|
||||
{
|
||||
name: "two_ips_fail_closed",
|
||||
who: whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80","100.99.99.99:80"],"enforceRecorder":true}`}}),
|
||||
wantRecorderAddresses: []netip.AddrPort{addr1, addr2},
|
||||
},
|
||||
{
|
||||
name: "two_ips_fail_open",
|
||||
who: whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80","100.99.99.99:80"]}`}}),
|
||||
wantRecorderAddresses: []netip.AddrPort{addr1, addr2},
|
||||
wantFailOpen: true,
|
||||
},
|
||||
{
|
||||
name: "odd_rule_combination_fail_closed",
|
||||
who: whoResp(map[string][]string{string(tailcfg.PeerCapabilityKubernetes): {`{"recorderAddrs":["100.99.99.99:80"],"enforceRecorder":false}`, `{"recorderAddrs":["[fd7a:115c:a1e0:ab12:4843:cd96:626b:628b]:80"]}`, `{"enforceRecorder":true,"impersonate":{"groups":["system:masters"]}}`}}),
|
||||
wantRecorderAddresses: []netip.AddrPort{addr2, addr1},
|
||||
},
|
||||
{
|
||||
name: "no_caps",
|
||||
who: whoResp(map[string][]string{}),
|
||||
wantFailOpen: true,
|
||||
},
|
||||
{
|
||||
name: "no_recorder_caps",
|
||||
who: whoResp(map[string][]string{"foo": {`{"x":"y"}`}, string(tailcfg.PeerCapabilityKubernetes): {`{"impersonate":{"groups":["system:masters"]}}`}}),
|
||||
wantFailOpen: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotFailOpen, gotRecorderAddresses, err := determineRecorderConfig(tt.who)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotFailOpen != tt.wantFailOpen {
|
||||
t.Errorf("determineRecorderConfig() gotFailOpen = %v, want %v", gotFailOpen, tt.wantFailOpen)
|
||||
}
|
||||
if !reflect.DeepEqual(gotRecorderAddresses, tt.wantRecorderAddresses) {
|
||||
t.Errorf("determineRecorderConfig() gotRecorderAddresses = %v, want %v", gotRecorderAddresses, tt.wantRecorderAddresses)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func whoResp(capMap map[string][]string) *apitype.WhoIsResponse {
|
||||
resp := &apitype.WhoIsResponse{
|
||||
CapMap: tailcfg.PeerCapMap{},
|
||||
}
|
||||
for cap, rules := range capMap {
|
||||
resp.CapMap[tailcfg.PeerCapability(cap)] = raw(rules...)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func raw(in ...string) []tailcfg.RawMessage {
|
||||
var out []tailcfg.RawMessage
|
||||
for _, i := range in {
|
||||
out = append(out, tailcfg.RawMessage(i))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
dockerref "github.com/distribution/reference"
|
||||
"go.uber.org/zap"
|
||||
@@ -18,6 +20,7 @@ import (
|
||||
apivalidation "k8s.io/apimachinery/pkg/api/validation"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
metavalidation "k8s.io/apimachinery/pkg/apis/meta/v1/validation"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
@@ -25,6 +28,8 @@ import (
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -41,8 +46,20 @@ type ProxyClassReconciler struct {
|
||||
recorder record.EventRecorder
|
||||
logger *zap.SugaredLogger
|
||||
clock tstime.Clock
|
||||
|
||||
mu sync.Mutex // protects following
|
||||
|
||||
// managedProxyClasses is a set of all ProxyClass resources that we're currently
|
||||
// managing. This is only used for metrics.
|
||||
managedProxyClasses set.Slice[types.UID]
|
||||
}
|
||||
|
||||
var (
|
||||
// gaugeProxyClassResources tracks the number of ProxyClass resources
|
||||
// that we're currently managing.
|
||||
gaugeProxyClassResources = clientmetric.NewGauge("k8s_proxyclass_resources")
|
||||
)
|
||||
|
||||
func (pcr *ProxyClassReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
logger := pcr.logger.With("ProxyClass", req.Name)
|
||||
logger.Debugf("starting reconcile")
|
||||
@@ -57,9 +74,26 @@ func (pcr *ProxyClassReconciler) Reconcile(ctx context.Context, req reconcile.Re
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com ProxyClass: %w", err)
|
||||
}
|
||||
if !pc.DeletionTimestamp.IsZero() {
|
||||
logger.Debugf("ProxyClass is being deleted, do nothing")
|
||||
return reconcile.Result{}, nil
|
||||
logger.Debugf("ProxyClass is being deleted")
|
||||
return reconcile.Result{}, pcr.maybeCleanup(ctx, logger, pc)
|
||||
}
|
||||
|
||||
// Add a finalizer so that we can ensure that metrics get updated when
|
||||
// this ProxyClass is deleted.
|
||||
if !slices.Contains(pc.Finalizers, FinalizerName) {
|
||||
logger.Debugf("updating ProxyClass finalizers")
|
||||
pc.Finalizers = append(pc.Finalizers, FinalizerName)
|
||||
if err := pcr.Update(ctx, pc); err != nil {
|
||||
return res, fmt.Errorf("failed to add finalizer: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure this ProxyClass is tracked in metrics.
|
||||
pcr.mu.Lock()
|
||||
pcr.managedProxyClasses.Add(pc.UID)
|
||||
gaugeProxyClassResources.Set(int64(pcr.managedProxyClasses.Len()))
|
||||
pcr.mu.Unlock()
|
||||
|
||||
oldPCStatus := pc.Status.DeepCopy()
|
||||
if errs := pcr.validate(pc); errs != nil {
|
||||
msg := fmt.Sprintf(messageProxyClassInvalid, errs.ToAggregate().Error())
|
||||
@@ -77,7 +111,7 @@ func (pcr *ProxyClassReconciler) Reconcile(ctx context.Context, req reconcile.Re
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
func (a *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations field.ErrorList) {
|
||||
func (pcr *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations field.ErrorList) {
|
||||
if sts := pc.Spec.StatefulSet; sts != nil {
|
||||
if len(sts.Labels) > 0 {
|
||||
if errs := metavalidation.ValidateLabels(sts.Labels, field.NewPath(".spec.statefulSet.labels")); errs != nil {
|
||||
@@ -103,13 +137,13 @@ func (a *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations field.
|
||||
if tc := pod.TailscaleContainer; tc != nil {
|
||||
for _, e := range tc.Env {
|
||||
if strings.HasPrefix(string(e.Name), "TS_") {
|
||||
a.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
|
||||
pcr.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
|
||||
}
|
||||
if strings.EqualFold(string(e.Name), "EXPERIMENTAL_TS_CONFIGFILE_PATH") {
|
||||
a.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
|
||||
pcr.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
|
||||
}
|
||||
if strings.EqualFold(string(e.Name), "EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS") {
|
||||
a.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
|
||||
pcr.recorder.Event(pc, corev1.EventTypeWarning, reasonCustomTSEnvVar, fmt.Sprintf(messageCustomTSEnvVar, string(e.Name), "tailscale"))
|
||||
}
|
||||
}
|
||||
if tc.Image != "" {
|
||||
@@ -135,3 +169,27 @@ func (a *ProxyClassReconciler) validate(pc *tsapi.ProxyClass) (violations field.
|
||||
// time.
|
||||
return violations
|
||||
}
|
||||
|
||||
// maybeCleanup removes tailscale.com finalizer and ensures that the ProxyClass
|
||||
// is no longer counted towards k8s_proxyclass_resources.
|
||||
func (pcr *ProxyClassReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, pc *tsapi.ProxyClass) error {
|
||||
ix := slices.Index(pc.Finalizers, FinalizerName)
|
||||
if ix < 0 {
|
||||
logger.Debugf("no finalizer, nothing to do")
|
||||
pcr.mu.Lock()
|
||||
defer pcr.mu.Unlock()
|
||||
pcr.managedProxyClasses.Remove(pc.UID)
|
||||
gaugeProxyClassResources.Set(int64(pcr.managedProxyClasses.Len()))
|
||||
return nil
|
||||
}
|
||||
pc.Finalizers = append(pc.Finalizers[:ix], pc.Finalizers[ix+1:]...)
|
||||
if err := pcr.Update(ctx, pc); err != nil {
|
||||
return fmt.Errorf("failed to remove finalizer: %w", err)
|
||||
}
|
||||
pcr.mu.Lock()
|
||||
defer pcr.mu.Unlock()
|
||||
pcr.managedProxyClasses.Remove(pc.UID)
|
||||
gaugeProxyClassResources.Set(int64(pcr.managedProxyClasses.Len()))
|
||||
logger.Infof("ProxyClass resources have been cleaned up")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ func TestProxyClass(t *testing.T) {
|
||||
// The apiserver is supposed to set the UID, but the fake client
|
||||
// doesn't. So, set it explicitly because other code later depends
|
||||
// on it being set.
|
||||
UID: types.UID("1234-UID"),
|
||||
UID: types.UID("1234-UID"),
|
||||
Finalizers: []string{"tailscale.com/finalizer"},
|
||||
},
|
||||
Spec: tsapi.ProxyClassSpec{
|
||||
StatefulSet: &tsapi.StatefulSet{
|
||||
@@ -67,12 +68,12 @@ func TestProxyClass(t *testing.T) {
|
||||
|
||||
// 1. A valid ProxyClass resource gets its status updated to Ready.
|
||||
expectReconciled(t, pcr, "", "test")
|
||||
pc.Status.Conditions = append(pc.Status.Conditions, tsapi.ConnectorCondition{
|
||||
Type: tsapi.ProxyClassready,
|
||||
pc.Status.Conditions = append(pc.Status.Conditions, metav1.Condition{
|
||||
Type: string(tsapi.ProxyClassready),
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: reasonProxyClassValid,
|
||||
Message: reasonProxyClassValid,
|
||||
LastTransitionTime: &metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
LastTransitionTime: metav1.Time{Time: cl.Now().Truncate(time.Second)},
|
||||
})
|
||||
|
||||
expectEqual(t, fc, pc, nil)
|
||||
|
||||
@@ -35,7 +35,6 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/opt"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
@@ -295,6 +294,7 @@ func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, l
|
||||
Selector: map[string]string{
|
||||
"app": sts.ParentResourceUID,
|
||||
},
|
||||
IPFamilyPolicy: ptr.To(corev1.IPFamilyPolicyPreferDualStack),
|
||||
},
|
||||
}
|
||||
logger.Debugf("reconciling headless service for StatefulSet")
|
||||
@@ -406,8 +406,10 @@ func sanitizeConfigBytes(c ipn.ConfigVAlpha) string {
|
||||
return string(sanitizedBytes)
|
||||
}
|
||||
|
||||
// DeviceInfo returns the device ID and hostname for the Tailscale device
|
||||
// associated with the given labels.
|
||||
// DeviceInfo returns the device ID, hostname and IPs for the Tailscale device
|
||||
// that acts as an operator proxy. It retrieves info from a Kubernetes Secret
|
||||
// labeled with the provided labels.
|
||||
// Either of device ID, hostname and IPs can be empty string if not found in the Secret.
|
||||
func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map[string]string) (id tailcfg.StableNodeID, hostname string, ips []string, err error) {
|
||||
sec, err := getSingleObject[corev1.Secret](ctx, a.Client, a.operatorNamespace, childLabels)
|
||||
if err != nil {
|
||||
@@ -424,7 +426,12 @@ func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map
|
||||
// to remove it.
|
||||
hostname = strings.TrimSuffix(string(sec.Data["device_fqdn"]), ".")
|
||||
if hostname == "" {
|
||||
return "", "", nil, nil
|
||||
// Device ID gets stored and retrieved in a different flow than
|
||||
// FQDN and IPs. A device that acts as Kubernetes operator
|
||||
// proxy, but whose route setup has failed might have an device
|
||||
// ID, but no FQDN/IPs. If so, return the ID, to allow the
|
||||
// operator to clean up such devices.
|
||||
return id, "", nil, nil
|
||||
}
|
||||
if rawDeviceIPs, ok := sec.Data["device_ips"]; ok {
|
||||
if err := json.Unmarshal(rawDeviceIPs, &ips); err != nil {
|
||||
@@ -940,14 +947,11 @@ func defaultEnv(envName, defVal string) string {
|
||||
return v
|
||||
}
|
||||
|
||||
func nameForService(svc *corev1.Service) (string, error) {
|
||||
func nameForService(svc *corev1.Service) string {
|
||||
if h, ok := svc.Annotations[AnnotationHostname]; ok {
|
||||
if err := dnsname.ValidLabel(h); err != nil {
|
||||
return "", fmt.Errorf("invalid Tailscale hostname %q: %w", h, err)
|
||||
}
|
||||
return h, nil
|
||||
return h
|
||||
}
|
||||
return svc.Namespace + "-" + svc.Name, nil
|
||||
return svc.Namespace + "-" + svc.Name
|
||||
}
|
||||
|
||||
func isValidFirewallMode(m string) bool {
|
||||
|
||||
@@ -7,6 +7,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
@@ -15,7 +16,9 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
@@ -23,13 +26,20 @@ import (
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/net/dns/resolvconffile"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/dnsname"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
resolvConfPath = "/etc/resolv.conf"
|
||||
defaultClusterDomain = "cluster.local"
|
||||
|
||||
reasonProxyCreated = "ProxyCreated"
|
||||
reasonProxyInvalid = "ProxyInvalid"
|
||||
reasonProxyFailed = "ProxyFailed"
|
||||
reasonProxyPending = "ProxyPending"
|
||||
)
|
||||
|
||||
type ServiceReconciler struct {
|
||||
@@ -50,6 +60,8 @@ type ServiceReconciler struct {
|
||||
recorder record.EventRecorder
|
||||
|
||||
tsNamespace string
|
||||
|
||||
clock tstime.Clock
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -76,6 +88,12 @@ func childResourceLabels(name, ns, typ string) map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) isTailscaleService(svc *corev1.Service) bool {
|
||||
targetIP := tailnetTargetAnnotation(svc)
|
||||
targetFQDN := svc.Annotations[AnnotationTailnetTargetFQDN]
|
||||
return a.shouldExpose(svc) || targetIP != "" || targetFQDN != ""
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
|
||||
logger := a.logger.With("service-ns", req.Namespace, "service-name", req.Name)
|
||||
logger.Debugf("starting reconcile")
|
||||
@@ -90,9 +108,8 @@ func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request
|
||||
} else if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get svc: %w", err)
|
||||
}
|
||||
targetIP := tailnetTargetAnnotation(svc)
|
||||
targetFQDN := svc.Annotations[AnnotationTailnetTargetFQDN]
|
||||
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) && targetIP == "" && targetFQDN == "" {
|
||||
|
||||
if !svc.DeletionTimestamp.IsZero() || !a.isTailscaleService(svc) {
|
||||
logger.Debugf("service is being deleted or is (no longer) referring to Tailscale ingress/egress, ensuring any created resources are cleaned up")
|
||||
return reconcile.Result{}, a.maybeCleanup(ctx, logger, svc)
|
||||
}
|
||||
@@ -104,7 +121,14 @@ func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request
|
||||
//
|
||||
// This function is responsible for removing the finalizer from the service,
|
||||
// once all associated resources are gone.
|
||||
func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
|
||||
func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) (err error) {
|
||||
oldSvcStatus := svc.Status.DeepCopy()
|
||||
defer func() {
|
||||
if !apiequality.Semantic.DeepEqual(oldSvcStatus, svc.Status) {
|
||||
// An error encountered here should get returned by the Reconcile function.
|
||||
err = errors.Join(err, a.Client.Status().Update(ctx, svc))
|
||||
}
|
||||
}()
|
||||
ix := slices.Index(svc.Finalizers, FinalizerName)
|
||||
if ix < 0 {
|
||||
logger.Debugf("no finalizer, nothing to do")
|
||||
@@ -114,6 +138,10 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
|
||||
a.managedEgressProxies.Remove(svc.UID)
|
||||
gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len()))
|
||||
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
|
||||
|
||||
if !a.isTailscaleService(svc) {
|
||||
tsoperator.RemoveServiceCondition(svc, tsapi.ProxyReady)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -133,7 +161,7 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
|
||||
// exactly once at the very end of cleanup, because the final step of
|
||||
// cleanup removes the tailscale finalizer, which will make all future
|
||||
// reconciles exit early.
|
||||
logger.Infof("unexposed service from tailnet")
|
||||
logger.Infof("unexposed Service from tailnet")
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -141,6 +169,10 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
|
||||
a.managedEgressProxies.Remove(svc.UID)
|
||||
gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len()))
|
||||
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
|
||||
|
||||
if !a.isTailscaleService(svc) {
|
||||
tsoperator.RemoveServiceCondition(svc, tsapi.ProxyReady)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -149,7 +181,15 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare
|
||||
//
|
||||
// This function adds a finalizer to svc, ensuring that we can handle orderly
|
||||
// deprovisioning later.
|
||||
func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error {
|
||||
func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) (err error) {
|
||||
oldSvcStatus := svc.Status.DeepCopy()
|
||||
defer func() {
|
||||
if !apiequality.Semantic.DeepEqual(oldSvcStatus, svc.Status) {
|
||||
// An error encountered here should get returned by the Reconcile function.
|
||||
err = errors.Join(err, a.Client.Status().Update(ctx, svc))
|
||||
}
|
||||
}()
|
||||
|
||||
// Run for proxy config related validations here as opposed to running
|
||||
// them earlier. This is to prevent cleanup being blocked on a
|
||||
// misconfigured proxy param.
|
||||
@@ -157,30 +197,31 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
msg := fmt.Sprintf("unable to provision proxy resources: invalid config: %v", err)
|
||||
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDCONFIG", msg)
|
||||
a.logger.Error(msg)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyInvalid, msg, a.clock, logger)
|
||||
return nil
|
||||
}
|
||||
if violations := validateService(svc); len(violations) > 0 {
|
||||
msg := fmt.Sprintf("unable to provision proxy resources: invalid Service: %s", strings.Join(violations, ", "))
|
||||
a.recorder.Event(svc, corev1.EventTypeWarning, "INVALIDSERVICE", msg)
|
||||
a.logger.Error(msg)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyInvalid, msg, a.clock, logger)
|
||||
return nil
|
||||
}
|
||||
|
||||
proxyClass := proxyClassForObject(svc)
|
||||
if proxyClass != "" {
|
||||
if ready, err := proxyClassIsReady(ctx, proxyClass, a.Client); err != nil {
|
||||
return fmt.Errorf("error verifying ProxyClass for Service: %w", err)
|
||||
errMsg := fmt.Errorf("error verifying ProxyClass for Service: %w", err)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger)
|
||||
return errMsg
|
||||
} else if !ready {
|
||||
logger.Infof("ProxyClass %s specified for the Service, but is not (yet) Ready, waiting..", proxyClass)
|
||||
msg := fmt.Sprintf("ProxyClass %s specified for the Service, but is not (yet) Ready, waiting..", proxyClass)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyPending, msg, a.clock, logger)
|
||||
logger.Info(msg)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
hostname, err := nameForService(svc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains(svc.Finalizers, FinalizerName) {
|
||||
// This log line is printed exactly once during initial provisioning,
|
||||
// because once the finalizer is in place this block gets skipped. So,
|
||||
@@ -189,7 +230,9 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
logger.Infof("exposing service over tailscale")
|
||||
svc.Finalizers = append(svc.Finalizers, FinalizerName)
|
||||
if err := a.Update(ctx, svc); err != nil {
|
||||
return fmt.Errorf("failed to add finalizer: %w", err)
|
||||
errMsg := fmt.Errorf("failed to add finalizer: %w", err)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger)
|
||||
return errMsg
|
||||
}
|
||||
}
|
||||
crl := childResourceLabels(svc.Name, svc.Namespace, "svc")
|
||||
@@ -201,7 +244,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
sts := &tailscaleSTSConfig{
|
||||
ParentResourceName: svc.Name,
|
||||
ParentResourceUID: string(svc.UID),
|
||||
Hostname: hostname,
|
||||
Hostname: nameForService(svc),
|
||||
Tags: tags,
|
||||
ChildResourceLabels: crl,
|
||||
ProxyClassName: proxyClass,
|
||||
@@ -233,10 +276,12 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
|
||||
var hsvc *corev1.Service
|
||||
if hsvc, err = a.ssr.Provision(ctx, logger, sts); err != nil {
|
||||
return fmt.Errorf("failed to provision: %w", err)
|
||||
errMsg := fmt.Errorf("failed to provision: %w", err)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger)
|
||||
return errMsg
|
||||
}
|
||||
|
||||
if sts.TailnetTargetIP != "" || sts.TailnetTargetFQDN != "" {
|
||||
if sts.TailnetTargetIP != "" || sts.TailnetTargetFQDN != "" { // if an egress proxy
|
||||
clusterDomain := retrieveClusterDomain(a.tsNamespace, logger)
|
||||
headlessSvcName := hsvc.Name + "." + hsvc.Namespace + ".svc." + clusterDomain
|
||||
if svc.Spec.ExternalName != headlessSvcName || svc.Spec.Type != corev1.ServiceTypeExternalName {
|
||||
@@ -244,14 +289,18 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
svc.Spec.Selector = nil
|
||||
svc.Spec.Type = corev1.ServiceTypeExternalName
|
||||
if err := a.Update(ctx, svc); err != nil {
|
||||
return fmt.Errorf("failed to update service: %w", err)
|
||||
errMsg := fmt.Errorf("failed to update service: %w", err)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, errMsg.Error(), a.clock, logger)
|
||||
return errMsg
|
||||
}
|
||||
}
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionTrue, reasonProxyCreated, reasonProxyCreated, a.clock, logger)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isTailscaleLoadBalancerService(svc, a.isDefaultLoadBalancer) {
|
||||
logger.Debugf("service is not a LoadBalancer, so not updating ingress")
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionTrue, reasonProxyCreated, reasonProxyCreated, a.clock, logger)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -260,22 +309,23 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
return fmt.Errorf("failed to get device ID: %w", err)
|
||||
}
|
||||
if tsHost == "" {
|
||||
logger.Debugf("no Tailscale hostname known yet, waiting for proxy pod to finish auth")
|
||||
msg := "no Tailscale hostname known yet, waiting for proxy pod to finish auth"
|
||||
logger.Debug(msg)
|
||||
// No hostname yet. Wait for the proxy pod to auth.
|
||||
svc.Status.LoadBalancer.Ingress = nil
|
||||
if err := a.Status().Update(ctx, svc); err != nil {
|
||||
return fmt.Errorf("failed to update service status: %w", err)
|
||||
}
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyPending, msg, a.clock, logger)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debugf("setting ingress to %q, %s", tsHost, strings.Join(tsIPs, ", "))
|
||||
logger.Debugf("setting Service LoadBalancer status to %q, %s", tsHost, strings.Join(tsIPs, ", "))
|
||||
ingress := []corev1.LoadBalancerIngress{
|
||||
{Hostname: tsHost},
|
||||
}
|
||||
clusterIPAddr, err := netip.ParseAddr(svc.Spec.ClusterIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse cluster IP: %w", err)
|
||||
msg := fmt.Sprintf("failed to parse cluster IP: %v", err)
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionFalse, reasonProxyFailed, msg, a.clock, logger)
|
||||
return fmt.Errorf(msg)
|
||||
}
|
||||
for _, ip := range tsIPs {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
@@ -287,9 +337,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
}
|
||||
}
|
||||
svc.Status.LoadBalancer.Ingress = ingress
|
||||
if err := a.Status().Update(ctx, svc); err != nil {
|
||||
return fmt.Errorf("failed to update service status: %w", err)
|
||||
}
|
||||
tsoperator.SetServiceCondition(svc, tsapi.ProxyReady, metav1.ConditionTrue, reasonProxyCreated, reasonProxyCreated, a.clock, logger)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -303,6 +351,14 @@ func validateService(svc *corev1.Service) []string {
|
||||
violations = append(violations, fmt.Sprintf("invalid value of annotation %s: %q does not appear to be a valid MagicDNS name", AnnotationTailnetTargetFQDN, fqdn))
|
||||
}
|
||||
}
|
||||
svcName := nameForService(svc)
|
||||
if err := dnsname.ValidLabel(svcName); err != nil {
|
||||
if _, ok := svc.Annotations[AnnotationHostname]; ok {
|
||||
violations = append(violations, fmt.Sprintf("invalid Tailscale hostname specified %q: %s", svcName, err))
|
||||
} else {
|
||||
violations = append(violations, fmt.Sprintf("invalid Tailscale hostname %q, use %q annotation to override: %s", svcName, AnnotationHostname, err))
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
@@ -334,7 +390,7 @@ func hasExposeAnnotation(svc *corev1.Service) bool {
|
||||
return svc != nil && svc.Annotations[AnnotationExpose] == "true"
|
||||
}
|
||||
|
||||
// hasTailnetTargetAnnotation returns the value of tailscale.com/tailnet-ip
|
||||
// tailnetTargetAnnotation returns the value of tailscale.com/tailnet-ip
|
||||
// annotation or of the deprecated tailscale.com/ts-tailnet-target-ip
|
||||
// annotation. If neither is set, it returns an empty string. If both are set,
|
||||
// it returns the value of the new annotation.
|
||||
|
||||
@@ -304,10 +304,6 @@ func expectedSTSUserspace(t *testing.T, cl client.Client, opts configOpts) *apps
|
||||
|
||||
func expectedHeadlessService(name string, parentType string) *corev1.Service {
|
||||
return &corev1.Service{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Service",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
GenerateName: "ts-test-",
|
||||
@@ -323,7 +319,8 @@ func expectedHeadlessService(name string, parentType string) *corev1.Service {
|
||||
Selector: map[string]string{
|
||||
"app": "1234-UID",
|
||||
},
|
||||
ClusterIP: "None",
|
||||
ClusterIP: "None",
|
||||
IPFamilyPolicy: ptr.To(corev1.IPFamilyPolicyPreferDualStack),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -331,10 +328,6 @@ func expectedHeadlessService(name string, parentType string) *corev1.Service {
|
||||
func expectedSecret(t *testing.T, cl client.Client, opts configOpts) *corev1.Secret {
|
||||
t.Helper()
|
||||
s := &corev1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "Secret",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: opts.secretName,
|
||||
Namespace: "operator-ns",
|
||||
|
||||
@@ -448,7 +448,7 @@ func (c *connector) handleTCPFlow(src, dst netip.AddrPort) (handler func(net.Con
|
||||
// in --ignore-destinations
|
||||
func (c *connector) ignoreDestination(dstAddrs []netip.Addr) bool {
|
||||
for _, a := range dstAddrs {
|
||||
if _, ok := c.ignoreDsts.Get(a); ok {
|
||||
if _, ok := c.ignoreDsts.Lookup(a); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -489,7 +489,7 @@ type perPeerState struct {
|
||||
func (ps *perPeerState) domainForIP(ip netip.Addr) (_ string, ok bool) {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
return ps.addrToDomain.Get(ip)
|
||||
return ps.addrToDomain.Lookup(ip)
|
||||
}
|
||||
|
||||
// ipForDomain assigns a pair of unique IP addresses for the given domain and
|
||||
@@ -515,7 +515,7 @@ func (ps *perPeerState) ipForDomain(domain string) ([]netip.Addr, error) {
|
||||
// domain.
|
||||
// ps.mu must be held.
|
||||
func (ps *perPeerState) isIPUsedLocked(ip netip.Addr) bool {
|
||||
_, ok := ps.addrToDomain.Get(ip)
|
||||
_, ok := ps.addrToDomain.Lookup(ip)
|
||||
return ok
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ var (
|
||||
backendAddr = flag.String("backend-addr", "", "Address of the Grafana server served over HTTP, in host:port format. Typically localhost:nnnn.")
|
||||
tailscaleDir = flag.String("state-dir", "./", "Alternate directory to use for Tailscale state storage. If empty, a default is used.")
|
||||
useHTTPS = flag.Bool("use-https", false, "Serve over HTTPS via your *.ts.net subdomain if enabled in Tailscale admin.")
|
||||
loginServer = flag.String("login-server", "", "URL to alternative control server. If empty, the default Tailscale control is used.")
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -57,8 +58,9 @@ func main() {
|
||||
log.Fatal("missing --backend-addr")
|
||||
}
|
||||
ts := &tsnet.Server{
|
||||
Dir: *tailscaleDir,
|
||||
Hostname: *hostname,
|
||||
Dir: *tailscaleDir,
|
||||
Hostname: *hostname,
|
||||
ControlURL: *loginServer,
|
||||
}
|
||||
|
||||
// TODO(bradfitz,maisem): move this to a method on tsnet.Server probably.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"tailscale.com/net/stun"
|
||||
)
|
||||
@@ -15,12 +16,20 @@ import (
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
|
||||
if len(os.Args) != 2 {
|
||||
log.Fatalf("usage: %s <hostname>", os.Args[0])
|
||||
if len(os.Args) < 2 || len(os.Args) > 3 {
|
||||
log.Fatalf("usage: %s <hostname> [port]", os.Args[0])
|
||||
}
|
||||
host := os.Args[1]
|
||||
port := "3478"
|
||||
if len(os.Args) == 3 {
|
||||
port = os.Args[2]
|
||||
}
|
||||
_, err := strconv.ParseUint(port, 10, 16)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid port: %v", err)
|
||||
}
|
||||
|
||||
uaddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, "3478"))
|
||||
uaddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, port))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
|
||||
github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus
|
||||
💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus
|
||||
github.com/go-json-experiment/json from tailscale.com/types/opt
|
||||
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
|
||||
github.com/google/uuid from tailscale.com/util/fastuuid
|
||||
💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz
|
||||
github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus
|
||||
@@ -59,7 +65,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
tailscale.com/types/lazy from tailscale.com/version+
|
||||
tailscale.com/types/logger from tailscale.com/tsweb
|
||||
tailscale.com/types/opt from tailscale.com/envknob+
|
||||
tailscale.com/types/ptr from tailscale.com/tailcfg
|
||||
tailscale.com/types/ptr from tailscale.com/tailcfg+
|
||||
tailscale.com/types/structs from tailscale.com/tailcfg+
|
||||
tailscale.com/types/tkatype from tailscale.com/tailcfg+
|
||||
tailscale.com/types/views from tailscale.com/net/tsaddr+
|
||||
@@ -128,6 +134,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
|
||||
embed from crypto/internal/nistec+
|
||||
encoding from encoding/json+
|
||||
encoding/asn1 from crypto/x509+
|
||||
encoding/base32 from github.com/go-json-experiment/json
|
||||
encoding/base64 from encoding/json+
|
||||
encoding/binary from compress/gzip+
|
||||
encoding/hex from crypto/x509+
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
)
|
||||
|
||||
type api struct {
|
||||
db *db
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func newAPI(db *db) *api {
|
||||
a := &api{
|
||||
db: db,
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/query", a.query)
|
||||
a.mux = mux
|
||||
return a
|
||||
}
|
||||
|
||||
type apiResult struct {
|
||||
At int `json:"at"` // time.Time.Unix()
|
||||
RegionID int `json:"regionID"`
|
||||
Hostname string `json:"hostname"`
|
||||
Af int `json:"af"` // 4 or 6
|
||||
Addr string `json:"addr"`
|
||||
Source int `json:"source"` // timestampSourceUserspace (0) or timestampSourceKernel (1)
|
||||
StableConn bool `json:"stableConn"`
|
||||
DstPort int `json:"dstPort"`
|
||||
RttNS *int `json:"rttNS"`
|
||||
}
|
||||
|
||||
func getTimeBounds(vals url.Values) (from time.Time, to time.Time, err error) {
|
||||
lastForm, ok := vals["last"]
|
||||
if ok && len(lastForm) > 0 {
|
||||
dur, err := time.ParseDuration(lastForm[0])
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
return now.Add(-dur), now, nil
|
||||
}
|
||||
|
||||
fromForm, ok := vals["from"]
|
||||
if ok && len(fromForm) > 0 {
|
||||
fromUnixSec, err := strconv.Atoi(fromForm[0])
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
from = time.Unix(int64(fromUnixSec), 0)
|
||||
toForm, ok := vals["to"]
|
||||
if ok && len(toForm) > 0 {
|
||||
toUnixSec, err := strconv.Atoi(toForm[0])
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
to = time.Unix(int64(toUnixSec), 0)
|
||||
} else {
|
||||
return time.Time{}, time.Time{}, errors.New("from specified without to")
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
// no time bounds specified, default to last 1h
|
||||
now := time.Now()
|
||||
return now.Add(-time.Hour), now, nil
|
||||
}
|
||||
|
||||
func (a *api) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
a.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (a *api) query(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
from, to, err := getTimeBounds(r.Form)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
sb := sq.Select("at_unix", "region_id", "hostname", "af", "address", "timestamp_source", "stable_conn", "dst_port", "rtt_ns").From("rtt")
|
||||
sb = sb.Where(sq.And{
|
||||
sq.GtOrEq{"at_unix": from.Unix()},
|
||||
sq.LtOrEq{"at_unix": to.Unix()},
|
||||
})
|
||||
query, args, err := sb.ToSql()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := a.db.Query(query, args...)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
results := make([]apiResult, 0)
|
||||
for rows.Next() {
|
||||
rtt := 0
|
||||
result := apiResult{
|
||||
RttNS: &rtt,
|
||||
}
|
||||
err = rows.Scan(&result.At, &result.RegionID, &result.Hostname, &result.Af, &result.Addr, &result.Source, &result.StableConn, &result.DstPort, &result.RttNS)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
http.Error(w, rows.Err().Error(), 500)
|
||||
return
|
||||
}
|
||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
err = json.NewEncoder(gz).Encode(&results)
|
||||
} else {
|
||||
err = json.NewEncoder(w).Encode(&results)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !(windows && 386)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type db struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func newDB(path string) (*db, error) {
|
||||
d, err := sql.Open("sqlite", *flagOut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &db{
|
||||
DB: d,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type db struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func newDB(path string) (*db, error) {
|
||||
return nil, errors.New("unsupported platform")
|
||||
}
|
||||
@@ -8,18 +8,42 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getConnKernelTimestamp() (io.ReadWriteCloser, error) {
|
||||
func getUDPConnKernelTimestamp() (io.ReadWriteCloser, error) {
|
||||
return nil, errors.New("unimplemented")
|
||||
}
|
||||
|
||||
func measureRTTKernel(conn io.ReadWriteCloser, dst *net.UDPAddr) (rtt time.Duration, err error) {
|
||||
func measureSTUNRTTKernel(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error) {
|
||||
return 0, errors.New("unimplemented")
|
||||
}
|
||||
|
||||
func supportsKernelTS() bool {
|
||||
return false
|
||||
func getProtocolSupportInfo(p protocol) protocolSupportInfo {
|
||||
switch p {
|
||||
case protocolSTUN:
|
||||
return protocolSupportInfo{
|
||||
kernelTS: false,
|
||||
userspaceTS: true,
|
||||
stableConn: true,
|
||||
}
|
||||
case protocolHTTPS:
|
||||
return protocolSupportInfo{
|
||||
kernelTS: false,
|
||||
userspaceTS: true,
|
||||
stableConn: true,
|
||||
}
|
||||
case protocolTCP:
|
||||
return protocolSupportInfo{
|
||||
kernelTS: true,
|
||||
userspaceTS: false,
|
||||
stableConn: true,
|
||||
}
|
||||
}
|
||||
return protocolSupportInfo{}
|
||||
}
|
||||
|
||||
func setSOReuseAddr(fd uintptr) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mdlayher/socket"
|
||||
@@ -24,7 +25,7 @@ const (
|
||||
unix.SOF_TIMESTAMPING_SOFTWARE // report software timestamps
|
||||
)
|
||||
|
||||
func getConnKernelTimestamp() (io.ReadWriteCloser, error) {
|
||||
func getUDPConnKernelTimestamp() (io.ReadWriteCloser, error) {
|
||||
sconn, err := socket.Socket(unix.AF_INET6, unix.SOCK_DGRAM, unix.IPPROTO_UDP, "udp", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -56,24 +57,23 @@ func parseTimestampFromCmsgs(oob []byte) (time.Time, error) {
|
||||
return time.Time{}, errors.New("failed to parse timestamp from cmsgs")
|
||||
}
|
||||
|
||||
func measureRTTKernel(conn io.ReadWriteCloser, dst *net.UDPAddr) (rtt time.Duration, err error) {
|
||||
func measureSTUNRTTKernel(conn io.ReadWriteCloser, hostname string, dst netip.AddrPort) (rtt time.Duration, err error) {
|
||||
sconn, ok := conn.(*socket.Conn)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("conn of unexpected type: %T", conn)
|
||||
}
|
||||
|
||||
var to unix.Sockaddr
|
||||
to4 := dst.IP.To4()
|
||||
if to4 != nil {
|
||||
if dst.Addr().Is4() {
|
||||
to = &unix.SockaddrInet4{
|
||||
Port: dst.Port,
|
||||
Port: int(dst.Port()),
|
||||
}
|
||||
copy(to.(*unix.SockaddrInet4).Addr[:], to4)
|
||||
copy(to.(*unix.SockaddrInet4).Addr[:], dst.Addr().AsSlice())
|
||||
} else {
|
||||
to = &unix.SockaddrInet6{
|
||||
Port: dst.Port,
|
||||
Port: int(dst.Port()),
|
||||
}
|
||||
copy(to.(*unix.SockaddrInet6).Addr[:], dst.IP)
|
||||
copy(to.(*unix.SockaddrInet6).Addr[:], dst.Addr().AsSlice())
|
||||
}
|
||||
|
||||
txID := stun.NewTxID()
|
||||
@@ -138,6 +138,32 @@ func measureRTTKernel(conn io.ReadWriteCloser, dst *net.UDPAddr) (rtt time.Durat
|
||||
|
||||
}
|
||||
|
||||
func supportsKernelTS() bool {
|
||||
return true
|
||||
func getProtocolSupportInfo(p protocol) protocolSupportInfo {
|
||||
switch p {
|
||||
case protocolSTUN:
|
||||
return protocolSupportInfo{
|
||||
kernelTS: true,
|
||||
userspaceTS: true,
|
||||
stableConn: true,
|
||||
}
|
||||
case protocolHTTPS:
|
||||
return protocolSupportInfo{
|
||||
kernelTS: false,
|
||||
userspaceTS: true,
|
||||
stableConn: true,
|
||||
}
|
||||
case protocolTCP:
|
||||
return protocolSupportInfo{
|
||||
kernelTS: true,
|
||||
userspaceTS: false,
|
||||
stableConn: true,
|
||||
}
|
||||
// TODO(jwhited): add ICMP
|
||||
}
|
||||
return protocolSupportInfo{}
|
||||
}
|
||||
|
||||
func setSOReuseAddr(fd uintptr) error {
|
||||
// we may restart faster than TIME_WAIT can clear
|
||||
return syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"software.sslmate.com/src/go-pkcs12"
|
||||
@@ -34,14 +35,16 @@ var certCmd = &ffcli.Command{
|
||||
fs.StringVar(&certArgs.certFile, "cert-file", "", "output cert file or \"-\" for stdout; defaults to DOMAIN.crt if --cert-file and --key-file are both unset")
|
||||
fs.StringVar(&certArgs.keyFile, "key-file", "", "output key file or \"-\" for stdout; defaults to DOMAIN.key if --cert-file and --key-file are both unset")
|
||||
fs.BoolVar(&certArgs.serve, "serve-demo", false, "if true, serve on port :443 using the cert as a demo, instead of writing out the files to disk")
|
||||
fs.DurationVar(&certArgs.minValidity, "min-validity", 0, "ensure the certificate is valid for at least this duration; the output certificate is never expired if this flag is unset or 0, but the lifetime may vary; the maximum allowed min-validity depends on the CA")
|
||||
return fs
|
||||
})(),
|
||||
}
|
||||
|
||||
var certArgs struct {
|
||||
certFile string
|
||||
keyFile string
|
||||
serve bool
|
||||
certFile string
|
||||
keyFile string
|
||||
serve bool
|
||||
minValidity time.Duration
|
||||
}
|
||||
|
||||
func runCert(ctx context.Context, args []string) error {
|
||||
@@ -102,7 +105,7 @@ func runCert(ctx context.Context, args []string) error {
|
||||
certArgs.certFile = domain + ".crt"
|
||||
certArgs.keyFile = domain + ".key"
|
||||
}
|
||||
certPEM, keyPEM, err := localClient.CertPair(ctx, domain)
|
||||
certPEM, keyPEM, err := localClient.CertPairWithValidity(ctx, domain, certArgs.minValidity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -84,6 +84,13 @@ var localClient = tailscale.LocalClient{
|
||||
|
||||
// Run runs the CLI. The args do not include the binary name.
|
||||
func Run(args []string) (err error) {
|
||||
if runtime.GOOS == "linux" && os.Getenv("GOKRAZY_FIRST_START") == "1" && distro.Get() == distro.Gokrazy && os.Getppid() == 1 {
|
||||
// We're running on gokrazy and it's the first start.
|
||||
// Don't run the tailscale CLI as a service; just exit.
|
||||
// See https://gokrazy.org/development/process-interface/
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
args = CleanUpArgs(args)
|
||||
|
||||
if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") {
|
||||
|
||||
@@ -28,10 +28,12 @@ import (
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
"golang.org/x/net/http2"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/control/controlhttp"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/internal/noiseconn"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/net/tshttpproxy"
|
||||
@@ -801,7 +803,10 @@ func runTS2021(ctx context.Context, args []string) error {
|
||||
log.Printf("Dial(%q, %q) ...", network, address)
|
||||
c, err := dialer.DialContext(ctx, network, address)
|
||||
if err != nil {
|
||||
log.Printf("Dial(%q, %q) = %v", network, address, err)
|
||||
// skip logging context cancellation errors
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
log.Printf("Dial(%q, %q) = %v", network, address, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Dial(%q, %q) = %v / %v", network, address, c.LocalAddr(), c.RemoteAddr())
|
||||
}
|
||||
@@ -834,6 +839,52 @@ func runTS2021(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
log.Printf("final underlying conn: %v / %v", conn.LocalAddr(), conn.RemoteAddr())
|
||||
|
||||
h2Transport, err := http2.ConfigureTransports(&http.Transport{
|
||||
IdleConnTimeout: time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("http2.ConfigureTransports: %w", err)
|
||||
}
|
||||
|
||||
// Now, create a Noise conn over the existing conn.
|
||||
nc, err := noiseconn.New(conn.Conn, h2Transport, 0, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("noiseconn.New: %w", err)
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
// Reserve a RoundTrip for the whoami request.
|
||||
ok, _, err := nc.ReserveNewRequest(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ReserveNewRequest: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("ReserveNewRequest failed")
|
||||
}
|
||||
|
||||
// Make a /whoami request to the server to verify that we can actually
|
||||
// communicate over the newly-established connection.
|
||||
whoamiURL := "http://" + ts2021Args.host + "/machine/whoami"
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", whoamiURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := nc.RoundTrip(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("RoundTrip whoami request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Printf("whoami request returned status %v", resp.Status)
|
||||
} else {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading whoami response: %w", err)
|
||||
}
|
||||
log.Printf("whoami response: %q", body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
@@ -66,9 +67,14 @@ func runDriveShare(ctx context.Context, args []string) error {
|
||||
|
||||
name, path := args[0], args[1]
|
||||
|
||||
err := localClient.DriveShareSet(ctx, &drive.Share{
|
||||
absolutePath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = localClient.DriveShareSet(ctx, &drive.Share{
|
||||
Name: name,
|
||||
Path: path,
|
||||
Path: absolutePath,
|
||||
})
|
||||
if err == nil {
|
||||
fmt.Printf("Sharing %q as %q\n", path, name)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
xmaps "golang.org/x/exp/maps"
|
||||
"tailscale.com/envknob"
|
||||
@@ -136,6 +137,7 @@ func runExitNodeList(ctx context.Context, args []string) error {
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "# To view the complete list of exit nodes for a country, use `tailscale exit-node list --filter=` followed by the country name.")
|
||||
fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP.")
|
||||
if hasAnyExitNodeSuggestions(peers) {
|
||||
fmt.Fprintln(w, "# To have Tailscale suggest an exit node, use `tailscale exit-node suggest`.")
|
||||
@@ -154,7 +156,7 @@ func runExitNodeSuggest(ctx context.Context, args []string) error {
|
||||
fmt.Println("No exit node suggestion is available.")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Suggested exit node: %v\nTo accept this suggestion, use `tailscale set --exit-node=%v`.\n", res.Name, res.ID)
|
||||
fmt.Printf("Suggested exit node: %v\nTo accept this suggestion, use `tailscale set --exit-node=%v`.\n", res.Name, shellquote.Join(res.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -229,7 +231,7 @@ func filterFormatAndSortExitNodes(peers []*ipnstate.PeerStatus, filterBy string)
|
||||
for _, ps := range peers {
|
||||
loc := cmp.Or(ps.Location, noLocation)
|
||||
|
||||
if filterBy != "" && loc.Country != filterBy {
|
||||
if filterBy != "" && !strings.EqualFold(loc.Country, filterBy) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -269,9 +271,14 @@ func filterFormatAndSortExitNodes(peers []*ipnstate.PeerStatus, filterBy string)
|
||||
countryAnyPeer = append(countryAnyPeer, city.Peers...)
|
||||
var reducedCityPeers []*ipnstate.PeerStatus
|
||||
for i, peer := range city.Peers {
|
||||
if filterBy != "" {
|
||||
// If the peers are being filtered, we return all peers to the user.
|
||||
reducedCityPeers = append(reducedCityPeers, city.Peers...)
|
||||
break
|
||||
}
|
||||
// If the peers are not being filtered, we only return the highest priority peer and any peer that
|
||||
// is currently the active exit node.
|
||||
if i == 0 || peer.ExitNode {
|
||||
// We only return the highest priority peer and any peer that
|
||||
// is currently the active exit node.
|
||||
reducedCityPeers = append(reducedCityPeers, peer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ func TestFilterFormatAndSortExitNodes(t *testing.T) {
|
||||
{
|
||||
Name: "Rainier",
|
||||
Peers: []*ipnstate.PeerStatus{
|
||||
ps[2],
|
||||
ps[2], ps[3],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package internal contains internal code for the ffcomplete package.
|
||||
package internal
|
||||
|
||||
import (
|
||||
|
||||
@@ -52,9 +52,15 @@ func runNetcheck(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure that we close the portmapper after running a netcheck; this
|
||||
// will release any port mappings created.
|
||||
pm := portmapper.NewClient(logf, netMon, nil, nil, nil)
|
||||
defer pm.Close()
|
||||
|
||||
c := &netcheck.Client{
|
||||
NetMon: netMon,
|
||||
PortMapper: portmapper.NewClient(logf, netMon, nil, nil, nil),
|
||||
PortMapper: pm,
|
||||
UseDNSCache: false, // always resolve, don't cache
|
||||
}
|
||||
if netcheckArgs.verbose {
|
||||
@@ -78,9 +84,13 @@ func runNetcheck(ctx context.Context, args []string) error {
|
||||
log.Printf("No DERP map from tailscaled; using default.")
|
||||
}
|
||||
if err != nil || noRegions {
|
||||
hc := &http.Client{Transport: tlsdial.NewTransport()}
|
||||
hc := &http.Client{
|
||||
Transport: tlsdial.NewTransport(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
dm, err = prodDERPMap(ctx, hc)
|
||||
if err != nil {
|
||||
log.Println("Failed to fetch a DERP map, so netcheck cannot continue. Check your Internet connection.")
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -209,6 +219,7 @@ func portMapping(r *netcheck.Report) string {
|
||||
}
|
||||
|
||||
func prodDERPMap(ctx context.Context, httpc *http.Client) (*tailcfg.DERPMap, error) {
|
||||
log.Printf("attempting to fetch a DERPMap from %s", ipn.DefaultControlURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", ipn.DefaultControlURL+"/derpmap/default", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create prodDERPMap request: %w", err)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tka"
|
||||
"tailscale.com/tsconst"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/tkatype"
|
||||
)
|
||||
@@ -443,15 +444,33 @@ func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) err
|
||||
|
||||
var nlSignCmd = &ffcli.Command{
|
||||
Name: "sign",
|
||||
ShortUsage: "tailscale lock sign <node-key> [<rotation-key>] or sign <auth-key>",
|
||||
ShortUsage: "tailscale lock sign <node-key> [<rotation-key>]\ntailscale lock sign <auth-key>",
|
||||
ShortHelp: "Signs a node or pre-approved auth key",
|
||||
LongHelp: `Either:
|
||||
- signs a node key and transmits the signature to the coordination server, or
|
||||
- signs a pre-approved auth key, printing it in a form that can be used to bring up nodes under tailnet lock`,
|
||||
- signs a node key and transmits the signature to the coordination
|
||||
server, or
|
||||
- signs a pre-approved auth key, printing it in a form that can be
|
||||
used to bring up nodes under tailnet lock
|
||||
|
||||
If any of the key arguments begin with "file:", the key is retrieved from
|
||||
the file at the path specified in the argument suffix.`,
|
||||
Exec: runNetworkLockSign,
|
||||
}
|
||||
|
||||
func runNetworkLockSign(ctx context.Context, args []string) error {
|
||||
// If any of the arguments start with "file:", replace that argument
|
||||
// with the contents of the file. We do this early, before the check
|
||||
// to see if the first argument is an auth key.
|
||||
for i, arg := range args {
|
||||
if filename, ok := strings.CutPrefix(arg, "file:"); ok {
|
||||
b, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args[i] = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
if len(args) > 0 && strings.HasPrefix(args[0], "tskey-auth-") {
|
||||
return runTskeyWrapCmd(ctx, args)
|
||||
}
|
||||
@@ -476,7 +495,7 @@ func runNetworkLockSign(ctx context.Context, args []string) error {
|
||||
err := localClient.NetworkLockSign(ctx, nodeKey, []byte(rotationKey.Verifier()))
|
||||
// Provide a better help message for when someone clicks through the signing flow
|
||||
// on the wrong device.
|
||||
if err != nil && strings.Contains(err.Error(), "this node is not trusted by network lock") {
|
||||
if err != nil && strings.Contains(err.Error(), tsconst.TailnetLockNotTrustedMsg) {
|
||||
fmt.Fprintln(Stderr, "Error: Signing is not available on this device because it does not have a trusted tailnet lock key.")
|
||||
fmt.Fprintln(Stderr)
|
||||
fmt.Fprintln(Stderr, "Try again on a signing device instead. Tailnet admins can see signing devices on the admin panel.")
|
||||
@@ -789,7 +808,7 @@ func runNetworkLockRevokeKeys(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
fmt.Printf(`Run the following command on another machine with a trusted tailnet lock key:
|
||||
%s lock recover-compromised-key --cosign %X
|
||||
%s lock revoke-keys --cosign %X
|
||||
`, os.Args[0], aumBytes)
|
||||
return nil
|
||||
}
|
||||
@@ -813,10 +832,10 @@ func runNetworkLockRevokeKeys(ctx context.Context, args []string) error {
|
||||
fmt.Printf(`Co-signing completed successfully.
|
||||
|
||||
To accumulate an additional signature, run the following command on another machine with a trusted tailnet lock key:
|
||||
%s lock recover-compromised-key --cosign %X
|
||||
%s lock revoke-keys --cosign %X
|
||||
|
||||
Alternatively if you are done with co-signing, complete recovery by running the following command:
|
||||
%s lock recover-compromised-key --finish %X
|
||||
%s lock revoke-keys --finish %X
|
||||
`, os.Args[0], aumBytes, os.Args[0], aumBytes)
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
|
||||
@@ -89,7 +89,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -103,7 +103,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -117,7 +117,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -131,7 +131,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -146,7 +146,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -157,10 +157,10 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 9999: {HTTP: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
"/abc": {Proxy: "http://localhost:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -171,7 +171,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -182,7 +182,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 8080: {HTTP: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8080": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
@@ -236,7 +236,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -247,10 +247,10 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 9999: {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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
"/abc": {Proxy: "http://localhost:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -261,7 +261,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -272,7 +272,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
@@ -361,7 +361,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/foo": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -372,10 +372,10 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/foo": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/foo": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -439,7 +439,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:5432",
|
||||
TCPForward: "localhost:5432",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
@@ -466,7 +466,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:123",
|
||||
TCPForward: "localhost:123",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
@@ -560,7 +560,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -572,7 +572,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -584,10 +584,10 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/bar": {Proxy: "http://127.0.0.1:3001"},
|
||||
"/bar": {Proxy: "http://localhost:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -599,10 +599,10 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/bar": {Proxy: "http://127.0.0.1:3001"},
|
||||
"/bar": {Proxy: "http://localhost:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -614,10 +614,10 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/bar": {Proxy: "http://127.0.0.1:3001"},
|
||||
"/bar": {Proxy: "http://localhost:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -628,7 +628,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -636,10 +636,10 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
{ // start a tcp forwarder on 8443
|
||||
command: cmd("serve --bg --tcp=8443 tcp://localhost:5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "127.0.0.1:5432"}},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "localhost:5432"}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -647,7 +647,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
{ // remove primary port http handler
|
||||
command: cmd("serve off"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "127.0.0.1:5432"}},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "localhost:5432"}},
|
||||
},
|
||||
},
|
||||
{ // remove tcp forwarder
|
||||
@@ -717,7 +717,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:5432",
|
||||
TCPForward: "localhost:5432",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
@@ -738,7 +738,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
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"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -758,7 +758,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{4545: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:4545": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/foo": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/foo": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -769,8 +769,8 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{4545: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:4545": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/foo": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/bar": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/foo": {Proxy: "http://localhost:3000"},
|
||||
"/bar": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -800,7 +800,7 @@ func TestServeDevConfigMutations(t *testing.T) {
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{3000: {HTTP: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
"foo.test.ts.net:3000": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/": {Proxy: "http://127.0.0.1:3000"},
|
||||
"/": {Proxy: "http://localhost:3000"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -210,6 +210,9 @@ func runSet(ctx context.Context, args []string) (retErr error) {
|
||||
}
|
||||
}
|
||||
if maskedPrefs.AutoUpdateSet.ApplySet {
|
||||
if !clientupdate.CanAutoUpdate() {
|
||||
return errors.New("automatic updates are not supported on this platform")
|
||||
}
|
||||
// On macsys, tailscaled will set the Sparkle auto-update setting. It
|
||||
// does not use clientupdate.
|
||||
if version.IsMacSysExt() {
|
||||
@@ -221,10 +224,6 @@ func runSet(ctx context.Context, args []string) (retErr error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to enable automatic updates: %v, %q", err, out)
|
||||
}
|
||||
} else {
|
||||
if !clientupdate.CanAutoUpdate() {
|
||||
return errors.New("automatic updates are not supported on this platform")
|
||||
}
|
||||
}
|
||||
}
|
||||
checkPrefs := curPrefs.Clone()
|
||||
|
||||
@@ -33,11 +33,13 @@ var updateCmd = &ffcli.Command{
|
||||
// - Alpine (and other apk-based distros)
|
||||
// - FreeBSD (and other pkg-based distros)
|
||||
// - Unraid/QNAP/Synology
|
||||
// - macOS
|
||||
if distro.Get() != distro.Arch &&
|
||||
distro.Get() != distro.Alpine &&
|
||||
distro.Get() != distro.QNAP &&
|
||||
distro.Get() != distro.Synology &&
|
||||
runtime.GOOS != "freebsd" {
|
||||
runtime.GOOS != "freebsd" &&
|
||||
runtime.GOOS != "darwin" {
|
||||
fs.StringVar(&updateArgs.track, "track", "", `which track to check for updates: "stable" or "unstable" (dev); empty means same as current`)
|
||||
fs.StringVar(&updateArgs.version, "version", "", `explicit version to update/downgrade to`)
|
||||
}
|
||||
|
||||
@@ -26,12 +26,14 @@ var whoisCmd = &ffcli.Command{
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := newFlagSet("whois")
|
||||
fs.BoolVar(&whoIsArgs.json, "json", false, "output in JSON format")
|
||||
fs.StringVar(&whoIsArgs.proto, "proto", "", `protocol; one of "tcp" or "udp"; empty mans both `)
|
||||
return fs
|
||||
}(),
|
||||
}
|
||||
|
||||
var whoIsArgs struct {
|
||||
json bool // output in JSON format
|
||||
json bool // output in JSON format
|
||||
proto string // "tcp" or "udp"
|
||||
}
|
||||
|
||||
func runWhoIs(ctx context.Context, args []string) error {
|
||||
@@ -40,7 +42,7 @@ func runWhoIs(ctx context.Context, args []string) error {
|
||||
} else if len(args) == 0 {
|
||||
return errors.New("missing argument, expected one peer")
|
||||
}
|
||||
who, err := localClient.WhoIs(ctx, args[0])
|
||||
who, err := localClient.WhoIsProto(ctx, whoIsArgs.proto, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,10 +5,20 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
|
||||
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
|
||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
||||
github.com/coder/websocket from tailscale.com/control/controlhttp+
|
||||
github.com/coder/websocket/internal/errd from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/util from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
|
||||
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
|
||||
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/pe+
|
||||
W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
github.com/go-json-experiment/json from tailscale.com/types/opt+
|
||||
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
|
||||
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
|
||||
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
|
||||
L github.com/google/nftables from tailscale.com/util/linuxfw
|
||||
L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt
|
||||
@@ -57,13 +67,9 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
L github.com/vishvananda/netns from github.com/tailscale/netlink+
|
||||
github.com/x448/float16 from github.com/fxamacker/cbor/v2
|
||||
💣 go4.org/mem from tailscale.com/client/tailscale+
|
||||
go4.org/netipx from tailscale.com/net/tsaddr+
|
||||
go4.org/netipx from tailscale.com/net/tsaddr
|
||||
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/netmon+
|
||||
k8s.io/client-go/util/homedir from tailscale.com/cmd/tailscale/cli
|
||||
nhooyr.io/websocket from tailscale.com/control/controlhttp+
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/util from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
sigs.k8s.io/yaml from tailscale.com/cmd/tailscale/cli
|
||||
sigs.k8s.io/yaml/goyaml.v2 from sigs.k8s.io/yaml
|
||||
software.sslmate.com/src/go-pkcs12 from tailscale.com/cmd/tailscale/cli
|
||||
@@ -78,7 +84,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
|
||||
tailscale.com/cmd/tailscale/cli/ffcomplete from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/cmd/tailscale/cli/ffcomplete/internal from tailscale.com/cmd/tailscale/cli/ffcomplete
|
||||
tailscale.com/control/controlbase from tailscale.com/control/controlhttp
|
||||
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
|
||||
tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/control/controlknobs from tailscale.com/net/portmapper
|
||||
tailscale.com/derp from tailscale.com/derp/derphttp
|
||||
@@ -89,22 +95,24 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/health from tailscale.com/net/tlsdial+
|
||||
tailscale.com/health/healthmsg from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/hostinfo from tailscale.com/client/web+
|
||||
tailscale.com/internal/noiseconn from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/ipn from tailscale.com/client/tailscale+
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
|
||||
tailscale.com/licenses from tailscale.com/client/web+
|
||||
tailscale.com/metrics from tailscale.com/derp
|
||||
tailscale.com/net/captivedetection from tailscale.com/net/netcheck
|
||||
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
|
||||
tailscale.com/net/dnscache from tailscale.com/control/controlhttp+
|
||||
tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp
|
||||
tailscale.com/net/flowtrack from tailscale.com/net/packet+
|
||||
tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp+
|
||||
tailscale.com/net/flowtrack from tailscale.com/net/packet
|
||||
tailscale.com/net/netaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/netcheck from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/net/neterror from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/netknob from tailscale.com/net/netns
|
||||
💣 tailscale.com/net/netmon from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/net/netns from tailscale.com/derp/derphttp+
|
||||
💣 tailscale.com/net/netns from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/netutil from tailscale.com/client/tailscale+
|
||||
tailscale.com/net/packet from tailscale.com/wgengine/capture+
|
||||
tailscale.com/net/packet from tailscale.com/wgengine/capture
|
||||
tailscale.com/net/ping from tailscale.com/net/netcheck
|
||||
tailscale.com/net/portmapper from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/net/sockstats from tailscale.com/control/controlhttp+
|
||||
@@ -120,7 +128,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale+
|
||||
tailscale.com/tempfork/spf13/cobra from tailscale.com/cmd/tailscale/cli/ffcomplete+
|
||||
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||
W tailscale.com/tsconst from tailscale.com/net/netmon
|
||||
tailscale.com/tsconst from tailscale.com/net/netmon+
|
||||
tailscale.com/tstime from tailscale.com/control/controlhttp+
|
||||
tailscale.com/tstime/mono from tailscale.com/tstime/rate
|
||||
tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli+
|
||||
@@ -144,9 +152,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/util/cloudenv from tailscale.com/net/dnscache+
|
||||
tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy+
|
||||
tailscale.com/util/ctxkey from tailscale.com/types/logger
|
||||
💣 tailscale.com/util/deephash from tailscale.com/util/syspolicy/setting
|
||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/util/groupmember from tailscale.com/client/web
|
||||
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
|
||||
tailscale.com/util/httpm from tailscale.com/client/tailscale+
|
||||
tailscale.com/util/lineread from tailscale.com/hostinfo+
|
||||
L tailscale.com/util/linuxfw from tailscale.com/net/netns
|
||||
@@ -159,16 +169,18 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache+
|
||||
tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
|
||||
tailscale.com/util/syspolicy from tailscale.com/ipn
|
||||
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/testenv from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/truncate from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/vizerror from tailscale.com/tailcfg+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
|
||||
tailscale.com/version from tailscale.com/client/web+
|
||||
tailscale.com/version/distro from tailscale.com/client/web+
|
||||
tailscale.com/wgengine/capture from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/wgengine/filter from tailscale.com/types/netmap
|
||||
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap
|
||||
golang.org/x/crypto/argon2 from tailscale.com/tka
|
||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/argon2+
|
||||
golang.org/x/crypto/blake2s from tailscale.com/clientupdate/distsign+
|
||||
@@ -183,12 +195,13 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
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+
|
||||
W golang.org/x/exp/constraints from github.com/dblohm7/wingoes/pe+
|
||||
golang.org/x/exp/maps from tailscale.com/cmd/tailscale/cli
|
||||
golang.org/x/exp/maps from tailscale.com/cmd/tailscale/cli+
|
||||
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/http2/hpack from net/http
|
||||
golang.org/x/net/http2 from tailscale.com/cmd/tailscale/cli+
|
||||
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+
|
||||
golang.org/x/net/ipv4 from github.com/miekg/dns+
|
||||
@@ -299,7 +312,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
reflect from archive/tar+
|
||||
regexp from github.com/coreos/go-iptables/iptables+
|
||||
regexp/syntax from regexp
|
||||
runtime/debug from nhooyr.io/websocket/internal/xsync+
|
||||
runtime/debug from github.com/coder/websocket/internal/xsync+
|
||||
slices from tailscale.com/client/web+
|
||||
sort from archive/tar+
|
||||
strconv from archive/tar+
|
||||
|
||||
@@ -17,6 +17,10 @@ func TestDeps(t *testing.T) {
|
||||
"gvisor.dev/gvisor/pkg/cpuid": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"gvisor.dev/gvisor/pkg/tcpip/header": "https://github.com/tailscale/tailscale/issues/9756",
|
||||
"tailscale.com/wgengine/filter": "brings in bart, etc",
|
||||
"github.com/bits-and-blooms/bitset": "unneeded in CLI",
|
||||
"github.com/gaissmai/bart": "unneeded in CLI",
|
||||
"tailscale.com/net/ipset": "unneeded in CLI",
|
||||
},
|
||||
}.Check(t)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
L github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http
|
||||
L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm
|
||||
github.com/bits-and-blooms/bitset from github.com/gaissmai/bart
|
||||
github.com/coder/websocket from tailscale.com/control/controlhttp+
|
||||
github.com/coder/websocket/internal/errd from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/util from github.com/coder/websocket
|
||||
github.com/coder/websocket/internal/xsync from github.com/coder/websocket
|
||||
L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw
|
||||
LD 💣 github.com/creack/pty from tailscale.com/ssh/tailssh
|
||||
W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/com+
|
||||
@@ -90,11 +94,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
💣 github.com/djherbis/times from tailscale.com/drive/driveimpl
|
||||
github.com/fxamacker/cbor/v2 from tailscale.com/tka
|
||||
github.com/gaissmai/bart from tailscale.com/net/tstun+
|
||||
github.com/go-json-experiment/json from tailscale.com/types/opt+
|
||||
github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+
|
||||
github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+
|
||||
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext
|
||||
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext
|
||||
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail
|
||||
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+
|
||||
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+
|
||||
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+
|
||||
W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+
|
||||
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
|
||||
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns+
|
||||
@@ -211,7 +216,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
gvisor.dev/gvisor/pkg/tcpip/header from gvisor.dev/gvisor/pkg/tcpip/header/parse+
|
||||
gvisor.dev/gvisor/pkg/tcpip/header/parse from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/internal/tcp from gvisor.dev/gvisor/pkg/tcpip/stack+
|
||||
gvisor.dev/gvisor/pkg/tcpip/link/channel from tailscale.com/wgengine/netstack
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/hash from gvisor.dev/gvisor/pkg/tcpip/network/ipv4
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/fragmentation from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/ip from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
@@ -221,6 +225,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
gvisor.dev/gvisor/pkg/tcpip/ports from gvisor.dev/gvisor/pkg/tcpip/stack+
|
||||
gvisor.dev/gvisor/pkg/tcpip/seqnum from gvisor.dev/gvisor/pkg/tcpip/header+
|
||||
💣 gvisor.dev/gvisor/pkg/tcpip/stack from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
|
||||
gvisor.dev/gvisor/pkg/tcpip/stack/gro from tailscale.com/wgengine/netstack
|
||||
gvisor.dev/gvisor/pkg/tcpip/transport from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
|
||||
gvisor.dev/gvisor/pkg/tcpip/transport/icmp from tailscale.com/wgengine/netstack
|
||||
gvisor.dev/gvisor/pkg/tcpip/transport/internal/network from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
|
||||
@@ -231,10 +236,6 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
gvisor.dev/gvisor/pkg/tcpip/transport/tcpconntrack from gvisor.dev/gvisor/pkg/tcpip/stack
|
||||
gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
|
||||
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
|
||||
nhooyr.io/websocket from tailscale.com/control/controlhttp+
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/util from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
tailscale.com from tailscale.com/version
|
||||
tailscale.com/appc from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/atomicfile from tailscale.com/ipn+
|
||||
@@ -245,7 +246,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/clientupdate from tailscale.com/client/web+
|
||||
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
|
||||
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/control/controlbase from tailscale.com/control/controlclient+
|
||||
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
|
||||
tailscale.com/control/controlclient from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/control/controlhttp from tailscale.com/control/controlclient
|
||||
tailscale.com/control/controlknobs from tailscale.com/control/controlclient+
|
||||
@@ -265,6 +266,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/health from tailscale.com/control/controlclient+
|
||||
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/hostinfo from tailscale.com/client/web+
|
||||
tailscale.com/internal/noiseconn from tailscale.com/control/controlclient
|
||||
tailscale.com/ipn from tailscale.com/client/tailscale+
|
||||
tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+
|
||||
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
|
||||
@@ -286,6 +288,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/logtail/backoff from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/logtail/filch from tailscale.com/log/sockstatlog+
|
||||
tailscale.com/metrics from tailscale.com/derp+
|
||||
tailscale.com/net/captivedetection from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/connstats from tailscale.com/net/tstun+
|
||||
tailscale.com/net/dns from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/net/dns/publicdns from tailscale.com/net/dns+
|
||||
@@ -295,13 +298,14 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/dnscache from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/dnsfallback from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/net/flowtrack from tailscale.com/net/packet+
|
||||
tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/netaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
|
||||
tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/net/netknob from tailscale.com/logpolicy+
|
||||
💣 tailscale.com/net/netmon from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/net/netns from tailscale.com/cmd/tailscaled+
|
||||
💣 tailscale.com/net/netns from tailscale.com/cmd/tailscaled+
|
||||
W 💣 tailscale.com/net/netstat from tailscale.com/portlist
|
||||
tailscale.com/net/netutil from tailscale.com/client/tailscale+
|
||||
tailscale.com/net/packet from tailscale.com/net/connstats+
|
||||
@@ -326,6 +330,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/posture from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/proxymap from tailscale.com/tsd+
|
||||
💣 tailscale.com/safesocket from tailscale.com/client/tailscale+
|
||||
LD tailscale.com/sessionrecording from tailscale.com/ssh/tailssh
|
||||
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/syncs from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale+
|
||||
@@ -333,7 +338,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
|
||||
tailscale.com/tempfork/heap from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/tka from tailscale.com/client/tailscale+
|
||||
W tailscale.com/tsconst from tailscale.com/net/netmon
|
||||
tailscale.com/tsconst from tailscale.com/net/netmon+
|
||||
tailscale.com/tsd from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/tstime from tailscale.com/control/controlclient+
|
||||
tailscale.com/tstime/mono from tailscale.com/net/tstun+
|
||||
@@ -391,6 +396,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/slicesx from tailscale.com/net/dns/recursive+
|
||||
tailscale.com/util/syspolicy from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/util/syspolicy/internal from tailscale.com/util/syspolicy/setting
|
||||
tailscale.com/util/syspolicy/setting from tailscale.com/util/syspolicy
|
||||
tailscale.com/util/sysresources from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/util/systemd from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/testenv from tailscale.com/ipn/ipnlocal+
|
||||
@@ -399,8 +406,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/util/vizerror from tailscale.com/tailcfg+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/clientupdate+
|
||||
W 💣 tailscale.com/util/winutil/authenticode from tailscale.com/clientupdate+
|
||||
W 💣 tailscale.com/util/winutil/gp from tailscale.com/net/dns
|
||||
W tailscale.com/util/winutil/policy from tailscale.com/ipn/ipnlocal
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo
|
||||
W 💣 tailscale.com/util/winutil/winenv from tailscale.com/hostinfo+
|
||||
tailscale.com/util/zstdframe from tailscale.com/control/controlclient+
|
||||
tailscale.com/version from tailscale.com/client/web+
|
||||
tailscale.com/version/distro from tailscale.com/client/web+
|
||||
@@ -408,6 +416,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/wgengine from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/wgengine/capture from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/filter from tailscale.com/control/controlclient+
|
||||
tailscale.com/wgengine/filter/filtertype from tailscale.com/types/netmap+
|
||||
💣 tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/netlog from tailscale.com/wgengine
|
||||
tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/drive/driveimpl"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/conffile"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
@@ -154,9 +155,11 @@ var beCLI func() // non-nil if CLI is linked in
|
||||
func main() {
|
||||
envknob.PanicIfAnyEnvCheckedInInit()
|
||||
envknob.ApplyDiskConfig()
|
||||
applyIntegrationTestEnvKnob()
|
||||
|
||||
defaultVerbosity := envknob.RegisterInt("TS_LOG_VERBOSITY")
|
||||
printVersion := false
|
||||
flag.IntVar(&args.verbose, "verbose", 0, "log verbosity level; 0 is default, 1 or higher are increasingly verbose")
|
||||
flag.IntVar(&args.verbose, "verbose", defaultVerbosity(), "log verbosity level; 0 is default, 1 or higher are increasingly verbose")
|
||||
flag.BoolVar(&args.cleanUp, "cleanup", false, "clean up system state and exit")
|
||||
flag.StringVar(&args.debug, "debug", "", "listen address ([ip]:port) of optional debug server")
|
||||
flag.StringVar(&args.socksAddr, "socks5-server", "", `optional [ip]:port to run a SOCK5 server (e.g. "localhost:1080")`)
|
||||
@@ -394,7 +397,7 @@ func run() (err error) {
|
||||
// Always clean up, even if we're going to run the server. This covers cases
|
||||
// such as when a system was rebooted without shutting down, or tailscaled
|
||||
// crashed, and would for example restore system DNS configuration.
|
||||
dns.CleanUp(logf, netMon, args.tunname)
|
||||
dns.CleanUp(logf, netMon, sys.HealthTracker(), args.tunname)
|
||||
router.CleanUp(logf, netMon, args.tunname)
|
||||
// If the cleanUp flag was passed, then exit.
|
||||
if args.cleanUp {
|
||||
@@ -698,7 +701,7 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
|
||||
// configuration being unavailable (from the noop
|
||||
// manager). More in Issue 4017.
|
||||
// TODO(bradfitz): add a Synology-specific DNS manager.
|
||||
conf.DNS, err = dns.NewOSConfigurator(logf, sys.HealthTracker(), "") // empty interface name
|
||||
conf.DNS, err = dns.NewOSConfigurator(logf, sys.HealthTracker(), sys.ControlKnobs(), "") // empty interface name
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("dns.NewOSConfigurator: %w", err)
|
||||
}
|
||||
@@ -726,7 +729,7 @@ func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack boo
|
||||
return false, fmt.Errorf("creating router: %w", err)
|
||||
}
|
||||
|
||||
d, err := dns.NewOSConfigurator(logf, sys.HealthTracker(), devName)
|
||||
d, err := dns.NewOSConfigurator(logf, sys.HealthTracker(), sys.ControlKnobs(), devName)
|
||||
if err != nil {
|
||||
dev.Close()
|
||||
r.Close()
|
||||
@@ -895,3 +898,24 @@ func dieOnPipeReadErrorOfFD(fd int) {
|
||||
f.Read(make([]byte, 1))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// applyIntegrationTestEnvKnob applies the tailscaled.env=... environment
|
||||
// variables specified on the Linux kernel command line, if the VM is being
|
||||
// run in NATLab integration tests.
|
||||
//
|
||||
// They're specified as: tailscaled.env=FOO=bar tailscaled.env=BAR=baz
|
||||
func applyIntegrationTestEnvKnob() {
|
||||
if runtime.GOOS != "linux" || !hostinfo.IsNATLabGuestVM() {
|
||||
return
|
||||
}
|
||||
cmdLine, _ := os.ReadFile("/proc/cmdline")
|
||||
for _, s := range strings.Fields(string(cmdLine)) {
|
||||
suf, ok := strings.CutPrefix(s, "tailscaled.env=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if k, v, ok := strings.Cut(suf, "="); ok {
|
||||
envknob.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"crypto/rsa"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -25,6 +27,7 @@ import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -35,6 +38,7 @@ import (
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsnet"
|
||||
@@ -44,13 +48,22 @@ import (
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/rands"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
// ctxConn is a key to look up a net.Conn stored in an HTTP request's context.
|
||||
type ctxConn struct{}
|
||||
|
||||
// funnelClientsFile is the file where client IDs and secrets for OIDC clients
|
||||
// accessing the IDP over Funnel are persisted.
|
||||
const funnelClientsFile = "oidc-funnel-clients.json"
|
||||
|
||||
var (
|
||||
flagVerbose = flag.Bool("verbose", false, "be verbose")
|
||||
flagPort = flag.Int("port", 443, "port to listen on")
|
||||
flagLocalPort = flag.Int("local-port", -1, "allow requests from localhost")
|
||||
flagUseLocalTailscaled = flag.Bool("use-local-tailscaled", false, "use local tailscaled instead of tsnet")
|
||||
flagFunnel = flag.Bool("funnel", false, "use Tailscale Funnel to make tsidp available on the public internet")
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -61,9 +74,11 @@ func main() {
|
||||
}
|
||||
|
||||
var (
|
||||
lc *tailscale.LocalClient
|
||||
st *ipnstate.Status
|
||||
err error
|
||||
lc *tailscale.LocalClient
|
||||
st *ipnstate.Status
|
||||
err error
|
||||
watcherChan chan error
|
||||
cleanup func()
|
||||
|
||||
lns []net.Listener
|
||||
)
|
||||
@@ -90,6 +105,18 @@ func main() {
|
||||
if !anySuccess {
|
||||
log.Fatalf("failed to listen on any of %v", st.TailscaleIPs)
|
||||
}
|
||||
|
||||
// tailscaled needs to be setting an HTTP header for funneled requests
|
||||
// that older versions don't provide.
|
||||
// TODO(naman): is this the correct check?
|
||||
if *flagFunnel && !version.AtLeast(st.Version, "1.71.0") {
|
||||
log.Fatalf("Local tailscaled not new enough to support -funnel. Update Tailscale or use tsnet mode.")
|
||||
}
|
||||
cleanup, watcherChan, err = serveOnLocalTailscaled(ctx, lc, st, uint16(*flagPort), *flagFunnel)
|
||||
if err != nil {
|
||||
log.Fatalf("could not serve on local tailscaled: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
} else {
|
||||
ts := &tsnet.Server{
|
||||
Hostname: "idp",
|
||||
@@ -105,7 +132,15 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("getting local client: %v", err)
|
||||
}
|
||||
ln, err := ts.ListenTLS("tcp", fmt.Sprintf(":%d", *flagPort))
|
||||
var ln net.Listener
|
||||
if *flagFunnel {
|
||||
if err := ipn.CheckFunnelAccess(uint16(*flagPort), st.Self); err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
ln, err = ts.ListenFunnel("tcp", fmt.Sprintf(":%d", *flagPort))
|
||||
} else {
|
||||
ln, err = ts.ListenTLS("tcp", fmt.Sprintf(":%d", *flagPort))
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -113,13 +148,26 @@ func main() {
|
||||
}
|
||||
|
||||
srv := &idpServer{
|
||||
lc: lc,
|
||||
lc: lc,
|
||||
funnel: *flagFunnel,
|
||||
localTSMode: *flagUseLocalTailscaled,
|
||||
}
|
||||
if *flagPort != 443 {
|
||||
srv.serverURL = fmt.Sprintf("https://%s:%d", strings.TrimSuffix(st.Self.DNSName, "."), *flagPort)
|
||||
} else {
|
||||
srv.serverURL = fmt.Sprintf("https://%s", strings.TrimSuffix(st.Self.DNSName, "."))
|
||||
}
|
||||
if *flagFunnel {
|
||||
f, err := os.Open(funnelClientsFile)
|
||||
if err == nil {
|
||||
srv.funnelClients = make(map[string]*funnelClient)
|
||||
if err := json.NewDecoder(f).Decode(&srv.funnelClients); err != nil {
|
||||
log.Fatalf("could not parse %s: %v", funnelClientsFile, err)
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Fatalf("could not open %s: %v", funnelClientsFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Running tsidp at %s ...", srv.serverURL)
|
||||
|
||||
@@ -134,35 +182,129 @@ func main() {
|
||||
}
|
||||
|
||||
for _, ln := range lns {
|
||||
go http.Serve(ln, srv)
|
||||
server := http.Server{
|
||||
Handler: srv,
|
||||
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
|
||||
return context.WithValue(ctx, ctxConn{}, c)
|
||||
},
|
||||
}
|
||||
go server.Serve(ln)
|
||||
}
|
||||
select {}
|
||||
// need to catch os.Interrupt, otherwise deferred cleanup code doesn't run
|
||||
exitChan := make(chan os.Signal, 1)
|
||||
signal.Notify(exitChan, os.Interrupt)
|
||||
select {
|
||||
case <-exitChan:
|
||||
log.Printf("interrupt, exiting")
|
||||
return
|
||||
case <-watcherChan:
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
|
||||
log.Printf("watcher closed, exiting")
|
||||
return
|
||||
}
|
||||
log.Fatalf("watcher error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// serveOnLocalTailscaled starts a serve session using an already-running
|
||||
// tailscaled instead of starting a fresh tsnet server, making something
|
||||
// listening on clientDNSName:dstPort accessible over serve/funnel.
|
||||
func serveOnLocalTailscaled(ctx context.Context, lc *tailscale.LocalClient, st *ipnstate.Status, dstPort uint16, shouldFunnel bool) (cleanup func(), watcherChan chan error, err error) {
|
||||
// In order to support funneling out in local tailscaled mode, we need
|
||||
// to add a serve config to forward the listeners we bound above and
|
||||
// allow those forwarders to be funneled out.
|
||||
sc, err := lc.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("could not get serve config: %v", err)
|
||||
}
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
|
||||
// We watch the IPN bus just to get a session ID. The session expires
|
||||
// when we stop watching the bus, and that auto-deletes the foreground
|
||||
// serve/funnel configs we are creating below.
|
||||
watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyNoPrivateKeys)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("could not set up ipn bus watcher: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
watcher.Close()
|
||||
}
|
||||
}()
|
||||
n, err := watcher.Next()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("could not get initial state from ipn bus watcher: %v", err)
|
||||
}
|
||||
if n.SessionID == "" {
|
||||
err = fmt.Errorf("missing sessionID in ipn.Notify")
|
||||
return nil, nil, err
|
||||
}
|
||||
watcherChan = make(chan error)
|
||||
go func() {
|
||||
for {
|
||||
_, err = watcher.Next()
|
||||
if err != nil {
|
||||
watcherChan <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Create a foreground serve config that gets cleaned up when tsidp
|
||||
// exits and the session ID associated with this config is invalidated.
|
||||
foregroundSc := new(ipn.ServeConfig)
|
||||
mak.Set(&sc.Foreground, n.SessionID, foregroundSc)
|
||||
serverURL := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||
fmt.Printf("setting funnel for %s:%v\n", serverURL, dstPort)
|
||||
|
||||
foregroundSc.SetFunnel(serverURL, dstPort, shouldFunnel)
|
||||
foregroundSc.SetWebHandler(&ipn.HTTPHandler{
|
||||
Proxy: fmt.Sprintf("https://%s", net.JoinHostPort(serverURL, strconv.Itoa(int(dstPort)))),
|
||||
}, serverURL, uint16(*flagPort), "/", true)
|
||||
err = lc.SetServeConfig(ctx, sc)
|
||||
if err != nil {
|
||||
return nil, watcherChan, fmt.Errorf("could not set serve config: %v", err)
|
||||
}
|
||||
|
||||
return func() { watcher.Close() }, watcherChan, nil
|
||||
}
|
||||
|
||||
type idpServer struct {
|
||||
lc *tailscale.LocalClient
|
||||
loopbackURL string
|
||||
serverURL string // "https://foo.bar.ts.net"
|
||||
funnel bool
|
||||
localTSMode bool
|
||||
|
||||
lazyMux lazy.SyncValue[*http.ServeMux]
|
||||
lazySigningKey lazy.SyncValue[*signingKey]
|
||||
lazySigner lazy.SyncValue[jose.Signer]
|
||||
|
||||
mu sync.Mutex // guards the fields below
|
||||
code map[string]*authRequest // keyed by random hex
|
||||
accessToken map[string]*authRequest // keyed by random hex
|
||||
mu sync.Mutex // guards the fields below
|
||||
code map[string]*authRequest // keyed by random hex
|
||||
accessToken map[string]*authRequest // keyed by random hex
|
||||
funnelClients map[string]*funnelClient // keyed by client ID
|
||||
}
|
||||
|
||||
type authRequest struct {
|
||||
// localRP is true if the request is from a relying party running on the
|
||||
// same machine as the idp server. It is mutually exclusive with rpNodeID.
|
||||
// same machine as the idp server. It is mutually exclusive with rpNodeID
|
||||
// and funnelRP.
|
||||
localRP bool
|
||||
|
||||
// rpNodeID is the NodeID of the relying party (who requested the auth, such
|
||||
// as Proxmox or Synology), not the user node who is being authenticated. It
|
||||
// is mutually exclusive with localRP.
|
||||
// is mutually exclusive with localRP and funnelRP.
|
||||
rpNodeID tailcfg.NodeID
|
||||
|
||||
// funnelRP is non-nil if the request is from a relying party outside the
|
||||
// tailnet, via Tailscale Funnel. It is mutually exclusive with rpNodeID
|
||||
// and localRP.
|
||||
funnelRP *funnelClient
|
||||
|
||||
// clientID is the "client_id" sent in the authorized request.
|
||||
clientID string
|
||||
|
||||
@@ -181,9 +323,12 @@ type authRequest struct {
|
||||
validTill time.Time
|
||||
}
|
||||
|
||||
func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string, lc *tailscale.LocalClient) error {
|
||||
// allowRelyingParty validates that a relying party identified either by a
|
||||
// known remoteAddr or a valid client ID/secret pair is allowed to proceed
|
||||
// with the authorization flow associated with this authRequest.
|
||||
func (ar *authRequest) allowRelyingParty(r *http.Request, lc *tailscale.LocalClient) error {
|
||||
if ar.localRP {
|
||||
ra, err := netip.ParseAddrPort(remoteAddr)
|
||||
ra, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -192,7 +337,18 @@ func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
who, err := lc.WhoIs(ctx, remoteAddr)
|
||||
if ar.funnelRP != nil {
|
||||
clientID, clientSecret, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
clientID = r.FormValue("client_id")
|
||||
clientSecret = r.FormValue("client_secret")
|
||||
}
|
||||
if ar.funnelRP.ID != clientID || ar.funnelRP.Secret != clientSecret {
|
||||
return fmt.Errorf("tsidp: invalid client credentials")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
who, err := lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tsidp: error getting WhoIs: %w", err)
|
||||
}
|
||||
@@ -203,24 +359,60 @@ func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string,
|
||||
}
|
||||
|
||||
func (s *idpServer) authorize(w http.ResponseWriter, r *http.Request) {
|
||||
who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
// This URL is visited by the user who is being authenticated. If they are
|
||||
// visiting the URL over Funnel, that means they are not part of the
|
||||
// tailnet that they are trying to be authenticated for.
|
||||
if isFunnelRequest(r) {
|
||||
http.Error(w, "tsidp: unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
uq := r.URL.Query()
|
||||
|
||||
redirectURI := uq.Get("redirect_uri")
|
||||
if redirectURI == "" {
|
||||
http.Error(w, "tsidp: must specify redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var remoteAddr string
|
||||
if s.localTSMode {
|
||||
// in local tailscaled mode, the local tailscaled is forwarding us
|
||||
// HTTP requests, so reading r.RemoteAddr will just get us our own
|
||||
// address.
|
||||
remoteAddr = r.Header.Get("X-Forwarded-For")
|
||||
} else {
|
||||
remoteAddr = r.RemoteAddr
|
||||
}
|
||||
who, err := s.lc.WhoIs(r.Context(), remoteAddr)
|
||||
if err != nil {
|
||||
log.Printf("Error getting WhoIs: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
uq := r.URL.Query()
|
||||
|
||||
code := rands.HexString(32)
|
||||
ar := &authRequest{
|
||||
nonce: uq.Get("nonce"),
|
||||
remoteUser: who,
|
||||
redirectURI: uq.Get("redirect_uri"),
|
||||
redirectURI: redirectURI,
|
||||
clientID: uq.Get("client_id"),
|
||||
}
|
||||
|
||||
if r.URL.Path == "/authorize/localhost" {
|
||||
if r.URL.Path == "/authorize/funnel" {
|
||||
s.mu.Lock()
|
||||
c, ok := s.funnelClients[ar.clientID]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
http.Error(w, "tsidp: invalid client ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ar.redirectURI != c.RedirectURI {
|
||||
http.Error(w, "tsidp: redirect_uri mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ar.funnelRP = c
|
||||
} else if r.URL.Path == "/authorize/localhost" {
|
||||
ar.localRP = true
|
||||
} else {
|
||||
var ok bool
|
||||
@@ -237,8 +429,10 @@ func (s *idpServer) authorize(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
q := make(url.Values)
|
||||
q.Set("code", code)
|
||||
q.Set("state", uq.Get("state"))
|
||||
u := uq.Get("redirect_uri") + "?" + q.Encode()
|
||||
if state := uq.Get("state"); state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u := redirectURI + "?" + q.Encode()
|
||||
log.Printf("Redirecting to %q", u)
|
||||
|
||||
http.Redirect(w, r, u, http.StatusFound)
|
||||
@@ -251,6 +445,7 @@ func (s *idpServer) newMux() *http.ServeMux {
|
||||
mux.HandleFunc("/authorize/", s.authorize)
|
||||
mux.HandleFunc("/userinfo", s.serveUserInfo)
|
||||
mux.HandleFunc("/token", s.serveToken)
|
||||
mux.HandleFunc("/clients/", s.serveClients)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
io.WriteString(w, "<html><body><h1>Tailscale OIDC IdP</h1>")
|
||||
@@ -284,11 +479,6 @@ func (s *idpServer) serveUserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "tsidp: invalid token", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := ar.allowRelyingParty(r.Context(), r.RemoteAddr, s.lc); err != nil {
|
||||
log.Printf("Error allowing relying party: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if ar.validTill.Before(time.Now()) {
|
||||
http.Error(w, "tsidp: token expired", http.StatusBadRequest)
|
||||
@@ -348,7 +538,7 @@ func (s *idpServer) serveToken(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "tsidp: code not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := ar.allowRelyingParty(r.Context(), r.RemoteAddr, s.lc); err != nil {
|
||||
if err := ar.allowRelyingParty(r, s.lc); err != nil {
|
||||
log.Printf("Error allowing relying party: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
@@ -581,7 +771,9 @@ func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var authorizeEndpoint string
|
||||
rpEndpoint := s.serverURL
|
||||
if who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil {
|
||||
if isFunnelRequest(r) {
|
||||
authorizeEndpoint = fmt.Sprintf("%s/authorize/funnel", s.serverURL)
|
||||
} else if who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil {
|
||||
authorizeEndpoint = fmt.Sprintf("%s/authorize/%d", s.serverURL, who.Node.ID)
|
||||
} else if ap.Addr().IsLoopback() {
|
||||
rpEndpoint = s.loopbackURL
|
||||
@@ -611,6 +803,148 @@ func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// funnelClient represents an OIDC client/relying party that is accessing the
|
||||
// IDP over Funnel.
|
||||
type funnelClient struct {
|
||||
ID string `json:"client_id"`
|
||||
Secret string `json:"client_secret,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
// /clients is a privileged endpoint that allows the visitor to create new
|
||||
// Funnel-capable OIDC clients, so it is only accessible over the tailnet.
|
||||
func (s *idpServer) serveClients(w http.ResponseWriter, r *http.Request) {
|
||||
if isFunnelRequest(r) {
|
||||
http.Error(w, "tsidp: not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/clients/")
|
||||
|
||||
if path == "new" {
|
||||
s.serveNewClient(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
s.serveGetClientsList(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
c, ok := s.funnelClients[path]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
http.Error(w, "tsidp: not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "DELETE":
|
||||
s.serveDeleteClient(w, r, path)
|
||||
case "GET":
|
||||
json.NewEncoder(w).Encode(&funnelClient{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Secret: "",
|
||||
RedirectURI: c.RedirectURI,
|
||||
})
|
||||
default:
|
||||
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *idpServer) serveNewClient(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
redirectURI := r.FormValue("redirect_uri")
|
||||
if redirectURI == "" {
|
||||
http.Error(w, "tsidp: must provide redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
clientID := rands.HexString(32)
|
||||
clientSecret := rands.HexString(64)
|
||||
newClient := funnelClient{
|
||||
ID: clientID,
|
||||
Secret: clientSecret,
|
||||
Name: r.FormValue("name"),
|
||||
RedirectURI: redirectURI,
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
mak.Set(&s.funnelClients, clientID, &newClient)
|
||||
if err := s.storeFunnelClientsLocked(); err != nil {
|
||||
log.Printf("could not write funnel clients db: %v", err)
|
||||
http.Error(w, "tsidp: could not write funnel clients to db", http.StatusInternalServerError)
|
||||
// delete the new client to avoid inconsistent state between memory
|
||||
// and disk
|
||||
delete(s.funnelClients, clientID)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(newClient)
|
||||
}
|
||||
|
||||
func (s *idpServer) serveGetClientsList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
redactedClients := make([]funnelClient, 0, len(s.funnelClients))
|
||||
for _, c := range s.funnelClients {
|
||||
redactedClients = append(redactedClients, funnelClient{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Secret: "",
|
||||
RedirectURI: c.RedirectURI,
|
||||
})
|
||||
}
|
||||
s.mu.Unlock()
|
||||
json.NewEncoder(w).Encode(redactedClients)
|
||||
}
|
||||
|
||||
func (s *idpServer) serveDeleteClient(w http.ResponseWriter, r *http.Request, clientID string) {
|
||||
if r.Method != "DELETE" {
|
||||
http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.funnelClients == nil {
|
||||
http.Error(w, "tsidp: client not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if _, ok := s.funnelClients[clientID]; !ok {
|
||||
http.Error(w, "tsidp: client not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
deleted := s.funnelClients[clientID]
|
||||
delete(s.funnelClients, clientID)
|
||||
if err := s.storeFunnelClientsLocked(); err != nil {
|
||||
log.Printf("could not write funnel clients db: %v", err)
|
||||
http.Error(w, "tsidp: could not write funnel clients to db", http.StatusInternalServerError)
|
||||
// restore the deleted value to avoid inconsistent state between memory
|
||||
// and disk
|
||||
s.funnelClients[clientID] = deleted
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// storeFunnelClientsLocked writes the current mapping of OIDC client ID/secret
|
||||
// pairs for RPs that access the IDP over funnel. s.mu must be held while
|
||||
// calling this.
|
||||
func (s *idpServer) storeFunnelClientsLocked() error {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(s.funnelClients); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(funnelClientsFile, buf.Bytes(), 0600)
|
||||
}
|
||||
|
||||
const (
|
||||
minimumRSAKeySize = 2048
|
||||
)
|
||||
@@ -700,3 +1034,24 @@ func parseID[T ~int64](input string) (_ T, ok bool) {
|
||||
}
|
||||
return T(i), true
|
||||
}
|
||||
|
||||
// isFunnelRequest checks if an HTTP request is coming over Tailscale Funnel.
|
||||
func isFunnelRequest(r *http.Request) bool {
|
||||
// If we're funneling through the local tailscaled, it will set this HTTP
|
||||
// header.
|
||||
if r.Header.Get("Tailscale-Funnel-Request") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// If the funneled connection is from tsnet, then the net.Conn will be of
|
||||
// type ipn.FunnelConn.
|
||||
netConn := r.Context().Value(ctxConn{})
|
||||
// if the conn is wrapped inside TLS, unwrap it
|
||||
if tlsConn, ok := netConn.(*tls.Conn); ok {
|
||||
netConn = tlsConn.NetConn()
|
||||
}
|
||||
if _, ok := netConn.(*ipn.FunnelConn); ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
128
cmd/tta/fw_linux.go
Normal file
128
cmd/tta/fw_linux.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/google/nftables"
|
||||
"github.com/google/nftables/expr"
|
||||
"tailscale.com/types/ptr"
|
||||
)
|
||||
|
||||
func init() {
|
||||
addFirewall = addFirewallLinux
|
||||
}
|
||||
|
||||
func addFirewallLinux() error {
|
||||
c, err := nftables.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a new table
|
||||
table := &nftables.Table{
|
||||
Family: nftables.TableFamilyIPv4, // TableFamilyINet doesn't work (why?. oh well.)
|
||||
Name: "filter",
|
||||
}
|
||||
c.AddTable(table)
|
||||
|
||||
// Create a new chain for incoming traffic
|
||||
inputChain := &nftables.Chain{
|
||||
Name: "input",
|
||||
Table: table,
|
||||
Type: nftables.ChainTypeFilter,
|
||||
Hooknum: nftables.ChainHookInput,
|
||||
Priority: nftables.ChainPriorityFilter,
|
||||
Policy: ptr.To(nftables.ChainPolicyDrop),
|
||||
}
|
||||
c.AddChain(inputChain)
|
||||
|
||||
// Allow traffic from the loopback interface
|
||||
c.AddRule(&nftables.Rule{
|
||||
Table: table,
|
||||
Chain: inputChain,
|
||||
Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
||||
&expr.Cmp{
|
||||
Op: expr.CmpOpEq,
|
||||
Register: 1,
|
||||
Data: []byte("lo"),
|
||||
},
|
||||
&expr.Verdict{
|
||||
Kind: expr.VerdictAccept,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Accept established and related connections
|
||||
c.AddRule(&nftables.Rule{
|
||||
Table: table,
|
||||
Chain: inputChain,
|
||||
Exprs: []expr.Any{
|
||||
&expr.Ct{
|
||||
Register: 1,
|
||||
Key: expr.CtKeySTATE,
|
||||
},
|
||||
&expr.Bitwise{
|
||||
SourceRegister: 1,
|
||||
DestRegister: 1,
|
||||
Len: 4,
|
||||
Mask: binary.NativeEndian.AppendUint32(nil, 0x06), // CT_STATE_BIT_ESTABLISHED | CT_STATE_BIT_RELATED
|
||||
Xor: binary.NativeEndian.AppendUint32(nil, 0),
|
||||
},
|
||||
&expr.Cmp{
|
||||
Op: expr.CmpOpNeq,
|
||||
Register: 1,
|
||||
Data: binary.NativeEndian.AppendUint32(nil, 0x00),
|
||||
},
|
||||
&expr.Verdict{
|
||||
Kind: expr.VerdictAccept,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Allow TCP packets in that don't have the SYN bit set, even if they're not
|
||||
// ESTABLISHED or RELATED. This is because the test suite gets TCP
|
||||
// connections up & idle (for HTTP) before it conditionally installs these
|
||||
// firewall rules. But because conntrack wasn't previously active, existing
|
||||
// TCP flows aren't ESTABLISHED and get dropped. So this rule allows
|
||||
// previously established TCP connections that predates the firewall rules
|
||||
// to continue working, as they don't have conntrack state.
|
||||
c.AddRule(&nftables.Rule{
|
||||
Table: table,
|
||||
Chain: inputChain,
|
||||
Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||
&expr.Cmp{
|
||||
Op: expr.CmpOpEq,
|
||||
Register: 1,
|
||||
Data: []byte{0x06}, // TCP
|
||||
},
|
||||
&expr.Payload{ // get TCP flags
|
||||
DestRegister: 1,
|
||||
Base: 2,
|
||||
Offset: 13, // flags
|
||||
Len: 1,
|
||||
},
|
||||
&expr.Bitwise{
|
||||
SourceRegister: 1,
|
||||
DestRegister: 1,
|
||||
Len: 1,
|
||||
Mask: []byte{2}, // TCP_SYN
|
||||
Xor: []byte{0},
|
||||
},
|
||||
&expr.Cmp{
|
||||
Op: expr.CmpOpNeq,
|
||||
Register: 1,
|
||||
Data: []byte{2}, // TCP_SYN
|
||||
},
|
||||
&expr.Verdict{
|
||||
Kind: expr.VerdictAccept,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return c.Flush()
|
||||
}
|
||||
238
cmd/tta/tta.go
Normal file
238
cmd/tta/tta.go
Normal file
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The tta server is the Tailscale Test Agent.
|
||||
//
|
||||
// It runs on each Tailscale node being integration tested and permits the test
|
||||
// harness to control the node. It connects out to the test drver (rather than
|
||||
// accepting any TCP connections inbound, which might be blocked depending on
|
||||
// the scenario being tested) and then the test driver turns the TCP connection
|
||||
// around and sends request back.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/util/must"
|
||||
"tailscale.com/util/set"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
var (
|
||||
driverAddr = flag.String("driver", "test-driver.tailscale:8008", "address of the test driver; by default we use the DNS name test-driver.tailscale which is special cased in the emulated network's DNS server")
|
||||
)
|
||||
|
||||
func absify(cmd string) string {
|
||||
if distro.Get() == distro.Gokrazy && !strings.Contains(cmd, "/") {
|
||||
return "/user/" + cmd
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func serveCmd(w http.ResponseWriter, cmd string, args ...string) {
|
||||
log.Printf("Got serveCmd for %q %v", cmd, args)
|
||||
out, err := exec.Command(absify(cmd), args...).CombinedOutput()
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if err != nil {
|
||||
w.Header().Set("Exec-Err", err.Error())
|
||||
w.WriteHeader(500)
|
||||
log.Printf("Err on serveCmd for %q %v, %d bytes of output: %v", cmd, args, len(out), err)
|
||||
} else {
|
||||
log.Printf("Did serveCmd for %q %v, %d bytes of output", cmd, args, len(out))
|
||||
}
|
||||
w.Write(out)
|
||||
}
|
||||
|
||||
type localClientRoundTripper struct {
|
||||
lc tailscale.LocalClient
|
||||
}
|
||||
|
||||
func (rt *localClientRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.RequestURI = ""
|
||||
return rt.lc.DoLocalRequest(req)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if distro.Get() == distro.Gokrazy {
|
||||
if !hostinfo.IsNATLabGuestVM() {
|
||||
// "Exiting immediately with status code 0 when the
|
||||
// GOKRAZY_FIRST_START=1 environment variable is set means “don’t
|
||||
// start the program on boot”"
|
||||
return
|
||||
}
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
if distro.Get() == distro.Gokrazy {
|
||||
nsRx := regexp.MustCompile(`(?m)^nameserver (.*)`)
|
||||
for t := time.Now(); time.Since(t) < 10*time.Second; time.Sleep(10 * time.Millisecond) {
|
||||
all, _ := os.ReadFile("/etc/resolv.conf")
|
||||
if nsRx.Match(all) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logc, err := net.Dial("tcp", "9.9.9.9:124")
|
||||
if err == nil {
|
||||
log.SetOutput(logc)
|
||||
}
|
||||
|
||||
log.Printf("Tailscale Test Agent running.")
|
||||
|
||||
gokRP := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://gokrazy")))
|
||||
gokRP.Transport = &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if network != "tcp" {
|
||||
return nil, errors.New("unexpected network")
|
||||
}
|
||||
if addr != "gokrazy:80" {
|
||||
return nil, errors.New("unexpected addr")
|
||||
}
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", "/run/gokrazy-http.sock")
|
||||
},
|
||||
}
|
||||
|
||||
var ttaMux http.ServeMux // agent mux
|
||||
var serveMux http.ServeMux
|
||||
serveMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-TTA-GoKrazy") == "1" {
|
||||
gokRP.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ttaMux.ServeHTTP(w, r)
|
||||
})
|
||||
var hs http.Server
|
||||
hs.Handler = &serveMux
|
||||
var (
|
||||
stMu sync.Mutex
|
||||
newSet = set.Set[net.Conn]{} // conns in StateNew
|
||||
)
|
||||
needConnCh := make(chan bool, 1)
|
||||
hs.ConnState = func(c net.Conn, s http.ConnState) {
|
||||
stMu.Lock()
|
||||
defer stMu.Unlock()
|
||||
oldLen := len(newSet)
|
||||
switch s {
|
||||
case http.StateNew:
|
||||
newSet.Add(c)
|
||||
default:
|
||||
newSet.Delete(c)
|
||||
}
|
||||
if oldLen != 0 && len(newSet) == 0 {
|
||||
select {
|
||||
case needConnCh <- true:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
conns := make(chan net.Conn, 1)
|
||||
|
||||
lcRP := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://local-tailscaled.sock")))
|
||||
lcRP.Transport = new(localClientRoundTripper)
|
||||
ttaMux.HandleFunc("/localapi/", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Got localapi request: %v", r.URL)
|
||||
t0 := time.Now()
|
||||
lcRP.ServeHTTP(w, r)
|
||||
log.Printf("Did localapi request in %v: %v", time.Since(t0).Round(time.Millisecond), r.URL)
|
||||
})
|
||||
|
||||
ttaMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "TTA\n")
|
||||
return
|
||||
})
|
||||
ttaMux.HandleFunc("/up", func(w http.ResponseWriter, r *http.Request) {
|
||||
serveCmd(w, "tailscale", "up", "--login-server=http://control.tailscale")
|
||||
})
|
||||
ttaMux.HandleFunc("/fw", addFirewallHandler)
|
||||
|
||||
go hs.Serve(chanListener(conns))
|
||||
|
||||
// For doing agent operations locally from gokrazy:
|
||||
// (e.g. with "wget -O - localhost:8123/fw")
|
||||
go func() {
|
||||
err := http.ListenAndServe("127.0.0.1:8123", &ttaMux)
|
||||
if err != nil {
|
||||
log.Fatalf("ListenAndServe: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var lastErr string
|
||||
needConnCh <- true
|
||||
for {
|
||||
<-needConnCh
|
||||
c, err := connect()
|
||||
if err != nil {
|
||||
s := err.Error()
|
||||
if s != lastErr {
|
||||
log.Printf("Connect failure: %v", s)
|
||||
}
|
||||
lastErr = s
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
conns <- c
|
||||
}
|
||||
}
|
||||
|
||||
func connect() (net.Conn, error) {
|
||||
c, err := net.Dial("tcp", *driverAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type chanListener <-chan net.Conn
|
||||
|
||||
func (cl chanListener) Accept() (net.Conn, error) {
|
||||
c, ok := <-cl
|
||||
if !ok {
|
||||
return nil, errors.New("closed")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (cl chanListener) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cl chanListener) Addr() net.Addr {
|
||||
return &net.TCPAddr{
|
||||
IP: net.ParseIP("52.0.0.34"), // TS..DR(iver)
|
||||
Port: 123,
|
||||
}
|
||||
}
|
||||
|
||||
func addFirewallHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if addFirewall == nil {
|
||||
http.Error(w, "firewall not supported", 500)
|
||||
return
|
||||
}
|
||||
err := addFirewall()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
io.WriteString(w, "OK\n")
|
||||
}
|
||||
|
||||
var addFirewall func() error // set by fw_linux.go
|
||||
@@ -7,9 +7,13 @@ package tests
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
"golang.org/x/exp/constraints"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
//go:generate go run tailscale.com/cmd/viewer --type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded --clone-only-type=OnlyGetClone
|
||||
//go:generate go run tailscale.com/cmd/viewer --type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded,GenericIntStruct,GenericNoPtrsStruct,GenericCloneableStruct,StructWithContainers --clone-only-type=OnlyGetClone
|
||||
|
||||
type StructWithoutPtrs struct {
|
||||
Int int
|
||||
@@ -25,12 +29,12 @@ type Map struct {
|
||||
SlicesWithPtrs map[string][]*StructWithPtrs
|
||||
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
|
||||
StructWithoutPtrKey map[StructWithoutPtrs]int `json:"-"`
|
||||
StructWithPtr map[string]StructWithPtrs
|
||||
|
||||
// Unsupported views.
|
||||
SliceIntPtr map[string][]*int
|
||||
PointerKey map[*string]int `json:"-"`
|
||||
StructWithPtrKey map[StructWithPtrs]int `json:"-"`
|
||||
StructWithPtr map[string]StructWithPtrs
|
||||
}
|
||||
|
||||
type StructWithPtrs struct {
|
||||
@@ -50,12 +54,14 @@ type StructWithSlices struct {
|
||||
Values []StructWithoutPtrs
|
||||
ValuePointers []*StructWithoutPtrs
|
||||
StructPointers []*StructWithPtrs
|
||||
Structs []StructWithPtrs
|
||||
Ints []*int
|
||||
|
||||
Slice []string
|
||||
Prefixes []netip.Prefix
|
||||
Data []byte
|
||||
|
||||
// Unsupported views.
|
||||
Structs []StructWithPtrs
|
||||
Ints []*int
|
||||
}
|
||||
|
||||
type OnlyGetClone struct {
|
||||
@@ -66,3 +72,133 @@ type StructWithEmbedded struct {
|
||||
A *StructWithPtrs
|
||||
StructWithSlices
|
||||
}
|
||||
|
||||
type GenericIntStruct[T constraints.Integer] struct {
|
||||
Value T
|
||||
Pointer *T
|
||||
Slice []T
|
||||
Map map[string]T
|
||||
|
||||
// Unsupported views.
|
||||
PtrSlice []*T
|
||||
PtrKeyMap map[*T]string `json:"-"`
|
||||
PtrValueMap map[string]*T
|
||||
SliceMap map[string][]T
|
||||
}
|
||||
|
||||
type BasicType interface {
|
||||
~bool | constraints.Integer | constraints.Float | constraints.Complex | ~string
|
||||
}
|
||||
|
||||
type GenericNoPtrsStruct[T StructWithoutPtrs | netip.Prefix | BasicType] struct {
|
||||
Value T
|
||||
Pointer *T
|
||||
Slice []T
|
||||
Map map[string]T
|
||||
|
||||
// Unsupported views.
|
||||
PtrSlice []*T
|
||||
PtrKeyMap map[*T]string `json:"-"`
|
||||
PtrValueMap map[string]*T
|
||||
SliceMap map[string][]T
|
||||
}
|
||||
|
||||
type GenericCloneableStruct[T views.ViewCloner[T, V], V views.StructView[T]] struct {
|
||||
Value T
|
||||
Slice []T
|
||||
Map map[string]T
|
||||
|
||||
// Unsupported views.
|
||||
Pointer *T
|
||||
PtrSlice []*T
|
||||
PtrKeyMap map[*T]string `json:"-"`
|
||||
PtrValueMap map[string]*T
|
||||
SliceMap map[string][]T
|
||||
}
|
||||
|
||||
// Container is a pre-defined container type, such as a collection, an optional
|
||||
// value or a generic wrapper.
|
||||
type Container[T any] struct {
|
||||
Item T
|
||||
}
|
||||
|
||||
func (c *Container[T]) Clone() *Container[T] {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if cloner, ok := any(c.Item).(views.Cloner[T]); ok {
|
||||
return &Container[T]{cloner.Clone()}
|
||||
}
|
||||
if !views.ContainsPointers[T]() {
|
||||
return ptr.To(*c)
|
||||
}
|
||||
panic(fmt.Errorf("%T contains pointers, but is not cloneable", c.Item))
|
||||
}
|
||||
|
||||
// ContainerView is a pre-defined readonly view of a Container[T].
|
||||
type ContainerView[T views.ViewCloner[T, V], V views.StructView[T]] struct {
|
||||
// ж is the underlying mutable value, named with a hard-to-type
|
||||
// character that looks pointy like a pointer.
|
||||
// It is named distinctively to make you think of how dangerous it is to escape
|
||||
// to callers. You must not let callers be able to mutate it.
|
||||
ж *Container[T]
|
||||
}
|
||||
|
||||
func (cv ContainerView[T, V]) Item() V {
|
||||
return cv.ж.Item.View()
|
||||
}
|
||||
|
||||
func ContainerViewOf[T views.ViewCloner[T, V], V views.StructView[T]](c *Container[T]) ContainerView[T, V] {
|
||||
return ContainerView[T, V]{c}
|
||||
}
|
||||
|
||||
// MapContainer is a predefined map-like container type.
|
||||
// Unlike [Container], it has two type parameters, where the value
|
||||
// is the second parameter.
|
||||
type MapContainer[K comparable, V views.Cloner[V]] struct {
|
||||
Items map[K]V
|
||||
}
|
||||
|
||||
func (c *MapContainer[K, V]) Clone() *MapContainer[K, V] {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
var m map[K]V
|
||||
if c.Items != nil {
|
||||
m = make(map[K]V, len(c.Items))
|
||||
for i := range m {
|
||||
m[i] = c.Items[i].Clone()
|
||||
}
|
||||
}
|
||||
return &MapContainer[K, V]{m}
|
||||
}
|
||||
|
||||
// MapContainerView is a pre-defined readonly view of a [MapContainer][K, T].
|
||||
type MapContainerView[K comparable, T views.ViewCloner[T, V], V views.StructView[T]] struct {
|
||||
// ж is the underlying mutable value, named with a hard-to-type
|
||||
// character that looks pointy like a pointer.
|
||||
// It is named distinctively to make you think of how dangerous it is to escape
|
||||
// to callers. You must not let callers be able to mutate it.
|
||||
ж *MapContainer[K, T]
|
||||
}
|
||||
|
||||
func (cv MapContainerView[K, T, V]) Items() views.MapFn[K, T, V] {
|
||||
return views.MapFnOf(cv.ж.Items, func(t T) V { return t.View() })
|
||||
}
|
||||
|
||||
func MapContainerViewOf[K comparable, T views.ViewCloner[T, V], V views.StructView[T]](c *MapContainer[K, T]) MapContainerView[K, T, V] {
|
||||
return MapContainerView[K, T, V]{c}
|
||||
}
|
||||
|
||||
type GenericBasicStruct[T BasicType] struct {
|
||||
Value T
|
||||
}
|
||||
|
||||
type StructWithContainers struct {
|
||||
IntContainer Container[int]
|
||||
CloneableContainer Container[*StructWithPtrs]
|
||||
BasicGenericContainer Container[GenericBasicStruct[int]]
|
||||
CloneableGenericContainer Container[*GenericNoPtrsStruct[int]]
|
||||
CloneableMap MapContainer[int, *StructWithPtrs]
|
||||
CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"maps"
|
||||
"net/netip"
|
||||
|
||||
"golang.org/x/exp/constraints"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// Clone makes a deep copy of StructWithPtrs.
|
||||
@@ -71,13 +73,21 @@ func (src *Map) Clone() *Map {
|
||||
if dst.StructPtrWithPtr != nil {
|
||||
dst.StructPtrWithPtr = map[string]*StructWithPtrs{}
|
||||
for k, v := range src.StructPtrWithPtr {
|
||||
dst.StructPtrWithPtr[k] = v.Clone()
|
||||
if v == nil {
|
||||
dst.StructPtrWithPtr[k] = nil
|
||||
} else {
|
||||
dst.StructPtrWithPtr[k] = v.Clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
if dst.StructPtrWithoutPtr != nil {
|
||||
dst.StructPtrWithoutPtr = map[string]*StructWithoutPtrs{}
|
||||
for k, v := range src.StructPtrWithoutPtr {
|
||||
dst.StructPtrWithoutPtr[k] = v.Clone()
|
||||
if v == nil {
|
||||
dst.StructPtrWithoutPtr[k] = nil
|
||||
} else {
|
||||
dst.StructPtrWithoutPtr[k] = ptr.To(*v)
|
||||
}
|
||||
}
|
||||
}
|
||||
dst.StructWithoutPtr = maps.Clone(src.StructWithoutPtr)
|
||||
@@ -94,6 +104,12 @@ func (src *Map) Clone() *Map {
|
||||
}
|
||||
}
|
||||
dst.StructWithoutPtrKey = maps.Clone(src.StructWithoutPtrKey)
|
||||
if dst.StructWithPtr != nil {
|
||||
dst.StructWithPtr = map[string]StructWithPtrs{}
|
||||
for k, v := range src.StructWithPtr {
|
||||
dst.StructWithPtr[k] = *(v.Clone())
|
||||
}
|
||||
}
|
||||
if dst.SliceIntPtr != nil {
|
||||
dst.SliceIntPtr = map[string][]*int{}
|
||||
for k := range src.SliceIntPtr {
|
||||
@@ -102,12 +118,6 @@ func (src *Map) Clone() *Map {
|
||||
}
|
||||
dst.PointerKey = maps.Clone(src.PointerKey)
|
||||
dst.StructWithPtrKey = maps.Clone(src.StructWithPtrKey)
|
||||
if dst.StructWithPtr != nil {
|
||||
dst.StructWithPtr = map[string]StructWithPtrs{}
|
||||
for k, v := range src.StructWithPtr {
|
||||
dst.StructWithPtr[k] = *(v.Clone())
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
@@ -121,10 +131,10 @@ var _MapCloneNeedsRegeneration = Map(struct {
|
||||
SlicesWithPtrs map[string][]*StructWithPtrs
|
||||
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
|
||||
StructWithoutPtrKey map[StructWithoutPtrs]int
|
||||
StructWithPtr map[string]StructWithPtrs
|
||||
SliceIntPtr map[string][]*int
|
||||
PointerKey map[*string]int
|
||||
StructWithPtrKey map[StructWithPtrs]int
|
||||
StructWithPtr map[string]StructWithPtrs
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of StructWithSlices.
|
||||
@@ -139,15 +149,26 @@ func (src *StructWithSlices) Clone() *StructWithSlices {
|
||||
if src.ValuePointers != nil {
|
||||
dst.ValuePointers = make([]*StructWithoutPtrs, len(src.ValuePointers))
|
||||
for i := range dst.ValuePointers {
|
||||
dst.ValuePointers[i] = src.ValuePointers[i].Clone()
|
||||
if src.ValuePointers[i] == nil {
|
||||
dst.ValuePointers[i] = nil
|
||||
} else {
|
||||
dst.ValuePointers[i] = ptr.To(*src.ValuePointers[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if src.StructPointers != nil {
|
||||
dst.StructPointers = make([]*StructWithPtrs, len(src.StructPointers))
|
||||
for i := range dst.StructPointers {
|
||||
dst.StructPointers[i] = src.StructPointers[i].Clone()
|
||||
if src.StructPointers[i] == nil {
|
||||
dst.StructPointers[i] = nil
|
||||
} else {
|
||||
dst.StructPointers[i] = src.StructPointers[i].Clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
dst.Slice = append(src.Slice[:0:0], src.Slice...)
|
||||
dst.Prefixes = append(src.Prefixes[:0:0], src.Prefixes...)
|
||||
dst.Data = append(src.Data[:0:0], src.Data...)
|
||||
if src.Structs != nil {
|
||||
dst.Structs = make([]StructWithPtrs, len(src.Structs))
|
||||
for i := range dst.Structs {
|
||||
@@ -164,9 +185,6 @@ func (src *StructWithSlices) Clone() *StructWithSlices {
|
||||
}
|
||||
}
|
||||
}
|
||||
dst.Slice = append(src.Slice[:0:0], src.Slice...)
|
||||
dst.Prefixes = append(src.Prefixes[:0:0], src.Prefixes...)
|
||||
dst.Data = append(src.Data[:0:0], src.Data...)
|
||||
return dst
|
||||
}
|
||||
|
||||
@@ -175,11 +193,11 @@ var _StructWithSlicesCloneNeedsRegeneration = StructWithSlices(struct {
|
||||
Values []StructWithoutPtrs
|
||||
ValuePointers []*StructWithoutPtrs
|
||||
StructPointers []*StructWithPtrs
|
||||
Structs []StructWithPtrs
|
||||
Ints []*int
|
||||
Slice []string
|
||||
Prefixes []netip.Prefix
|
||||
Data []byte
|
||||
Structs []StructWithPtrs
|
||||
Ints []*int
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of OnlyGetClone.
|
||||
@@ -216,3 +234,210 @@ var _StructWithEmbeddedCloneNeedsRegeneration = StructWithEmbedded(struct {
|
||||
A *StructWithPtrs
|
||||
StructWithSlices
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of GenericIntStruct.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *GenericIntStruct[T]) Clone() *GenericIntStruct[T] {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(GenericIntStruct[T])
|
||||
*dst = *src
|
||||
if dst.Pointer != nil {
|
||||
dst.Pointer = ptr.To(*src.Pointer)
|
||||
}
|
||||
dst.Slice = append(src.Slice[:0:0], src.Slice...)
|
||||
dst.Map = maps.Clone(src.Map)
|
||||
if src.PtrSlice != nil {
|
||||
dst.PtrSlice = make([]*T, len(src.PtrSlice))
|
||||
for i := range dst.PtrSlice {
|
||||
if src.PtrSlice[i] == nil {
|
||||
dst.PtrSlice[i] = nil
|
||||
} else {
|
||||
dst.PtrSlice[i] = ptr.To(*src.PtrSlice[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
dst.PtrKeyMap = maps.Clone(src.PtrKeyMap)
|
||||
if dst.PtrValueMap != nil {
|
||||
dst.PtrValueMap = map[string]*T{}
|
||||
for k, v := range src.PtrValueMap {
|
||||
if v == nil {
|
||||
dst.PtrValueMap[k] = nil
|
||||
} else {
|
||||
dst.PtrValueMap[k] = ptr.To(*v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if dst.SliceMap != nil {
|
||||
dst.SliceMap = map[string][]T{}
|
||||
for k := range src.SliceMap {
|
||||
dst.SliceMap[k] = append([]T{}, src.SliceMap[k]...)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
func _GenericIntStructCloneNeedsRegeneration[T constraints.Integer](GenericIntStruct[T]) {
|
||||
_GenericIntStructCloneNeedsRegeneration(struct {
|
||||
Value T
|
||||
Pointer *T
|
||||
Slice []T
|
||||
Map map[string]T
|
||||
PtrSlice []*T
|
||||
PtrKeyMap map[*T]string `json:"-"`
|
||||
PtrValueMap map[string]*T
|
||||
SliceMap map[string][]T
|
||||
}{})
|
||||
}
|
||||
|
||||
// Clone makes a deep copy of GenericNoPtrsStruct.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *GenericNoPtrsStruct[T]) Clone() *GenericNoPtrsStruct[T] {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(GenericNoPtrsStruct[T])
|
||||
*dst = *src
|
||||
if dst.Pointer != nil {
|
||||
dst.Pointer = ptr.To(*src.Pointer)
|
||||
}
|
||||
dst.Slice = append(src.Slice[:0:0], src.Slice...)
|
||||
dst.Map = maps.Clone(src.Map)
|
||||
if src.PtrSlice != nil {
|
||||
dst.PtrSlice = make([]*T, len(src.PtrSlice))
|
||||
for i := range dst.PtrSlice {
|
||||
if src.PtrSlice[i] == nil {
|
||||
dst.PtrSlice[i] = nil
|
||||
} else {
|
||||
dst.PtrSlice[i] = ptr.To(*src.PtrSlice[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
dst.PtrKeyMap = maps.Clone(src.PtrKeyMap)
|
||||
if dst.PtrValueMap != nil {
|
||||
dst.PtrValueMap = map[string]*T{}
|
||||
for k, v := range src.PtrValueMap {
|
||||
if v == nil {
|
||||
dst.PtrValueMap[k] = nil
|
||||
} else {
|
||||
dst.PtrValueMap[k] = ptr.To(*v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if dst.SliceMap != nil {
|
||||
dst.SliceMap = map[string][]T{}
|
||||
for k := range src.SliceMap {
|
||||
dst.SliceMap[k] = append([]T{}, src.SliceMap[k]...)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
func _GenericNoPtrsStructCloneNeedsRegeneration[T StructWithoutPtrs | netip.Prefix | BasicType](GenericNoPtrsStruct[T]) {
|
||||
_GenericNoPtrsStructCloneNeedsRegeneration(struct {
|
||||
Value T
|
||||
Pointer *T
|
||||
Slice []T
|
||||
Map map[string]T
|
||||
PtrSlice []*T
|
||||
PtrKeyMap map[*T]string `json:"-"`
|
||||
PtrValueMap map[string]*T
|
||||
SliceMap map[string][]T
|
||||
}{})
|
||||
}
|
||||
|
||||
// Clone makes a deep copy of GenericCloneableStruct.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *GenericCloneableStruct[T, V]) Clone() *GenericCloneableStruct[T, V] {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(GenericCloneableStruct[T, V])
|
||||
*dst = *src
|
||||
dst.Value = src.Value.Clone()
|
||||
if src.Slice != nil {
|
||||
dst.Slice = make([]T, len(src.Slice))
|
||||
for i := range dst.Slice {
|
||||
dst.Slice[i] = src.Slice[i].Clone()
|
||||
}
|
||||
}
|
||||
if dst.Map != nil {
|
||||
dst.Map = map[string]T{}
|
||||
for k, v := range src.Map {
|
||||
dst.Map[k] = v.Clone()
|
||||
}
|
||||
}
|
||||
if dst.Pointer != nil {
|
||||
dst.Pointer = ptr.To((*src.Pointer).Clone())
|
||||
}
|
||||
if src.PtrSlice != nil {
|
||||
dst.PtrSlice = make([]*T, len(src.PtrSlice))
|
||||
for i := range dst.PtrSlice {
|
||||
if src.PtrSlice[i] == nil {
|
||||
dst.PtrSlice[i] = nil
|
||||
} else {
|
||||
dst.PtrSlice[i] = ptr.To((*src.PtrSlice[i]).Clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
dst.PtrKeyMap = maps.Clone(src.PtrKeyMap)
|
||||
if dst.PtrValueMap != nil {
|
||||
dst.PtrValueMap = map[string]*T{}
|
||||
for k, v := range src.PtrValueMap {
|
||||
if v == nil {
|
||||
dst.PtrValueMap[k] = nil
|
||||
} else {
|
||||
dst.PtrValueMap[k] = ptr.To((*v).Clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
if dst.SliceMap != nil {
|
||||
dst.SliceMap = map[string][]T{}
|
||||
for k := range src.SliceMap {
|
||||
dst.SliceMap[k] = append([]T{}, src.SliceMap[k]...)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
func _GenericCloneableStructCloneNeedsRegeneration[T views.ViewCloner[T, V], V views.StructView[T]](GenericCloneableStruct[T, V]) {
|
||||
_GenericCloneableStructCloneNeedsRegeneration(struct {
|
||||
Value T
|
||||
Slice []T
|
||||
Map map[string]T
|
||||
Pointer *T
|
||||
PtrSlice []*T
|
||||
PtrKeyMap map[*T]string `json:"-"`
|
||||
PtrValueMap map[string]*T
|
||||
SliceMap map[string][]T
|
||||
}{})
|
||||
}
|
||||
|
||||
// Clone makes a deep copy of StructWithContainers.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *StructWithContainers) Clone() *StructWithContainers {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(StructWithContainers)
|
||||
*dst = *src
|
||||
dst.CloneableContainer = *src.CloneableContainer.Clone()
|
||||
dst.CloneableGenericContainer = *src.CloneableGenericContainer.Clone()
|
||||
dst.CloneableMap = *src.CloneableMap.Clone()
|
||||
dst.CloneableGenericMap = *src.CloneableGenericMap.Clone()
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _StructWithContainersCloneNeedsRegeneration = StructWithContainers(struct {
|
||||
IntContainer Container[int]
|
||||
CloneableContainer Container[*StructWithPtrs]
|
||||
BasicGenericContainer Container[GenericBasicStruct[int]]
|
||||
CloneableGenericContainer Container[*GenericNoPtrsStruct[int]]
|
||||
CloneableMap MapContainer[int, *StructWithPtrs]
|
||||
CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]]
|
||||
}{})
|
||||
|
||||
@@ -10,10 +10,11 @@ import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
|
||||
"golang.org/x/exp/constraints"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices,OnlyGetClone,StructWithEmbedded,GenericIntStruct,GenericNoPtrsStruct,GenericCloneableStruct,StructWithContainers
|
||||
|
||||
// View returns a readonly view of StructWithPtrs.
|
||||
func (p *StructWithPtrs) View() StructWithPtrsView {
|
||||
@@ -188,11 +189,7 @@ func (v *MapView) UnmarshalJSON(b []byte) error {
|
||||
|
||||
func (v MapView) Int() views.Map[string, int] { return views.MapOf(v.ж.Int) }
|
||||
|
||||
func (v MapView) SliceInt() views.MapFn[string, []int, views.Slice[int]] {
|
||||
return views.MapFnOf(v.ж.SliceInt, func(t []int) views.Slice[int] {
|
||||
return views.SliceOf(t)
|
||||
})
|
||||
}
|
||||
func (v MapView) SliceInt() views.MapSlice[string, int] { return views.MapSliceOf(v.ж.SliceInt) }
|
||||
|
||||
func (v MapView) StructPtrWithPtr() views.MapFn[string, *StructWithPtrs, StructWithPtrsView] {
|
||||
return views.MapFnOf(v.ж.StructPtrWithPtr, func(t *StructWithPtrs) StructWithPtrsView {
|
||||
@@ -225,15 +222,15 @@ func (v MapView) SlicesWithoutPtrs() views.MapFn[string, []*StructWithoutPtrs, v
|
||||
func (v MapView) StructWithoutPtrKey() views.Map[StructWithoutPtrs, int] {
|
||||
return views.MapOf(v.ж.StructWithoutPtrKey)
|
||||
}
|
||||
func (v MapView) SliceIntPtr() map[string][]*int { panic("unsupported") }
|
||||
func (v MapView) PointerKey() map[*string]int { panic("unsupported") }
|
||||
func (v MapView) StructWithPtrKey() map[StructWithPtrs]int { panic("unsupported") }
|
||||
|
||||
func (v MapView) StructWithPtr() views.MapFn[string, StructWithPtrs, StructWithPtrsView] {
|
||||
return views.MapFnOf(v.ж.StructWithPtr, func(t StructWithPtrs) StructWithPtrsView {
|
||||
return t.View()
|
||||
})
|
||||
}
|
||||
func (v MapView) SliceIntPtr() map[string][]*int { panic("unsupported") }
|
||||
func (v MapView) PointerKey() map[*string]int { panic("unsupported") }
|
||||
func (v MapView) StructWithPtrKey() map[StructWithPtrs]int { panic("unsupported") }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _MapViewNeedsRegeneration = Map(struct {
|
||||
@@ -245,10 +242,10 @@ var _MapViewNeedsRegeneration = Map(struct {
|
||||
SlicesWithPtrs map[string][]*StructWithPtrs
|
||||
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
|
||||
StructWithoutPtrKey map[StructWithoutPtrs]int
|
||||
StructWithPtr map[string]StructWithPtrs
|
||||
SliceIntPtr map[string][]*int
|
||||
PointerKey map[*string]int
|
||||
StructWithPtrKey map[StructWithPtrs]int
|
||||
StructWithPtr map[string]StructWithPtrs
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of StructWithSlices.
|
||||
@@ -305,24 +302,24 @@ func (v StructWithSlicesView) ValuePointers() views.SliceView[*StructWithoutPtrs
|
||||
func (v StructWithSlicesView) StructPointers() views.SliceView[*StructWithPtrs, StructWithPtrsView] {
|
||||
return views.SliceOfViews[*StructWithPtrs, StructWithPtrsView](v.ж.StructPointers)
|
||||
}
|
||||
func (v StructWithSlicesView) Structs() StructWithPtrs { panic("unsupported") }
|
||||
func (v StructWithSlicesView) Ints() *int { panic("unsupported") }
|
||||
func (v StructWithSlicesView) Slice() views.Slice[string] { return views.SliceOf(v.ж.Slice) }
|
||||
func (v StructWithSlicesView) Prefixes() views.Slice[netip.Prefix] {
|
||||
return views.SliceOf(v.ж.Prefixes)
|
||||
}
|
||||
func (v StructWithSlicesView) Data() views.ByteSlice[[]byte] { return views.ByteSliceOf(v.ж.Data) }
|
||||
func (v StructWithSlicesView) Structs() StructWithPtrs { panic("unsupported") }
|
||||
func (v StructWithSlicesView) Ints() *int { panic("unsupported") }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _StructWithSlicesViewNeedsRegeneration = StructWithSlices(struct {
|
||||
Values []StructWithoutPtrs
|
||||
ValuePointers []*StructWithoutPtrs
|
||||
StructPointers []*StructWithPtrs
|
||||
Structs []StructWithPtrs
|
||||
Ints []*int
|
||||
Slice []string
|
||||
Prefixes []netip.Prefix
|
||||
Data []byte
|
||||
Structs []StructWithPtrs
|
||||
Ints []*int
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of StructWithEmbedded.
|
||||
@@ -380,3 +377,302 @@ var _StructWithEmbeddedViewNeedsRegeneration = StructWithEmbedded(struct {
|
||||
A *StructWithPtrs
|
||||
StructWithSlices
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of GenericIntStruct.
|
||||
func (p *GenericIntStruct[T]) View() GenericIntStructView[T] {
|
||||
return GenericIntStructView[T]{ж: p}
|
||||
}
|
||||
|
||||
// GenericIntStructView[T] provides a read-only view over GenericIntStruct[T].
|
||||
//
|
||||
// Its methods should only be called if `Valid()` returns true.
|
||||
type GenericIntStructView[T constraints.Integer] struct {
|
||||
// ж is the underlying mutable value, named with a hard-to-type
|
||||
// character that looks pointy like a pointer.
|
||||
// It is named distinctively to make you think of how dangerous it is to escape
|
||||
// to callers. You must not let callers be able to mutate it.
|
||||
ж *GenericIntStruct[T]
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v GenericIntStructView[T]) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v GenericIntStructView[T]) AsStruct() *GenericIntStruct[T] {
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v GenericIntStructView[T]) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *GenericIntStructView[T]) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x GenericIntStruct[T]
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж = &x
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v GenericIntStructView[T]) Value() T { return v.ж.Value }
|
||||
func (v GenericIntStructView[T]) Pointer() *T {
|
||||
if v.ж.Pointer == nil {
|
||||
return nil
|
||||
}
|
||||
x := *v.ж.Pointer
|
||||
return &x
|
||||
}
|
||||
|
||||
func (v GenericIntStructView[T]) Slice() views.Slice[T] { return views.SliceOf(v.ж.Slice) }
|
||||
|
||||
func (v GenericIntStructView[T]) Map() views.Map[string, T] { return views.MapOf(v.ж.Map) }
|
||||
func (v GenericIntStructView[T]) PtrSlice() *T { panic("unsupported") }
|
||||
func (v GenericIntStructView[T]) PtrKeyMap() map[*T]string { panic("unsupported") }
|
||||
func (v GenericIntStructView[T]) PtrValueMap() map[string]*T { panic("unsupported") }
|
||||
func (v GenericIntStructView[T]) SliceMap() map[string][]T { panic("unsupported") }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
func _GenericIntStructViewNeedsRegeneration[T constraints.Integer](GenericIntStruct[T]) {
|
||||
_GenericIntStructViewNeedsRegeneration(struct {
|
||||
Value T
|
||||
Pointer *T
|
||||
Slice []T
|
||||
Map map[string]T
|
||||
PtrSlice []*T
|
||||
PtrKeyMap map[*T]string `json:"-"`
|
||||
PtrValueMap map[string]*T
|
||||
SliceMap map[string][]T
|
||||
}{})
|
||||
}
|
||||
|
||||
// View returns a readonly view of GenericNoPtrsStruct.
|
||||
func (p *GenericNoPtrsStruct[T]) View() GenericNoPtrsStructView[T] {
|
||||
return GenericNoPtrsStructView[T]{ж: p}
|
||||
}
|
||||
|
||||
// GenericNoPtrsStructView[T] provides a read-only view over GenericNoPtrsStruct[T].
|
||||
//
|
||||
// Its methods should only be called if `Valid()` returns true.
|
||||
type GenericNoPtrsStructView[T StructWithoutPtrs | netip.Prefix | BasicType] struct {
|
||||
// ж is the underlying mutable value, named with a hard-to-type
|
||||
// character that looks pointy like a pointer.
|
||||
// It is named distinctively to make you think of how dangerous it is to escape
|
||||
// to callers. You must not let callers be able to mutate it.
|
||||
ж *GenericNoPtrsStruct[T]
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v GenericNoPtrsStructView[T]) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v GenericNoPtrsStructView[T]) AsStruct() *GenericNoPtrsStruct[T] {
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v GenericNoPtrsStructView[T]) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *GenericNoPtrsStructView[T]) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x GenericNoPtrsStruct[T]
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж = &x
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v GenericNoPtrsStructView[T]) Value() T { return v.ж.Value }
|
||||
func (v GenericNoPtrsStructView[T]) Pointer() *T {
|
||||
if v.ж.Pointer == nil {
|
||||
return nil
|
||||
}
|
||||
x := *v.ж.Pointer
|
||||
return &x
|
||||
}
|
||||
|
||||
func (v GenericNoPtrsStructView[T]) Slice() views.Slice[T] { return views.SliceOf(v.ж.Slice) }
|
||||
|
||||
func (v GenericNoPtrsStructView[T]) Map() views.Map[string, T] { return views.MapOf(v.ж.Map) }
|
||||
func (v GenericNoPtrsStructView[T]) PtrSlice() *T { panic("unsupported") }
|
||||
func (v GenericNoPtrsStructView[T]) PtrKeyMap() map[*T]string { panic("unsupported") }
|
||||
func (v GenericNoPtrsStructView[T]) PtrValueMap() map[string]*T { panic("unsupported") }
|
||||
func (v GenericNoPtrsStructView[T]) SliceMap() map[string][]T { panic("unsupported") }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
func _GenericNoPtrsStructViewNeedsRegeneration[T StructWithoutPtrs | netip.Prefix | BasicType](GenericNoPtrsStruct[T]) {
|
||||
_GenericNoPtrsStructViewNeedsRegeneration(struct {
|
||||
Value T
|
||||
Pointer *T
|
||||
Slice []T
|
||||
Map map[string]T
|
||||
PtrSlice []*T
|
||||
PtrKeyMap map[*T]string `json:"-"`
|
||||
PtrValueMap map[string]*T
|
||||
SliceMap map[string][]T
|
||||
}{})
|
||||
}
|
||||
|
||||
// View returns a readonly view of GenericCloneableStruct.
|
||||
func (p *GenericCloneableStruct[T, V]) View() GenericCloneableStructView[T, V] {
|
||||
return GenericCloneableStructView[T, V]{ж: p}
|
||||
}
|
||||
|
||||
// GenericCloneableStructView[T, V] provides a read-only view over GenericCloneableStruct[T, V].
|
||||
//
|
||||
// Its methods should only be called if `Valid()` returns true.
|
||||
type GenericCloneableStructView[T views.ViewCloner[T, V], V views.StructView[T]] struct {
|
||||
// ж is the underlying mutable value, named with a hard-to-type
|
||||
// character that looks pointy like a pointer.
|
||||
// It is named distinctively to make you think of how dangerous it is to escape
|
||||
// to callers. You must not let callers be able to mutate it.
|
||||
ж *GenericCloneableStruct[T, V]
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v GenericCloneableStructView[T, V]) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v GenericCloneableStructView[T, V]) AsStruct() *GenericCloneableStruct[T, V] {
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v GenericCloneableStructView[T, V]) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *GenericCloneableStructView[T, V]) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x GenericCloneableStruct[T, V]
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж = &x
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v GenericCloneableStructView[T, V]) Value() V { return v.ж.Value.View() }
|
||||
func (v GenericCloneableStructView[T, V]) Slice() views.SliceView[T, V] {
|
||||
return views.SliceOfViews[T, V](v.ж.Slice)
|
||||
}
|
||||
|
||||
func (v GenericCloneableStructView[T, V]) Map() views.MapFn[string, T, V] {
|
||||
return views.MapFnOf(v.ж.Map, func(t T) V {
|
||||
return t.View()
|
||||
})
|
||||
}
|
||||
func (v GenericCloneableStructView[T, V]) Pointer() map[string]T { panic("unsupported") }
|
||||
func (v GenericCloneableStructView[T, V]) PtrSlice() *T { panic("unsupported") }
|
||||
func (v GenericCloneableStructView[T, V]) PtrKeyMap() map[*T]string { panic("unsupported") }
|
||||
func (v GenericCloneableStructView[T, V]) PtrValueMap() map[string]*T { panic("unsupported") }
|
||||
func (v GenericCloneableStructView[T, V]) SliceMap() map[string][]T { panic("unsupported") }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
func _GenericCloneableStructViewNeedsRegeneration[T views.ViewCloner[T, V], V views.StructView[T]](GenericCloneableStruct[T, V]) {
|
||||
_GenericCloneableStructViewNeedsRegeneration(struct {
|
||||
Value T
|
||||
Slice []T
|
||||
Map map[string]T
|
||||
Pointer *T
|
||||
PtrSlice []*T
|
||||
PtrKeyMap map[*T]string `json:"-"`
|
||||
PtrValueMap map[string]*T
|
||||
SliceMap map[string][]T
|
||||
}{})
|
||||
}
|
||||
|
||||
// View returns a readonly view of StructWithContainers.
|
||||
func (p *StructWithContainers) View() StructWithContainersView {
|
||||
return StructWithContainersView{ж: p}
|
||||
}
|
||||
|
||||
// StructWithContainersView provides a read-only view over StructWithContainers.
|
||||
//
|
||||
// Its methods should only be called if `Valid()` returns true.
|
||||
type StructWithContainersView struct {
|
||||
// ж is the underlying mutable value, named with a hard-to-type
|
||||
// character that looks pointy like a pointer.
|
||||
// It is named distinctively to make you think of how dangerous it is to escape
|
||||
// to callers. You must not let callers be able to mutate it.
|
||||
ж *StructWithContainers
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v StructWithContainersView) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v StructWithContainersView) AsStruct() *StructWithContainers {
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v StructWithContainersView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *StructWithContainersView) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x StructWithContainers
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж = &x
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v StructWithContainersView) IntContainer() Container[int] { return v.ж.IntContainer }
|
||||
func (v StructWithContainersView) CloneableContainer() ContainerView[*StructWithPtrs, StructWithPtrsView] {
|
||||
return ContainerViewOf(&v.ж.CloneableContainer)
|
||||
}
|
||||
func (v StructWithContainersView) BasicGenericContainer() Container[GenericBasicStruct[int]] {
|
||||
return v.ж.BasicGenericContainer
|
||||
}
|
||||
func (v StructWithContainersView) CloneableGenericContainer() ContainerView[*GenericNoPtrsStruct[int], GenericNoPtrsStructView[int]] {
|
||||
return ContainerViewOf(&v.ж.CloneableGenericContainer)
|
||||
}
|
||||
func (v StructWithContainersView) CloneableMap() MapContainerView[int, *StructWithPtrs, StructWithPtrsView] {
|
||||
return MapContainerViewOf(&v.ж.CloneableMap)
|
||||
}
|
||||
func (v StructWithContainersView) CloneableGenericMap() MapContainerView[int, *GenericNoPtrsStruct[int], GenericNoPtrsStructView[int]] {
|
||||
return MapContainerViewOf(&v.ж.CloneableGenericMap)
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _StructWithContainersViewNeedsRegeneration = StructWithContainers(struct {
|
||||
IntContainer Container[int]
|
||||
CloneableContainer Container[*StructWithPtrs]
|
||||
BasicGenericContainer Container[GenericBasicStruct[int]]
|
||||
CloneableGenericContainer Container[*GenericNoPtrsStruct[int]]
|
||||
CloneableMap MapContainer[int, *StructWithPtrs]
|
||||
CloneableGenericMap MapContainer[int, *GenericNoPtrsStruct[int]]
|
||||
}{})
|
||||
|
||||
@@ -13,50 +13,52 @@ import (
|
||||
"html/template"
|
||||
"log"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/codegen"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
const viewTemplateStr = `{{define "common"}}
|
||||
// View returns a readonly view of {{.StructName}}.
|
||||
func (p *{{.StructName}}) View() {{.ViewName}} {
|
||||
return {{.ViewName}}{ж: p}
|
||||
func (p *{{.StructName}}{{.TypeParamNames}}) View() {{.ViewName}}{{.TypeParamNames}} {
|
||||
return {{.ViewName}}{{.TypeParamNames}}{ж: p}
|
||||
}
|
||||
|
||||
// {{.ViewName}} provides a read-only view over {{.StructName}}.
|
||||
// {{.ViewName}}{{.TypeParamNames}} provides a read-only view over {{.StructName}}{{.TypeParamNames}}.
|
||||
//
|
||||
// Its methods should only be called if ` + "`Valid()`" + ` returns true.
|
||||
type {{.ViewName}} struct {
|
||||
type {{.ViewName}}{{.TypeParams}} struct {
|
||||
// ж is the underlying mutable value, named with a hard-to-type
|
||||
// character that looks pointy like a pointer.
|
||||
// It is named distinctively to make you think of how dangerous it is to escape
|
||||
// to callers. You must not let callers be able to mutate it.
|
||||
ж *{{.StructName}}
|
||||
ж *{{.StructName}}{{.TypeParamNames}}
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v {{.ViewName}}) Valid() bool { return v.ж != nil }
|
||||
func (v {{.ViewName}}{{.TypeParamNames}}) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v {{.ViewName}}) AsStruct() *{{.StructName}}{
|
||||
func (v {{.ViewName}}{{.TypeParamNames}}) AsStruct() *{{.StructName}}{{.TypeParamNames}}{
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v {{.ViewName}}) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
func (v {{.ViewName}}{{.TypeParamNames}}) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *{{.ViewName}}) UnmarshalJSON(b []byte) error {
|
||||
func (v *{{.ViewName}}{{.TypeParamNames}}) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x {{.StructName}}
|
||||
var x {{.StructName}}{{.TypeParamNames}}
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -65,17 +67,19 @@ func (v *{{.ViewName}}) UnmarshalJSON(b []byte) error {
|
||||
}
|
||||
|
||||
{{end}}
|
||||
{{define "valueField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} { return v.ж.{{.FieldName}} }
|
||||
{{define "valueField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldType}} { return v.ж.{{.FieldName}} }
|
||||
{{end}}
|
||||
{{define "byteSliceField"}}func (v {{.ViewName}}) {{.FieldName}}() views.ByteSlice[{{.FieldType}}] { return views.ByteSliceOf(v.ж.{{.FieldName}}) }
|
||||
{{define "byteSliceField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.ByteSlice[{{.FieldType}}] { return views.ByteSliceOf(v.ж.{{.FieldName}}) }
|
||||
{{end}}
|
||||
{{define "sliceField"}}func (v {{.ViewName}}) {{.FieldName}}() views.Slice[{{.FieldType}}] { return views.SliceOf(v.ж.{{.FieldName}}) }
|
||||
{{define "sliceField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.Slice[{{.FieldType}}] { return views.SliceOf(v.ж.{{.FieldName}}) }
|
||||
{{end}}
|
||||
{{define "viewSliceField"}}func (v {{.ViewName}}) {{.FieldName}}() views.SliceView[{{.FieldType}},{{.FieldViewName}}] { return views.SliceOfViews[{{.FieldType}},{{.FieldViewName}}](v.ж.{{.FieldName}}) }
|
||||
{{define "viewSliceField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.SliceView[{{.FieldType}},{{.FieldViewName}}] { return views.SliceOfViews[{{.FieldType}},{{.FieldViewName}}](v.ж.{{.FieldName}}) }
|
||||
{{end}}
|
||||
{{define "viewField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}}View { return v.ж.{{.FieldName}}.View() }
|
||||
{{define "viewField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldViewName}} { return v.ж.{{.FieldName}}.View() }
|
||||
{{end}}
|
||||
{{define "valuePointerField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} {
|
||||
{{define "makeViewField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldViewName}} { return {{.MakeViewFnName}}(&v.ж.{{.FieldName}}) }
|
||||
{{end}}
|
||||
{{define "valuePointerField"}}func (v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldType}} {
|
||||
if v.ж.{{.FieldName}} == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -85,18 +89,21 @@ func (v *{{.ViewName}}) UnmarshalJSON(b []byte) error {
|
||||
|
||||
{{end}}
|
||||
{{define "mapField"}}
|
||||
func(v {{.ViewName}}) {{.FieldName}}() views.Map[{{.MapKeyType}},{{.MapValueType}}] { return views.MapOf(v.ж.{{.FieldName}})}
|
||||
func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.Map[{{.MapKeyType}},{{.MapValueType}}] { return views.MapOf(v.ж.{{.FieldName}})}
|
||||
{{end}}
|
||||
{{define "mapFnField"}}
|
||||
func(v {{.ViewName}}) {{.FieldName}}() views.MapFn[{{.MapKeyType}},{{.MapValueType}},{{.MapValueView}}] { return views.MapFnOf(v.ж.{{.FieldName}}, func (t {{.MapValueType}}) {{.MapValueView}} {
|
||||
func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.MapFn[{{.MapKeyType}},{{.MapValueType}},{{.MapValueView}}] { return views.MapFnOf(v.ж.{{.FieldName}}, func (t {{.MapValueType}}) {{.MapValueView}} {
|
||||
return {{.MapFn}}
|
||||
})}
|
||||
{{end}}
|
||||
{{define "unsupportedField"}}func(v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} {panic("unsupported")}
|
||||
{{define "mapSliceField"}}
|
||||
func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() views.MapSlice[{{.MapKeyType}},{{.MapValueType}}] { return views.MapSliceOf(v.ж.{{.FieldName}}) }
|
||||
{{end}}
|
||||
{{define "stringFunc"}}func(v {{.ViewName}}) String() string { return v.ж.String() }
|
||||
{{define "unsupportedField"}}func(v {{.ViewName}}{{.TypeParamNames}}) {{.FieldName}}() {{.FieldType}} {panic("unsupported")}
|
||||
{{end}}
|
||||
{{define "equalFunc"}}func(v {{.ViewName}}) Equal(v2 {{.ViewName}}) bool { return v.ж.Equal(v2.ж) }
|
||||
{{define "stringFunc"}}func(v {{.ViewName}}{{.TypeParamNames}}) String() string { return v.ж.String() }
|
||||
{{end}}
|
||||
{{define "equalFunc"}}func(v {{.ViewName}}{{.TypeParamNames}}) Equal(v2 {{.ViewName}}{{.TypeParamNames}}) bool { return v.ж.Equal(v2.ж) }
|
||||
{{end}}
|
||||
`
|
||||
|
||||
@@ -128,8 +135,11 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
|
||||
it.Import("errors")
|
||||
|
||||
args := struct {
|
||||
StructName string
|
||||
ViewName string
|
||||
StructName string
|
||||
ViewName string
|
||||
TypeParams string // e.g. [T constraints.Integer]
|
||||
TypeParamNames string // e.g. [T]
|
||||
|
||||
FieldName string
|
||||
FieldType string
|
||||
FieldViewName string
|
||||
@@ -138,11 +148,17 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
|
||||
MapValueType string
|
||||
MapValueView string
|
||||
MapFn string
|
||||
|
||||
// MakeViewFnName is the name of the function that accepts a value and returns a readonly view of it.
|
||||
MakeViewFnName string
|
||||
}{
|
||||
StructName: typ.Obj().Name(),
|
||||
ViewName: typ.Obj().Name() + "View",
|
||||
ViewName: typ.Origin().Obj().Name() + "View",
|
||||
}
|
||||
|
||||
typeParams := typ.Origin().TypeParams()
|
||||
args.TypeParams, args.TypeParamNames = codegen.FormatTypeParams(typeParams, it)
|
||||
|
||||
writeTemplate := func(name string) {
|
||||
if err := viewTemplate.ExecuteTemplate(buf, name, args); err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -179,19 +195,35 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
|
||||
it.Import("tailscale.com/types/views")
|
||||
shallow, deep, base := requiresCloning(elem)
|
||||
if deep {
|
||||
if _, isPtr := elem.(*types.Pointer); isPtr {
|
||||
args.FieldViewName = it.QualifiedName(base) + "View"
|
||||
writeTemplate("viewSliceField")
|
||||
} else {
|
||||
writeTemplate("unsupportedField")
|
||||
switch elem.Underlying().(type) {
|
||||
case *types.Pointer:
|
||||
if _, isIface := base.Underlying().(*types.Interface); !isIface {
|
||||
args.FieldViewName = appendNameSuffix(it.QualifiedName(base), "View")
|
||||
writeTemplate("viewSliceField")
|
||||
} else {
|
||||
writeTemplate("unsupportedField")
|
||||
}
|
||||
continue
|
||||
case *types.Interface:
|
||||
if viewType := viewTypeForValueType(elem); viewType != nil {
|
||||
args.FieldViewName = it.QualifiedName(viewType)
|
||||
writeTemplate("viewSliceField")
|
||||
continue
|
||||
}
|
||||
}
|
||||
writeTemplate("unsupportedField")
|
||||
continue
|
||||
} else if shallow {
|
||||
if _, isBasic := base.(*types.Basic); isBasic {
|
||||
switch base.Underlying().(type) {
|
||||
case *types.Basic, *types.Interface:
|
||||
writeTemplate("unsupportedField")
|
||||
} else {
|
||||
args.FieldViewName = it.QualifiedName(base) + "View"
|
||||
writeTemplate("viewSliceField")
|
||||
default:
|
||||
if _, isIface := base.Underlying().(*types.Interface); !isIface {
|
||||
args.FieldViewName = appendNameSuffix(it.QualifiedName(base), "View")
|
||||
writeTemplate("viewSliceField")
|
||||
} else {
|
||||
writeTemplate("unsupportedField")
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -202,7 +234,18 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
|
||||
strucT := underlying
|
||||
args.FieldType = it.QualifiedName(fieldType)
|
||||
if codegen.ContainsPointers(strucT) {
|
||||
writeTemplate("viewField")
|
||||
if viewType := viewTypeForValueType(fieldType); viewType != nil {
|
||||
args.FieldViewName = it.QualifiedName(viewType)
|
||||
writeTemplate("viewField")
|
||||
continue
|
||||
}
|
||||
if viewType, makeViewFn := viewTypeForContainerType(fieldType); viewType != nil {
|
||||
args.FieldViewName = it.QualifiedName(viewType)
|
||||
args.MakeViewFnName = it.PackagePrefix(makeViewFn.Pkg()) + makeViewFn.Name()
|
||||
writeTemplate("makeViewField")
|
||||
continue
|
||||
}
|
||||
writeTemplate("unsupportedField")
|
||||
continue
|
||||
}
|
||||
writeTemplate("valueField")
|
||||
@@ -226,7 +269,7 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
|
||||
args.MapFn = "t.View()"
|
||||
template = "mapFnField"
|
||||
args.MapValueType = it.QualifiedName(mElem)
|
||||
args.MapValueView = args.MapValueType + "View"
|
||||
args.MapValueView = appendNameSuffix(args.MapValueType, "View")
|
||||
} else {
|
||||
template = "mapField"
|
||||
args.MapValueType = it.QualifiedName(mElem)
|
||||
@@ -241,21 +284,25 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
|
||||
case *types.Basic, *types.Named:
|
||||
sElem := it.QualifiedName(sElem)
|
||||
args.MapValueView = fmt.Sprintf("views.Slice[%v]", sElem)
|
||||
args.MapValueType = "[]" + sElem
|
||||
args.MapFn = "views.SliceOf(t)"
|
||||
template = "mapFnField"
|
||||
args.MapValueType = sElem
|
||||
template = "mapSliceField"
|
||||
case *types.Pointer:
|
||||
ptr := x
|
||||
pElem := ptr.Elem()
|
||||
switch pElem.(type) {
|
||||
case *types.Struct, *types.Named:
|
||||
ptrType := it.QualifiedName(ptr)
|
||||
viewType := it.QualifiedName(pElem) + "View"
|
||||
args.MapFn = fmt.Sprintf("views.SliceOfViews[%v,%v](t)", ptrType, viewType)
|
||||
args.MapValueView = fmt.Sprintf("views.SliceView[%v,%v]", ptrType, viewType)
|
||||
args.MapValueType = "[]" + ptrType
|
||||
template = "mapFnField"
|
||||
default:
|
||||
template = "unsupportedField"
|
||||
if _, isIface := pElem.Underlying().(*types.Interface); !isIface {
|
||||
switch pElem.(type) {
|
||||
case *types.Struct, *types.Named:
|
||||
ptrType := it.QualifiedName(ptr)
|
||||
viewType := appendNameSuffix(it.QualifiedName(pElem), "View")
|
||||
args.MapFn = fmt.Sprintf("views.SliceOfViews[%v,%v](t)", ptrType, viewType)
|
||||
args.MapValueView = fmt.Sprintf("views.SliceView[%v,%v]", ptrType, viewType)
|
||||
args.MapValueType = "[]" + ptrType
|
||||
template = "mapFnField"
|
||||
default:
|
||||
template = "unsupportedField"
|
||||
}
|
||||
} else {
|
||||
template = "unsupportedField"
|
||||
}
|
||||
default:
|
||||
@@ -264,13 +311,29 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
|
||||
case *types.Pointer:
|
||||
ptr := u
|
||||
pElem := ptr.Elem()
|
||||
switch pElem.(type) {
|
||||
case *types.Struct, *types.Named:
|
||||
args.MapValueType = it.QualifiedName(ptr)
|
||||
args.MapValueView = it.QualifiedName(pElem) + "View"
|
||||
if _, isIface := pElem.Underlying().(*types.Interface); !isIface {
|
||||
switch pElem.(type) {
|
||||
case *types.Struct, *types.Named:
|
||||
args.MapValueType = it.QualifiedName(ptr)
|
||||
args.MapValueView = appendNameSuffix(it.QualifiedName(pElem), "View")
|
||||
args.MapFn = "t.View()"
|
||||
template = "mapFnField"
|
||||
default:
|
||||
template = "unsupportedField"
|
||||
}
|
||||
} else {
|
||||
template = "unsupportedField"
|
||||
}
|
||||
case *types.Interface, *types.TypeParam:
|
||||
if viewType := viewTypeForValueType(u); viewType != nil {
|
||||
args.MapValueType = it.QualifiedName(u)
|
||||
args.MapValueView = it.QualifiedName(viewType)
|
||||
args.MapFn = "t.View()"
|
||||
template = "mapFnField"
|
||||
default:
|
||||
} else if !codegen.ContainsPointers(u) {
|
||||
args.MapValueType = it.QualifiedName(mElem)
|
||||
template = "mapField"
|
||||
} else {
|
||||
template = "unsupportedField"
|
||||
}
|
||||
default:
|
||||
@@ -281,14 +344,28 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
|
||||
case *types.Pointer:
|
||||
ptr := underlying
|
||||
_, deep, base := requiresCloning(ptr)
|
||||
|
||||
if deep {
|
||||
args.FieldType = it.QualifiedName(base)
|
||||
writeTemplate("viewField")
|
||||
if _, isIface := base.Underlying().(*types.Interface); !isIface {
|
||||
args.FieldType = it.QualifiedName(base)
|
||||
args.FieldViewName = appendNameSuffix(args.FieldType, "View")
|
||||
writeTemplate("viewField")
|
||||
} else {
|
||||
writeTemplate("unsupportedField")
|
||||
}
|
||||
} else {
|
||||
args.FieldType = it.QualifiedName(ptr)
|
||||
writeTemplate("valuePointerField")
|
||||
}
|
||||
continue
|
||||
case *types.Interface:
|
||||
// If fieldType is an interface with a "View() {ViewType}" method, it can be used to clone the field.
|
||||
// This includes scenarios where fieldType is a constrained type parameter.
|
||||
if viewType := viewTypeForValueType(underlying); viewType != nil {
|
||||
args.FieldViewName = it.QualifiedName(viewType)
|
||||
writeTemplate("viewField")
|
||||
continue
|
||||
}
|
||||
}
|
||||
writeTemplate("unsupportedField")
|
||||
}
|
||||
@@ -316,7 +393,132 @@ func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thi
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(buf, "\n")
|
||||
buf.Write(codegen.AssertStructUnchanged(t, args.StructName, "View", it))
|
||||
buf.Write(codegen.AssertStructUnchanged(t, args.StructName, typeParams, "View", it))
|
||||
}
|
||||
|
||||
func appendNameSuffix(name, suffix string) string {
|
||||
if idx := strings.IndexRune(name, '['); idx != -1 {
|
||||
// Insert suffix after the type name, but before type parameters.
|
||||
return name[:idx] + suffix + name[idx:]
|
||||
}
|
||||
return name + suffix
|
||||
}
|
||||
|
||||
func viewTypeForValueType(typ types.Type) types.Type {
|
||||
if ptr, ok := typ.(*types.Pointer); ok {
|
||||
return viewTypeForValueType(ptr.Elem())
|
||||
}
|
||||
viewMethod := codegen.LookupMethod(typ, "View")
|
||||
if viewMethod == nil {
|
||||
return nil
|
||||
}
|
||||
sig, ok := viewMethod.Type().(*types.Signature)
|
||||
if !ok || sig.Results().Len() != 1 {
|
||||
return nil
|
||||
}
|
||||
return sig.Results().At(0).Type()
|
||||
}
|
||||
|
||||
func viewTypeForContainerType(typ types.Type) (*types.Named, *types.Func) {
|
||||
// The container type should be an instantiated generic type,
|
||||
// with its first type parameter specifying the element type.
|
||||
containerType, ok := typ.(*types.Named)
|
||||
if !ok || containerType.TypeArgs().Len() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Look up the view type for the container type.
|
||||
// It must include an additional type parameter specifying the element's view type.
|
||||
// For example, Container[T] => ContainerView[T, V].
|
||||
containerViewTypeName := containerType.Obj().Name() + "View"
|
||||
containerViewTypeObj, ok := containerType.Obj().Pkg().Scope().Lookup(containerViewTypeName).(*types.TypeName)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
containerViewGenericType, ok := containerViewTypeObj.Type().(*types.Named)
|
||||
if !ok || containerViewGenericType.TypeParams().Len() != containerType.TypeArgs().Len()+1 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Create a list of type arguments for instantiating the container view type.
|
||||
// Include all type arguments specified for the container type...
|
||||
containerViewTypeArgs := make([]types.Type, containerViewGenericType.TypeParams().Len())
|
||||
for i := range containerType.TypeArgs().Len() {
|
||||
containerViewTypeArgs[i] = containerType.TypeArgs().At(i)
|
||||
}
|
||||
// ...and add the element view type.
|
||||
// For that, we need to first determine the named elem type...
|
||||
elemType, ok := baseType(containerType.TypeArgs().At(containerType.TypeArgs().Len() - 1)).(*types.Named)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
// ...then infer the view type from it.
|
||||
var elemViewType *types.Named
|
||||
elemTypeName := elemType.Obj().Name()
|
||||
elemViewTypeBaseName := elemType.Obj().Name() + "View"
|
||||
if elemViewTypeName, ok := elemType.Obj().Pkg().Scope().Lookup(elemViewTypeBaseName).(*types.TypeName); ok {
|
||||
// The elem's view type is already defined in the same package as the elem type.
|
||||
elemViewType = elemViewTypeName.Type().(*types.Named)
|
||||
} else if slices.Contains(typeNames, elemTypeName) {
|
||||
// The elem's view type has not been generated yet, but we can define
|
||||
// and use a blank type with the expected view type name.
|
||||
elemViewTypeName = types.NewTypeName(0, elemType.Obj().Pkg(), elemViewTypeBaseName, nil)
|
||||
elemViewType = types.NewNamed(elemViewTypeName, types.NewStruct(nil, nil), nil)
|
||||
if elemTypeParams := elemType.TypeParams(); elemTypeParams != nil {
|
||||
elemViewType.SetTypeParams(collectTypeParams(elemTypeParams))
|
||||
}
|
||||
} else {
|
||||
// The elem view type does not exist and won't be generated.
|
||||
return nil, nil
|
||||
}
|
||||
// If elemType is an instantiated generic type, instantiate the elemViewType as well.
|
||||
if elemTypeArgs := elemType.TypeArgs(); elemTypeArgs != nil {
|
||||
elemViewType = must.Get(types.Instantiate(nil, elemViewType, collectTypes(elemTypeArgs), false)).(*types.Named)
|
||||
}
|
||||
// And finally set the elemViewType as the last type argument.
|
||||
containerViewTypeArgs[len(containerViewTypeArgs)-1] = elemViewType
|
||||
|
||||
// Instantiate the container view type with the specified type arguments.
|
||||
containerViewType := must.Get(types.Instantiate(nil, containerViewGenericType, containerViewTypeArgs, false))
|
||||
// Look up a function to create a view of a container.
|
||||
// It should be in the same package as the container type, named {ViewType}Of,
|
||||
// and have a signature like {ViewType}Of(c *Container[T]) ContainerView[T, V].
|
||||
makeContainerView, ok := containerType.Obj().Pkg().Scope().Lookup(containerViewTypeName + "Of").(*types.Func)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return containerViewType.(*types.Named), makeContainerView
|
||||
}
|
||||
|
||||
func baseType(typ types.Type) types.Type {
|
||||
if ptr, ok := typ.(*types.Pointer); ok {
|
||||
return ptr.Elem()
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
func collectTypes(list *types.TypeList) []types.Type {
|
||||
// TODO(nickkhyl): use slices.Collect in Go 1.23?
|
||||
if list.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
res := make([]types.Type, list.Len())
|
||||
for i := range res {
|
||||
res[i] = list.At(i)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func collectTypeParams(list *types.TypeParamList) []*types.TypeParam {
|
||||
if list.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
res := make([]*types.TypeParam, list.Len())
|
||||
for i := range res {
|
||||
p := list.At(i)
|
||||
res[i] = types.NewTypeParam(p.Obj(), p.Constraint())
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -325,6 +527,8 @@ var (
|
||||
flagCloneFunc = flag.Bool("clonefunc", false, "add a top-level Clone func")
|
||||
|
||||
flagCloneOnlyTypes = flag.String("clone-only-type", "", "comma-separated list of types (a subset of --type) that should only generate a go:generate clone line and not actual views")
|
||||
|
||||
typeNames []string
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -335,7 +539,7 @@ func main() {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
typeNames := strings.Split(*flagTypes, ",")
|
||||
typeNames = strings.Split(*flagTypes, ",")
|
||||
|
||||
var flagArgs []string
|
||||
flagArgs = append(flagArgs, fmt.Sprintf("-clonefunc=%v", *flagCloneFunc))
|
||||
@@ -379,7 +583,11 @@ func main() {
|
||||
}
|
||||
genView(buf, it, typ, pkg.Types)
|
||||
}
|
||||
out := pkg.Name + "_view.go"
|
||||
out := pkg.Name + "_view"
|
||||
if *flagBuildTags == "test" {
|
||||
out += "_test"
|
||||
}
|
||||
out += ".go"
|
||||
if err := codegen.WritePackageFile("tailscale/cmd/viewer", pkg, out, it, buf); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
21
cmd/vnet/run-krazy.sh
Executable file
21
cmd/vnet/run-krazy.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "Type 'C-a c' to enter monitor; q to quit."
|
||||
|
||||
set -eux
|
||||
qemu-system-x86_64 -M microvm,isa-serial=off \
|
||||
-m 1G \
|
||||
-nodefaults -no-user-config -nographic \
|
||||
-kernel $HOME/src/github.com/tailscale/gokrazy-kernel/vmlinuz \
|
||||
-append "console=hvc0 root=PARTUUID=60c24cc1-f3f9-427a-8199-76baa2d60001/PARTNROFF=1 ro init=/gokrazy/init panic=10 oops=panic pci=off nousb tsc=unstable clocksource=hpet tailscale-tta=1 tailscaled.env=TS_DEBUG_RAW_DISCO=1" \
|
||||
-drive id=blk0,file=$HOME/src/tailscale.com/gokrazy/natlabapp.img,format=raw \
|
||||
-device virtio-blk-device,drive=blk0 \
|
||||
-device virtio-rng-device \
|
||||
-netdev stream,id=net0,addr.type=unix,addr.path=/tmp/qemu.sock \
|
||||
-device virtio-serial-device \
|
||||
-device virtio-net-device,netdev=net0,mac=52:cc:cc:cc:cc:01 \
|
||||
-chardev stdio,id=virtiocon0,mux=on \
|
||||
-device virtconsole,chardev=virtiocon0 \
|
||||
-mon chardev=virtiocon0,mode=readline \
|
||||
-audio none
|
||||
|
||||
118
cmd/vnet/vnet-main.go
Normal file
118
cmd/vnet/vnet-main.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The vnet binary runs a virtual network stack in userspace for qemu instances
|
||||
// to connect to and simulate various network conditions.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tstest/natlab/vnet"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/must"
|
||||
)
|
||||
|
||||
var (
|
||||
listen = flag.String("listen", "/tmp/qemu.sock", "path to listen on")
|
||||
nat = flag.String("nat", "easy", "type of NAT to use")
|
||||
nat2 = flag.String("nat2", "hard", "type of NAT to use for second network")
|
||||
portmap = flag.Bool("portmap", false, "enable portmapping")
|
||||
dgram = flag.Bool("dgram", false, "enable datagram mode; for use with macOS Hypervisor.Framework and VZFileHandleNetworkDeviceAttachment")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if _, err := os.Stat(*listen); err == nil {
|
||||
os.Remove(*listen)
|
||||
}
|
||||
|
||||
var srv net.Listener
|
||||
var err error
|
||||
var conn *net.UnixConn
|
||||
if *dgram {
|
||||
addr, err := net.ResolveUnixAddr("unixgram", *listen)
|
||||
if err != nil {
|
||||
log.Fatalf("ResolveUnixAddr: %v", err)
|
||||
}
|
||||
conn, err = net.ListenUnixgram("unixgram", addr)
|
||||
if err != nil {
|
||||
log.Fatalf("ListenUnixgram: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
} else {
|
||||
srv, err = net.Listen("unix", *listen)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var c vnet.Config
|
||||
node1 := c.AddNode(c.AddNetwork("2.1.1.1", "192.168.1.1/24", vnet.NAT(*nat)))
|
||||
c.AddNode(c.AddNetwork("2.2.2.2", "10.2.0.1/16", vnet.NAT(*nat2)))
|
||||
if *portmap {
|
||||
node1.Network().AddService(vnet.NATPMP)
|
||||
}
|
||||
|
||||
s, err := vnet.New(&c)
|
||||
if err != nil {
|
||||
log.Fatalf("newServer: %v", err)
|
||||
}
|
||||
|
||||
if err := s.PopulateDERPMapIPs(); err != nil {
|
||||
log.Printf("warning: ignoring failure to populate DERP map: %v", err)
|
||||
}
|
||||
|
||||
s.WriteStartingBanner(os.Stdout)
|
||||
nc := s.NodeAgentClient(node1)
|
||||
go func() {
|
||||
rp := httputil.NewSingleHostReverseProxy(must.Get(url.Parse("http://gokrazy")))
|
||||
d := rp.Director
|
||||
rp.Director = func(r *http.Request) {
|
||||
d(r)
|
||||
r.Header.Set("X-TTA-GoKrazy", "1")
|
||||
}
|
||||
rp.Transport = nc.HTTPClient.Transport
|
||||
http.ListenAndServe(":8080", rp)
|
||||
}()
|
||||
go func() {
|
||||
getStatus := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
st, err := nc.Status(ctx)
|
||||
if err != nil {
|
||||
log.Printf("NodeStatus: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("NodeStatus: %v", logger.AsJSON(st))
|
||||
}
|
||||
for {
|
||||
time.Sleep(5 * time.Second)
|
||||
//continue
|
||||
getStatus()
|
||||
}
|
||||
}()
|
||||
|
||||
if conn != nil {
|
||||
s.ServeUnixConn(conn, vnet.ProtocolUnixDGRAM)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
c, err := srv.Accept()
|
||||
if err != nil {
|
||||
log.Printf("Accept: %v", err)
|
||||
continue
|
||||
}
|
||||
go s.ServeUnixConn(c.(*net.UnixConn), vnet.ProtocolQEMU)
|
||||
}
|
||||
}
|
||||
108
cmd/xdpderper/xdpderper.go
Normal file
108
cmd/xdpderper/xdpderper.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Command xdpderper runs the XDP STUN server.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"tailscale.com/derp/xdp"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/tsweb"
|
||||
)
|
||||
|
||||
var (
|
||||
flagDevice = flag.String("device", "", "target device name (default: autodetect)")
|
||||
flagPort = flag.Int("dst-port", 0, "destination UDP port to serve")
|
||||
flagVerbose = flag.Bool("verbose", false, "verbose output including verifier errors")
|
||||
flagMode = flag.String("mode", "xdp", "XDP mode; valid modes: [xdp, xdpgeneric, xdpdrv, xdpoffload]")
|
||||
flagHTTP = flag.String("http", ":8230", "HTTP listen address")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
var attachFlags xdp.XDPAttachFlags
|
||||
switch strings.ToLower(*flagMode) {
|
||||
case "xdp":
|
||||
attachFlags = 0
|
||||
case "xdpgeneric":
|
||||
attachFlags = xdp.XDPGenericMode
|
||||
case "xdpdrv":
|
||||
attachFlags = xdp.XDPDriverMode
|
||||
case "xdpoffload":
|
||||
attachFlags = xdp.XDPOffloadMode
|
||||
default:
|
||||
log.Fatal("invalid mode")
|
||||
}
|
||||
deviceName := *flagDevice
|
||||
if deviceName == "" {
|
||||
var err error
|
||||
deviceName, _, err = netutil.DefaultInterfacePortable()
|
||||
if err != nil || deviceName == "" {
|
||||
log.Fatalf("failed to detect default route interface: %v", err)
|
||||
}
|
||||
}
|
||||
log.Printf("binding to device: %s", deviceName)
|
||||
|
||||
server, err := xdp.NewSTUNServer(&xdp.STUNServerConfig{
|
||||
DeviceName: deviceName,
|
||||
DstPort: *flagPort,
|
||||
AttachFlags: attachFlags,
|
||||
FullVerifierErr: *flagVerbose,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to init XDP STUN server: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
err = prometheus.Register(server)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to register XDP STUN server as a prometheus collector: %v", err)
|
||||
}
|
||||
log.Println("XDP STUN server started")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
debug := tsweb.Debugger(mux)
|
||||
debug.KVFunc("Drop STUN", func() any {
|
||||
return server.GetDropSTUN()
|
||||
})
|
||||
debug.Handle("drop-stun-on", "Drop STUN packets", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := server.SetDropSTUN(true)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
} else {
|
||||
io.WriteString(w, "STUN packets are now being dropped.")
|
||||
}
|
||||
}))
|
||||
debug.Handle("drop-stun-off", "Handle STUN packets", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := server.SetDropSTUN(false)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
} else {
|
||||
io.WriteString(w, "STUN packets are now being handled.")
|
||||
}
|
||||
}))
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
err := http.ListenAndServe(*flagHTTP, mux)
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
select {
|
||||
case err := <-errCh:
|
||||
log.Printf("HTTP serve err: %v", err)
|
||||
case sig := <-sigCh:
|
||||
log.Printf("received signal: %s", sig)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,14 +7,13 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -25,6 +24,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
@@ -62,6 +62,7 @@ import (
|
||||
// Direct is the client that connects to a tailcontrol server for a node.
|
||||
type Direct struct {
|
||||
httpc *http.Client // HTTP client used to talk to tailcontrol
|
||||
interceptedDial *atomic.Bool // if non-nil, pointer to bool whether ScreenTime intercepted our dial
|
||||
dialer *tsdial.Dialer
|
||||
dnsCache *dnscache.Resolver
|
||||
controlKnobs *controlknobs.Knobs // always non-nil
|
||||
@@ -258,23 +259,28 @@ func NewDirect(opts Options) (*Direct, error) {
|
||||
// etc set).
|
||||
httpc = http.DefaultClient
|
||||
}
|
||||
var interceptedDial *atomic.Bool
|
||||
if httpc == nil {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.Proxy = tshttpproxy.ProxyFromEnvironment
|
||||
tshttpproxy.SetTransportGetProxyConnectHeader(tr)
|
||||
tr.TLSClientConfig = tlsdial.Config(serverURL.Hostname(), opts.HealthTracker, tr.TLSClientConfig)
|
||||
tr.DialContext = dnscache.Dialer(opts.Dialer.SystemDial, dnsCache)
|
||||
tr.DialTLSContext = dnscache.TLSDialer(opts.Dialer.SystemDial, dnsCache, tr.TLSClientConfig)
|
||||
var dialFunc dialFunc
|
||||
dialFunc, interceptedDial = makeScreenTimeDetectingDialFunc(opts.Dialer.SystemDial)
|
||||
tr.DialContext = dnscache.Dialer(dialFunc, dnsCache)
|
||||
tr.DialTLSContext = dnscache.TLSDialer(dialFunc, dnsCache, tr.TLSClientConfig)
|
||||
tr.ForceAttemptHTTP2 = true
|
||||
// Disable implicit gzip compression; the various
|
||||
// handlers (register, map, set-dns, etc) do their own
|
||||
// zstd compression per naclbox.
|
||||
tr.DisableCompression = true
|
||||
|
||||
httpc = &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
c := &Direct{
|
||||
httpc: httpc,
|
||||
interceptedDial: interceptedDial,
|
||||
controlKnobs: opts.ControlKnobs,
|
||||
getMachinePrivKey: opts.GetMachinePrivateKey,
|
||||
serverURL: opts.ServerURL,
|
||||
@@ -327,6 +333,9 @@ func (c *Direct) Close() error {
|
||||
}
|
||||
}
|
||||
c.noiseClient = nil
|
||||
if tr, ok := c.httpc.Transport.(*http.Transport); ok {
|
||||
tr.CloseIdleConnections()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -464,6 +473,16 @@ func (c *Direct) hostInfoLocked() *tailcfg.Hostinfo {
|
||||
return hi
|
||||
}
|
||||
|
||||
var macOSScreenTime = health.Register(&health.Warnable{
|
||||
Code: "macos-screen-time-controlclient",
|
||||
Severity: health.SeverityHigh,
|
||||
Title: "Tailscale blocked by Screen Time",
|
||||
Text: func(args health.Args) string {
|
||||
return "macOS Screen Time seems to be blocking Tailscale. Try disabling Screen Time in System Settings > Screen Time > Content & Privacy > Access to Web Content."
|
||||
},
|
||||
ImpactsConnectivity: true,
|
||||
})
|
||||
|
||||
func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, newURL string, nks tkatype.MarshaledSignature, err error) {
|
||||
if c.panicOnUse {
|
||||
panic("tainted client")
|
||||
@@ -473,7 +492,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
tryingNewKey := c.tryingNewKey
|
||||
serverKey := c.serverLegacyKey
|
||||
serverNoiseKey := c.serverNoiseKey
|
||||
authKey, isWrapped, wrappedSig, wrappedKey := decodeWrappedAuthkey(c.authKey, c.logf)
|
||||
authKey, isWrapped, wrappedSig, wrappedKey := tka.DecodeWrappedAuthkey(c.authKey, c.logf)
|
||||
hi := c.hostInfoLocked()
|
||||
backendLogID := hi.BackendLogID
|
||||
expired := !c.expiry.IsZero() && c.expiry.Before(c.clock.Now())
|
||||
@@ -505,6 +524,11 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
c.logf("doLogin(regen=%v, hasUrl=%v)", regen, opt.URL != "")
|
||||
if serverKey.IsZero() {
|
||||
keys, err := loadServerPubKeys(ctx, c.httpc, c.serverURL)
|
||||
if err != nil && c.interceptedDial != nil && c.interceptedDial.Load() {
|
||||
c.health.SetUnhealthy(macOSScreenTime, nil)
|
||||
} else {
|
||||
c.health.SetHealthy(macOSScreenTime)
|
||||
}
|
||||
if err != nil {
|
||||
return regen, opt.URL, nil, err
|
||||
}
|
||||
@@ -565,18 +589,10 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
// We were given a wrapped pre-auth key, which means that in addition
|
||||
// to being a regular pre-auth key there was a suffix with information to
|
||||
// generate a tailnet-lock signature.
|
||||
nk, err := tryingNewKey.Public().MarshalBinary()
|
||||
nodeKeySignature, err = tka.SignByCredential(wrappedKey, wrappedSig, tryingNewKey.Public())
|
||||
if err != nil {
|
||||
return false, "", nil, fmt.Errorf("marshalling node-key: %w", err)
|
||||
return false, "", nil, err
|
||||
}
|
||||
sig := &tka.NodeKeySignature{
|
||||
SigKind: tka.SigRotation,
|
||||
Pubkey: nk,
|
||||
Nested: wrappedSig,
|
||||
}
|
||||
sigHash := sig.SigHash()
|
||||
sig.Signature = ed25519.Sign(wrappedKey, sigHash[:])
|
||||
nodeKeySignature = sig.Serialize()
|
||||
}
|
||||
|
||||
if backendLogID == "" {
|
||||
@@ -1581,9 +1597,9 @@ func postPingResult(start time.Time, logf logger.Logf, c *http.Client, pr *tailc
|
||||
}
|
||||
|
||||
// ReportHealthChange reports to the control plane a change to this node's
|
||||
// health.
|
||||
func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) {
|
||||
if sys == health.SysOverall {
|
||||
// health. w must be non-nil. us can be nil to indicate a healthy state for w.
|
||||
func (c *Direct) ReportHealthChange(w *health.Warnable, us *health.UnhealthyState) {
|
||||
if w == health.NetworkStatusWarnable || w == health.IPNStateWarnable || w == health.LoginStateWarnable {
|
||||
// We don't report these. These include things like the network is down
|
||||
// (in which case we can't report anyway) or the user wanted things
|
||||
// stopped, as opposed to the more unexpected failure types in the other
|
||||
@@ -1602,12 +1618,13 @@ func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) {
|
||||
if c.panicOnUse {
|
||||
panic("tainted client")
|
||||
}
|
||||
// TODO(angott): at some point, update `Subsys` in the request to be `Warnable`
|
||||
req := &tailcfg.HealthChangeRequest{
|
||||
Subsys: string(sys),
|
||||
Subsys: string(w.Code),
|
||||
NodeKey: nodeKey,
|
||||
}
|
||||
if sysErr != nil {
|
||||
req.Error = sysErr.Error()
|
||||
if us != nil {
|
||||
req.Error = us.Text
|
||||
}
|
||||
|
||||
// Best effort, no logging:
|
||||
@@ -1620,49 +1637,44 @@ func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) {
|
||||
res.Body.Close()
|
||||
}
|
||||
|
||||
// decodeWrappedAuthkey separates wrapping information from an authkey, if any.
|
||||
// In all cases the authkey is returned, sans wrapping information if any.
|
||||
//
|
||||
// If the authkey is wrapped, isWrapped returns true, along with the wrapping signature
|
||||
// and private key.
|
||||
func decodeWrappedAuthkey(key string, logf logger.Logf) (authKey string, isWrapped bool, sig *tka.NodeKeySignature, priv ed25519.PrivateKey) {
|
||||
authKey, suffix, found := strings.Cut(key, "--TL")
|
||||
if !found {
|
||||
return key, false, nil, nil
|
||||
}
|
||||
sigBytes, privBytes, found := strings.Cut(suffix, "-")
|
||||
if !found {
|
||||
logf("decoding wrapped auth-key: did not find delimiter")
|
||||
return key, false, nil, nil
|
||||
}
|
||||
|
||||
rawSig, err := base64.RawStdEncoding.DecodeString(sigBytes)
|
||||
if err != nil {
|
||||
logf("decoding wrapped auth-key: signature decode: %v", err)
|
||||
return key, false, nil, nil
|
||||
}
|
||||
rawPriv, err := base64.RawStdEncoding.DecodeString(privBytes)
|
||||
if err != nil {
|
||||
logf("decoding wrapped auth-key: priv decode: %v", err)
|
||||
return key, false, nil, nil
|
||||
}
|
||||
|
||||
sig = new(tka.NodeKeySignature)
|
||||
if err := sig.Unserialize([]byte(rawSig)); err != nil {
|
||||
logf("decoding wrapped auth-key: signature: %v", err)
|
||||
return key, false, nil, nil
|
||||
}
|
||||
priv = ed25519.PrivateKey(rawPriv)
|
||||
|
||||
return authKey, true, sig, priv
|
||||
}
|
||||
|
||||
func addLBHeader(req *http.Request, nodeKey key.NodePublic) {
|
||||
if !nodeKey.IsZero() {
|
||||
req.Header.Add(tailcfg.LBHeader, nodeKey.String())
|
||||
}
|
||||
}
|
||||
|
||||
type dialFunc = func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// makeScreenTimeDetectingDialFunc returns dialFunc, optionally wrapped (on
|
||||
// Apple systems) with a func that sets the returned atomic.Bool for whether
|
||||
// Screen Time seemed to intercept the connection.
|
||||
//
|
||||
// The returned *atomic.Bool is nil on non-Apple systems.
|
||||
func makeScreenTimeDetectingDialFunc(dial dialFunc) (dialFunc, *atomic.Bool) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin", "ios":
|
||||
// Continue below.
|
||||
default:
|
||||
return dial, nil
|
||||
}
|
||||
ab := new(atomic.Bool)
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
c, err := dial(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ab.Store(isTCPLoopback(c.LocalAddr()) && isTCPLoopback(c.RemoteAddr()))
|
||||
return c, nil
|
||||
}, ab
|
||||
}
|
||||
|
||||
func isTCPLoopback(a net.Addr) bool {
|
||||
if ta, ok := a.(*net.TCPAddr); ok {
|
||||
return ta.IP.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
metricMapRequestsActive = clientmetric.NewGauge("controlclient_map_requests_active")
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package controlclient
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -147,42 +146,3 @@ func TestTsmpPing(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWrappedAuthkey(t *testing.T) {
|
||||
k, isWrapped, sig, priv := decodeWrappedAuthkey("tskey-32mjsdkdsffds9o87dsfkjlh", nil)
|
||||
if want := "tskey-32mjsdkdsffds9o87dsfkjlh"; k != want {
|
||||
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).key = %q, want %q", k, want)
|
||||
}
|
||||
if isWrapped {
|
||||
t.Error("decodeWrappedAuthkey(<unwrapped-key>).isWrapped = true, want false")
|
||||
}
|
||||
if sig != nil {
|
||||
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).sig = %v, want nil", sig)
|
||||
}
|
||||
if priv != nil {
|
||||
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).priv = %v, want nil", priv)
|
||||
}
|
||||
|
||||
k, isWrapped, sig, priv = decodeWrappedAuthkey("tskey-auth-k7UagY1CNTRL-ZZZZZ--TLpAEDA1ggnXuw4/fWnNWUwcoOjLemhOvml1juMl5lhLmY5sBUsj8EWEAfL2gdeD9g8VDw5tgcxCiHGlEb67BgU2DlFzZApi4LheLJraA+pYjTGChVhpZz1iyiBPD+U2qxDQAbM3+WFY0EBlggxmVqG53Hu0Rg+KmHJFMlUhfgzo+AQP6+Kk9GzvJJOs4-k36RdoSFqaoARfQo0UncHAV0t3YTqrkD5r/z2jTrE43GZWobnce7RGD4qYckUyVSF+DOj4BA/r4qT0bO8kk6zg", nil)
|
||||
if want := "tskey-auth-k7UagY1CNTRL-ZZZZZ"; k != want {
|
||||
t.Errorf("decodeWrappedAuthkey(<wrapped-key>).key = %q, want %q", k, want)
|
||||
}
|
||||
if !isWrapped {
|
||||
t.Error("decodeWrappedAuthkey(<wrapped-key>).isWrapped = false, want true")
|
||||
}
|
||||
|
||||
if sig == nil {
|
||||
t.Fatal("decodeWrappedAuthkey(<wrapped-key>).sig = nil, want non-nil signature")
|
||||
}
|
||||
sigHash := sig.SigHash()
|
||||
if !ed25519.Verify(sig.KeyID, sigHash[:], sig.Signature) {
|
||||
t.Error("signature failed to verify")
|
||||
}
|
||||
|
||||
// Make sure the private is correct by using it.
|
||||
someSig := ed25519.Sign(priv, []byte{1, 2, 3, 4})
|
||||
if !ed25519.Verify(sig.WrappingPubkey, []byte{1, 2, 3, 4}, someSig) {
|
||||
t.Error("failed to use priv")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,10 +6,8 @@ package controlclient
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -17,9 +15,9 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"tailscale.com/control/controlbase"
|
||||
"tailscale.com/control/controlhttp"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/internal/noiseconn"
|
||||
"tailscale.com/net/dnscache"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/net/tsdial"
|
||||
@@ -32,113 +30,6 @@ import (
|
||||
"tailscale.com/util/singleflight"
|
||||
)
|
||||
|
||||
// noiseConn is a wrapper around controlbase.Conn.
|
||||
// It allows attaching an ID to a connection to allow
|
||||
// cleaning up references in the pool when the connection
|
||||
// is closed.
|
||||
type noiseConn struct {
|
||||
*controlbase.Conn
|
||||
id int
|
||||
pool *NoiseClient
|
||||
h2cc *http2.ClientConn
|
||||
|
||||
readHeaderOnce sync.Once // guards init of reader field
|
||||
reader io.Reader // (effectively Conn.Reader after header)
|
||||
earlyPayloadReady chan struct{} // closed after earlyPayload is set (including set to nil)
|
||||
earlyPayload *tailcfg.EarlyNoise
|
||||
earlyPayloadErr error
|
||||
}
|
||||
|
||||
func (c *noiseConn) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return c.h2cc.RoundTrip(r)
|
||||
}
|
||||
|
||||
// getEarlyPayload waits for the early noise payload to arrive.
|
||||
// It may return (nil, nil) if the server begins HTTP/2 without one.
|
||||
func (c *noiseConn) getEarlyPayload(ctx context.Context) (*tailcfg.EarlyNoise, error) {
|
||||
select {
|
||||
case <-c.earlyPayloadReady:
|
||||
return c.earlyPayload, c.earlyPayloadErr
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// The first 9 bytes from the server to client over Noise are either an HTTP/2
|
||||
// settings frame (a normal HTTP/2 setup) or, as we added later, an "early payload"
|
||||
// header that's also 9 bytes long: 5 bytes (earlyPayloadMagic) followed by 4 bytes
|
||||
// of length. Then that many bytes of JSON-encoded tailcfg.EarlyNoise.
|
||||
// The early payload is optional. Some servers may not send it.
|
||||
const (
|
||||
hdrLen = 9 // http2 frame header size; also size of our early payload size header
|
||||
earlyPayloadMagic = "\xff\xff\xffTS"
|
||||
)
|
||||
|
||||
// returnErrReader is an io.Reader that always returns an error.
|
||||
type returnErrReader struct {
|
||||
err error // the error to return
|
||||
}
|
||||
|
||||
func (r returnErrReader) Read([]byte) (int, error) { return 0, r.err }
|
||||
|
||||
// Read is basically the same as controlbase.Conn.Read, but it first reads the
|
||||
// "early payload" header from the server which may or may not be present,
|
||||
// depending on the server.
|
||||
func (c *noiseConn) Read(p []byte) (n int, err error) {
|
||||
c.readHeaderOnce.Do(c.readHeader)
|
||||
return c.reader.Read(p)
|
||||
}
|
||||
|
||||
// readHeader reads the optional "early payload" from the server that arrives
|
||||
// after the Noise handshake but before the HTTP/2 session begins.
|
||||
//
|
||||
// readHeader is responsible for reading the header (if present), initializing
|
||||
// c.earlyPayload, closing c.earlyPayloadReady, and initializing c.reader for
|
||||
// future reads.
|
||||
func (c *noiseConn) readHeader() {
|
||||
defer close(c.earlyPayloadReady)
|
||||
|
||||
setErr := func(err error) {
|
||||
c.reader = returnErrReader{err}
|
||||
c.earlyPayloadErr = err
|
||||
}
|
||||
|
||||
var hdr [hdrLen]byte
|
||||
if _, err := io.ReadFull(c.Conn, hdr[:]); err != nil {
|
||||
setErr(err)
|
||||
return
|
||||
}
|
||||
if string(hdr[:len(earlyPayloadMagic)]) != earlyPayloadMagic {
|
||||
// No early payload. We have to return the 9 bytes read we already
|
||||
// consumed.
|
||||
c.reader = io.MultiReader(bytes.NewReader(hdr[:]), c.Conn)
|
||||
return
|
||||
}
|
||||
epLen := binary.BigEndian.Uint32(hdr[len(earlyPayloadMagic):])
|
||||
if epLen > 10<<20 {
|
||||
setErr(errors.New("invalid early payload length"))
|
||||
return
|
||||
}
|
||||
payBuf := make([]byte, epLen)
|
||||
if _, err := io.ReadFull(c.Conn, payBuf); err != nil {
|
||||
setErr(err)
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(payBuf, &c.earlyPayload); err != nil {
|
||||
setErr(err)
|
||||
return
|
||||
}
|
||||
c.reader = c.Conn
|
||||
}
|
||||
|
||||
func (c *noiseConn) Close() error {
|
||||
if err := c.Conn.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.pool.connClosed(c.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NoiseClient provides a http.Client to connect to tailcontrol over
|
||||
// the ts2021 protocol.
|
||||
type NoiseClient struct {
|
||||
@@ -158,7 +49,7 @@ type NoiseClient struct {
|
||||
|
||||
// sfDial ensures that two concurrent requests for a noise connection only
|
||||
// produce one shared one between the two callers.
|
||||
sfDial singleflight.Group[struct{}, *noiseConn]
|
||||
sfDial singleflight.Group[struct{}, *noiseconn.Conn]
|
||||
|
||||
dialer *tsdial.Dialer
|
||||
dnsCache *dnscache.Resolver
|
||||
@@ -180,9 +71,9 @@ type NoiseClient struct {
|
||||
// mu only protects the following variables.
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
last *noiseConn // or nil
|
||||
last *noiseconn.Conn // or nil
|
||||
nextID int
|
||||
connPool map[int]*noiseConn // active connections not yet closed; see noiseConn.Close
|
||||
connPool map[int]*noiseconn.Conn // active connections not yet closed; see noiseconn.Conn.Close
|
||||
}
|
||||
|
||||
// NoiseOpts contains options for the NewNoiseClient function. All fields are
|
||||
@@ -283,11 +174,11 @@ func (nc *NoiseClient) GetSingleUseRoundTripper(ctx context.Context) (http.Round
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
earlyPayloadMaybeNil, err := conn.getEarlyPayload(ctx)
|
||||
ok, earlyPayloadMaybeNil, err := conn.ReserveNewRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if conn.h2cc.ReserveNewRequest() {
|
||||
if ok {
|
||||
return conn, earlyPayloadMaybeNil, nil
|
||||
}
|
||||
}
|
||||
@@ -308,14 +199,14 @@ func (e contextErr) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// getConn returns a noiseConn that can be used to make requests to the
|
||||
// getConn returns a noiseconn.Conn that can be used to make requests to the
|
||||
// coordination server. It may return a cached connection or create a new one.
|
||||
// Dials are singleflighted, so concurrent calls to getConn may only dial once.
|
||||
// As such, context values may not be respected as there are no guarantees that
|
||||
// the context passed to getConn is the same as the context passed to dial.
|
||||
func (nc *NoiseClient) getConn(ctx context.Context) (*noiseConn, error) {
|
||||
func (nc *NoiseClient) getConn(ctx context.Context) (*noiseconn.Conn, error) {
|
||||
nc.mu.Lock()
|
||||
if last := nc.last; last != nil && last.canTakeNewRequest() {
|
||||
if last := nc.last; last != nil && last.CanTakeNewRequest() {
|
||||
nc.mu.Unlock()
|
||||
return last, nil
|
||||
}
|
||||
@@ -327,7 +218,7 @@ func (nc *NoiseClient) getConn(ctx context.Context) (*noiseConn, error) {
|
||||
// canceled. Instead, we have to additionally check that the context
|
||||
// which was canceled is our context and retry if our context is still
|
||||
// valid.
|
||||
conn, err, _ := nc.sfDial.Do(struct{}{}, func() (*noiseConn, error) {
|
||||
conn, err, _ := nc.sfDial.Do(struct{}{}, func() (*noiseconn.Conn, error) {
|
||||
c, err := nc.dial(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
@@ -395,7 +286,7 @@ func (nc *NoiseClient) Close() error {
|
||||
|
||||
// dial opens a new connection to tailcontrol, fetching the server noise key
|
||||
// if not cached.
|
||||
func (nc *NoiseClient) dial(ctx context.Context) (*noiseConn, error) {
|
||||
func (nc *NoiseClient) dial(ctx context.Context) (*noiseconn.Conn, error) {
|
||||
nc.mu.Lock()
|
||||
connID := nc.nextID
|
||||
nc.nextID++
|
||||
@@ -465,18 +356,10 @@ func (nc *NoiseClient) dial(ctx context.Context) (*noiseConn, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ncc := &noiseConn{
|
||||
Conn: clientConn.Conn,
|
||||
id: connID,
|
||||
pool: nc,
|
||||
earlyPayloadReady: make(chan struct{}),
|
||||
}
|
||||
|
||||
h2cc, err := nc.h2t.NewClientConn(ncc)
|
||||
ncc, err := noiseconn.New(clientConn.Conn, nc.h2t, connID, nc.connClosed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ncc.h2cc = h2cc
|
||||
|
||||
nc.mu.Lock()
|
||||
if nc.closed {
|
||||
@@ -485,7 +368,7 @@ func (nc *NoiseClient) dial(ctx context.Context) (*noiseConn, error) {
|
||||
return nil, errors.New("noise client closed")
|
||||
}
|
||||
defer nc.mu.Unlock()
|
||||
mak.Set(&nc.connPool, ncc.id, ncc)
|
||||
mak.Set(&nc.connPool, connID, ncc)
|
||||
nc.last = ncc
|
||||
return ncc, nil
|
||||
}
|
||||
@@ -508,9 +391,5 @@ func (nc *NoiseClient) post(ctx context.Context, path string, nodeKey key.NodePu
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn.h2cc.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (c *noiseConn) canTakeNewRequest() bool {
|
||||
return c.h2cc.CanTakeNewRequest()
|
||||
return conn.RoundTrip(req)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"tailscale.com/control/controlhttp"
|
||||
"tailscale.com/internal/noiseconn"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -93,22 +94,21 @@ func (tt noiseClientTest) run(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-c.earlyPayloadReady:
|
||||
gotNonNil := c.earlyPayload != nil
|
||||
if gotNonNil != tt.sendEarlyPayload {
|
||||
t.Errorf("sendEarlyPayload = %v but got earlyPayload = %T", tt.sendEarlyPayload, c.earlyPayload)
|
||||
}
|
||||
if c.earlyPayload != nil {
|
||||
if c.earlyPayload.NodeKeyChallenge != chalPrivate.Public() {
|
||||
t.Errorf("earlyPayload.NodeKeyChallenge = %v; want %v", c.earlyPayload.NodeKeyChallenge, chalPrivate.Public())
|
||||
}
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
payload, err := c.GetEarlyPayload(ctx)
|
||||
if err != nil {
|
||||
t.Fatal("timed out waiting for didReadHeaderCh")
|
||||
}
|
||||
|
||||
gotNonNil := payload != nil
|
||||
if gotNonNil != tt.sendEarlyPayload {
|
||||
t.Errorf("sendEarlyPayload = %v but got earlyPayload = %T", tt.sendEarlyPayload, payload)
|
||||
}
|
||||
if payload != nil {
|
||||
if payload.NodeKeyChallenge != chalPrivate.Public() {
|
||||
t.Errorf("earlyPayload.NodeKeyChallenge = %v; want %v", payload.NodeKeyChallenge, chalPrivate.Public())
|
||||
}
|
||||
}
|
||||
|
||||
checkRes := func(t *testing.T, res *http.Response) {
|
||||
t.Helper()
|
||||
defer res.Body.Close()
|
||||
@@ -184,7 +184,7 @@ func (up *Upgrader) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// https://httpwg.org/specs/rfc7540.html#rfc.section.4.1 (Especially not
|
||||
// an HTTP/2 settings frame, which isn't of type 'T')
|
||||
var notH2Frame [5]byte
|
||||
copy(notH2Frame[:], earlyPayloadMagic)
|
||||
copy(notH2Frame[:], noiseconn.EarlyPayloadMagic)
|
||||
var lenBuf [4]byte
|
||||
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(earlyJSON)))
|
||||
// These writes are all buffered by caller, so fine to do them
|
||||
|
||||
@@ -32,18 +32,21 @@ import (
|
||||
"net/http/httptrace"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"tailscale.com/control/controlbase"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/health"
|
||||
"tailscale.com/net/dnscache"
|
||||
"tailscale.com/net/dnsfallback"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/sockstats"
|
||||
"tailscale.com/net/tlsdial"
|
||||
"tailscale.com/net/tshttpproxy"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/util/multierr"
|
||||
@@ -396,12 +399,29 @@ func (a *Dialer) resolver() *dnscache.Resolver {
|
||||
}
|
||||
}
|
||||
|
||||
func isLoopback(a net.Addr) bool {
|
||||
if ta, ok := a.(*net.TCPAddr); ok {
|
||||
return ta.IP.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var macOSScreenTime = health.Register(&health.Warnable{
|
||||
Code: "macos-screen-time",
|
||||
Severity: health.SeverityHigh,
|
||||
Title: "Tailscale blocked by Screen Time",
|
||||
Text: func(args health.Args) string {
|
||||
return "macOS Screen Time seems to be blocking Tailscale. Try disabling Screen Time in System Settings > Screen Time > Content & Privacy > Access to Web Content."
|
||||
},
|
||||
ImpactsConnectivity: true,
|
||||
})
|
||||
|
||||
// tryURLUpgrade connects to u, and tries to upgrade it to a net.Conn. If addr
|
||||
// is valid, then no DNS is used and the connection will be made to the
|
||||
// provided address.
|
||||
//
|
||||
// Only the provided ctx is used, not a.ctx.
|
||||
func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr, init []byte) (net.Conn, error) {
|
||||
func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr, init []byte) (_ net.Conn, retErr error) {
|
||||
var dns *dnscache.Resolver
|
||||
|
||||
// If we were provided an address to dial, then create a resolver that just
|
||||
@@ -423,6 +443,30 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr,
|
||||
dialer = stdDialer.DialContext
|
||||
}
|
||||
|
||||
// On macOS, see if Screen Time is blocking things.
|
||||
if runtime.GOOS == "darwin" {
|
||||
var proxydIntercepted atomic.Bool // intercepted by macOS webfilterproxyd
|
||||
origDialer := dialer
|
||||
dialer = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
c, err := origDialer(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isLoopback(c.LocalAddr()) && isLoopback(c.RemoteAddr()) {
|
||||
proxydIntercepted.Store(true)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
defer func() {
|
||||
if retErr != nil && proxydIntercepted.Load() {
|
||||
a.HealthTracker.SetUnhealthy(macOSScreenTime, nil)
|
||||
retErr = fmt.Errorf("macOS Screen Time is blocking network access: %w", retErr)
|
||||
} else {
|
||||
a.HealthTracker.SetHealthy(macOSScreenTime)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
defer tr.CloseIdleConnections()
|
||||
tr.Proxy = a.getProxyFunc()
|
||||
@@ -454,11 +498,9 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr,
|
||||
tr.DisableCompression = true
|
||||
|
||||
// (mis)use httptrace to extract the underlying net.Conn from the
|
||||
// transport. We make exactly 1 request using this transport, so
|
||||
// there will be exactly 1 GotConn call. Additionally, the
|
||||
// transport handles 101 Switching Protocols correctly, such that
|
||||
// the Conn will not be reused or kept alive by the transport once
|
||||
// the response has been handed back from RoundTrip.
|
||||
// transport. The transport handles 101 Switching Protocols correctly,
|
||||
// such that the Conn will not be reused or kept alive by the transport
|
||||
// once the response has been handed back from RoundTrip.
|
||||
//
|
||||
// In theory, the machinery of net/http should make it such that
|
||||
// the trace callback happens-before we get the response, but
|
||||
@@ -474,10 +516,16 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr,
|
||||
// unexpected EOFs...), and we're bound to forget someday and
|
||||
// introduce a protocol optimization at a higher level that starts
|
||||
// eagerly transmitting from the server.
|
||||
connCh := make(chan net.Conn, 1)
|
||||
var lastConn syncs.AtomicValue[net.Conn]
|
||||
trace := httptrace.ClientTrace{
|
||||
// Even though we only make a single HTTP request which should
|
||||
// require a single connection, the context (with the attached
|
||||
// trace configuration) might be used by our custom dialer to
|
||||
// make other HTTP requests (e.g. BootstrapDNS). We only care
|
||||
// about the last connection made, which should be the one to
|
||||
// the control server.
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
connCh <- info.Conn
|
||||
lastConn.Store(info.Conn)
|
||||
},
|
||||
}
|
||||
ctx = httptrace.WithClientTrace(ctx, &trace)
|
||||
@@ -505,11 +553,7 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr,
|
||||
// is still a read buffer attached to it within resp.Body. So, we
|
||||
// must direct I/O through resp.Body, but we can still use the
|
||||
// underlying net.Conn for stuff like deadlines.
|
||||
var switchedConn net.Conn
|
||||
select {
|
||||
case switchedConn = <-connCh:
|
||||
default:
|
||||
}
|
||||
switchedConn := lastConn.Load()
|
||||
if switchedConn == nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("httptrace didn't provide a connection")
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"net"
|
||||
"net/url"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
"github.com/coder/websocket"
|
||||
"tailscale.com/control/controlbase"
|
||||
"tailscale.com/net/wsconn"
|
||||
)
|
||||
|
||||
@@ -11,10 +11,12 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -41,6 +43,8 @@ type httpTestParam struct {
|
||||
makeHTTPHangAfterUpgrade bool
|
||||
|
||||
doEarlyWrite bool
|
||||
|
||||
httpInDial bool
|
||||
}
|
||||
|
||||
func TestControlHTTP(t *testing.T) {
|
||||
@@ -120,6 +124,12 @@ func TestControlHTTP(t *testing.T) {
|
||||
name: "early_write",
|
||||
doEarlyWrite: true,
|
||||
},
|
||||
// Dialer needed to make another HTTP request along the way (e.g. to
|
||||
// resolve the hostname via BootstrapDNS).
|
||||
{
|
||||
name: "http_request_in_dial",
|
||||
httpInDial: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -217,6 +227,29 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
|
||||
Clock: clock,
|
||||
}
|
||||
|
||||
if param.httpInDial {
|
||||
// Spin up a separate server to get a different port on localhost.
|
||||
secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return }))
|
||||
defer secondServer.Close()
|
||||
|
||||
prev := a.Dialer
|
||||
a.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", secondServer.URL, nil)
|
||||
if err != nil {
|
||||
t.Errorf("http.NewRequest: %v", err)
|
||||
}
|
||||
r, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Errorf("http.Get: %v", err)
|
||||
}
|
||||
r.Body.Close()
|
||||
|
||||
return prev(ctx, network, addr)
|
||||
}
|
||||
}
|
||||
|
||||
if proxy != nil {
|
||||
proxyEnv := proxy.Start(t)
|
||||
defer proxy.Close()
|
||||
@@ -238,6 +271,7 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
|
||||
t.Fatalf("dialing controlhttp: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
si := <-sch
|
||||
if si.conn != nil {
|
||||
defer si.conn.Close()
|
||||
@@ -266,6 +300,19 @@ func testControlHTTP(t *testing.T, param httpTestParam) {
|
||||
t.Errorf("early write = %q; want %q", buf, earlyWriteMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// When no proxy is used, the RemoteAddr of the returned connection should match
|
||||
// one of the listeners of the test server.
|
||||
if proxy == nil {
|
||||
var expectedAddrs []string
|
||||
for _, ln := range []net.Listener{httpLn, httpsLn} {
|
||||
expectedAddrs = append(expectedAddrs, fmt.Sprintf("127.0.0.1:%d", ln.Addr().(*net.TCPAddr).Port))
|
||||
expectedAddrs = append(expectedAddrs, fmt.Sprintf("[::1]:%d", ln.Addr().(*net.TCPAddr).Port))
|
||||
}
|
||||
if !slices.Contains(expectedAddrs, conn.RemoteAddr().String()) {
|
||||
t.Errorf("unexpected remote addr: %s, want %s", conn.RemoteAddr(), expectedAddrs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type serverResult struct {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user