Compare commits
265 Commits
cross-andr
...
Xe/gitops-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2716667c9d | ||
|
|
affa25097c | ||
|
|
227c6b2a53 | ||
|
|
90ccba6730 | ||
|
|
f7a36dfeb1 | ||
|
|
9514ed33d2 | ||
|
|
1d33157ab9 | ||
|
|
3e06b9ea7a | ||
|
|
480fd6c797 | ||
|
|
41e60dae80 | ||
|
|
43f3a969ca | ||
|
|
d8cb5aae17 | ||
|
|
b763a12331 | ||
|
|
de2dcda2e0 | ||
|
|
b7f1fe7b0d | ||
|
|
9bd3b5b89c | ||
|
|
cfdb862673 | ||
|
|
6f5096fa61 | ||
|
|
2a22ea3e83 | ||
|
|
4d0461f721 | ||
|
|
393a229de9 | ||
|
|
165c8f898e | ||
|
|
2491fe1afe | ||
|
|
c1cb3efbba | ||
|
|
4c0feba38e | ||
|
|
3c892d106c | ||
|
|
bd4b27753e | ||
|
|
469c30c33b | ||
|
|
c6648db333 | ||
|
|
9fcda1f0a0 | ||
|
|
0d52674a84 | ||
|
|
931f18b575 | ||
|
|
4f1374ec9e | ||
|
|
af412e8874 | ||
|
|
004f0ca3e0 | ||
|
|
16c85d0dc5 | ||
|
|
7fb6781bda | ||
|
|
ec4f849079 | ||
|
|
505ca2750d | ||
|
|
96afd1db46 | ||
|
|
755396d6fe | ||
|
|
cca25f6107 | ||
|
|
e37167b3ef | ||
|
|
d6b8a18b09 | ||
|
|
5bb44a4a5c | ||
|
|
c7993d2b88 | ||
|
|
3709074e55 | ||
|
|
1cfd96cdc2 | ||
|
|
e6572a0f08 | ||
|
|
fe3426b4c7 | ||
|
|
d6817d0f22 | ||
|
|
c93fd0d22b | ||
|
|
9584d8aa7d | ||
|
|
ea6e9099b9 | ||
|
|
72b7edbba9 | ||
|
|
6b71568eb7 | ||
|
|
ec649e707f | ||
|
|
3f4fd64311 | ||
|
|
aa37aece9c | ||
|
|
88c2afd1e3 | ||
|
|
6f58497647 | ||
|
|
88133c361e | ||
|
|
cfa484e1a2 | ||
|
|
ef351ac0dd | ||
|
|
06aa141632 | ||
|
|
3b1f99ded1 | ||
|
|
9280d39678 | ||
|
|
d7f452c0a1 | ||
|
|
09eaba91ad | ||
|
|
b5553c6ad2 | ||
|
|
de4c635e54 | ||
|
|
3ac8ab1791 | ||
|
|
bef6e2831a | ||
|
|
40503ef07a | ||
|
|
412c4c55e2 | ||
|
|
c434e47f2d | ||
|
|
a7d2024e35 | ||
|
|
1d04e01d1e | ||
|
|
9294a14a37 | ||
|
|
7f807fef6c | ||
|
|
35782f891d | ||
|
|
7b9a901489 | ||
|
|
c88bd53b1b | ||
|
|
2d65c1a950 | ||
|
|
4baf34cf25 | ||
|
|
8cdfd12977 | ||
|
|
76256d22d8 | ||
|
|
8c5c87be26 | ||
|
|
4f6fa3d63a | ||
|
|
dee95d0894 | ||
|
|
1007983159 | ||
|
|
51cc0e503b | ||
|
|
87a4c75fd4 | ||
|
|
ef0d740270 | ||
|
|
70a2797064 | ||
|
|
a1e429f7c3 | ||
|
|
526b0b6890 | ||
|
|
467eb2eca0 | ||
|
|
99ed54926b | ||
|
|
13d0b8e6a4 | ||
|
|
59ed846277 | ||
|
|
757ecf7e80 | ||
|
|
22c544bca7 | ||
|
|
f31588786f | ||
|
|
36ea837736 | ||
|
|
4005134263 | ||
|
|
0f05b2c13f | ||
|
|
1392a93445 | ||
|
|
7fffddce8e | ||
|
|
2990c2b1cf | ||
|
|
32c6823cf5 | ||
|
|
b788a5ba7e | ||
|
|
d8c05fc1b2 | ||
|
|
d3643fa151 | ||
|
|
96f73b3894 | ||
|
|
3a182d5dd6 | ||
|
|
6246fa32f0 | ||
|
|
c41837842b | ||
|
|
bb67999808 | ||
|
|
09363064b5 | ||
|
|
edc90ebc61 | ||
|
|
cfb5bd0559 | ||
|
|
db83926121 | ||
|
|
27a1ad6a70 | ||
|
|
4007601f73 | ||
|
|
3b55bf9306 | ||
|
|
bf2fa7b184 | ||
|
|
0d972678e7 | ||
|
|
0df3b76c25 | ||
|
|
c6ac82e3a6 | ||
|
|
0687195bee | ||
|
|
fbc079d82d | ||
|
|
2bac8b6013 | ||
|
|
03e3e6abcd | ||
|
|
a9b4bf1535 | ||
|
|
43f9c25fd2 | ||
|
|
c980bf01be | ||
|
|
a9f32656f5 | ||
|
|
80157f3f37 | ||
|
|
69b535c01f | ||
|
|
e428bba7a3 | ||
|
|
67325d334e | ||
|
|
1336fb740b | ||
|
|
81487169f0 | ||
|
|
3a926348a4 | ||
|
|
2a61261a5a | ||
|
|
928530a112 | ||
|
|
4d85cf586b | ||
|
|
575aacb1e2 | ||
|
|
7cd8c3e839 | ||
|
|
760740905e | ||
|
|
02e580c1d2 | ||
|
|
2903d42921 | ||
|
|
b005b79236 | ||
|
|
c16271fb46 | ||
|
|
0f95eaa8bb | ||
|
|
3f686688a6 | ||
|
|
c163b2a3f1 | ||
|
|
fc5839864b | ||
|
|
c81be7b899 | ||
|
|
36af49ae7f | ||
|
|
bded712e58 | ||
|
|
7cfc6130e5 | ||
|
|
eda647cb47 | ||
|
|
cc91a05686 | ||
|
|
3222bce02d | ||
|
|
acfe5bd33b | ||
|
|
ec4c49a338 | ||
|
|
53f6c3f9f2 | ||
|
|
48d43134d7 | ||
|
|
afb3f62b01 | ||
|
|
da601c23e1 | ||
|
|
e1c1d47991 | ||
|
|
9343967317 | ||
|
|
c48513b2be | ||
|
|
561f7be434 | ||
|
|
dd5548771e | ||
|
|
86069874c9 | ||
|
|
87b44aa311 | ||
|
|
4bb7440094 | ||
|
|
6dae9e47f9 | ||
|
|
4c75605e23 | ||
|
|
395cb588b6 | ||
|
|
d04afc697c | ||
|
|
5cd56fe8d5 | ||
|
|
a253057fc3 | ||
|
|
741ae9956e | ||
|
|
9f3ad40707 | ||
|
|
fd99c54e10 | ||
|
|
679415f3a8 | ||
|
|
c4e9739251 | ||
|
|
e409e59a54 | ||
|
|
025867fd07 | ||
|
|
7966aed1e0 | ||
|
|
35111061e9 | ||
|
|
d1d6ab068e | ||
|
|
c4f06ef7be | ||
|
|
46cb9d98a3 | ||
|
|
c1445155ef | ||
|
|
f9e86e64b7 | ||
|
|
2d1849a7b9 | ||
|
|
7ee3068f9d | ||
|
|
3e1f2d01f7 | ||
|
|
c60cbca371 | ||
|
|
ae483d3446 | ||
|
|
66f9292835 | ||
|
|
512573598a | ||
|
|
2a0b5c21d2 | ||
|
|
7f45734663 | ||
|
|
9e77660931 | ||
|
|
dc71d3559f | ||
|
|
9dee6adfab | ||
|
|
5b85f848dd | ||
|
|
a54671529b | ||
|
|
e3619b890c | ||
|
|
3012a2e1ca | ||
|
|
2ec371fe8b | ||
|
|
d915e0054c | ||
|
|
316523cc1e | ||
|
|
87ba528ae0 | ||
|
|
373176ea54 | ||
|
|
6bed781259 | ||
|
|
deb56f276e | ||
|
|
cfe68d0a86 | ||
|
|
6f5b91c94c | ||
|
|
2336c73d4d | ||
|
|
96fec4b969 | ||
|
|
eff6a404a6 | ||
|
|
71d401cc4e | ||
|
|
1237000efe | ||
|
|
488e63979e | ||
|
|
5a1ef1bbb9 | ||
|
|
e38d3dfc76 | ||
|
|
637cc1b5fc | ||
|
|
1aa75b1c9e | ||
|
|
adcb7e59d2 | ||
|
|
c88506caa6 | ||
|
|
3f7cc3563f | ||
|
|
c6c752cf64 | ||
|
|
50eb8c5add | ||
|
|
48e5f4ff88 | ||
|
|
21413392cf | ||
|
|
3601b43530 | ||
|
|
928d1fddd2 | ||
|
|
5fb8e01a8b | ||
|
|
80ba161c40 | ||
|
|
1a19aed410 | ||
|
|
e97209c6bf | ||
|
|
bbca2c78cb | ||
|
|
d819bb3bb0 | ||
|
|
2265587d38 | ||
|
|
78fededaa5 | ||
|
|
910ae68e0b | ||
|
|
c2eff20008 | ||
|
|
700bd37730 | ||
|
|
90b5f6286c | ||
|
|
db70774685 | ||
|
|
37c94c07cd | ||
|
|
a364bf2b62 | ||
|
|
c994eba763 | ||
|
|
31094d557b | ||
|
|
337c77964b | ||
|
|
8ac4d52b59 | ||
|
|
89832c1a95 | ||
|
|
695f8a1d7e |
@@ -1,4 +1,4 @@
|
||||
name: Android-Cross
|
||||
name: Wasm-Cross
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -25,17 +25,11 @@ jobs:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Android build cmd
|
||||
- name: Wasm client build
|
||||
env:
|
||||
GOOS: android
|
||||
GOARCH: amd64
|
||||
run: go build ./cmd/...
|
||||
|
||||
- name: Android build tests (does not run tests)
|
||||
env:
|
||||
GOOS: android
|
||||
GOARCH: amd64
|
||||
run: for d in $(go list -f '{{if .TestGoFiles}}{{.Dir}}{{end}}' ./... ); do (echo $d; cd $d && go test -run '^$' -c ); done
|
||||
GOOS: js
|
||||
GOARCH: wasm
|
||||
run: go build ./cmd/tsconnect/wasm
|
||||
|
||||
- uses: k0kubun/action-slack@v2.0.0
|
||||
with:
|
||||
11
.github/workflows/windows-race.yml
vendored
11
.github/workflows/windows-race.yml
vendored
@@ -43,6 +43,17 @@ jobs:
|
||||
# TODO(raggi): add a go version here.
|
||||
key: ${{ runner.os }}-go-2-race-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Print toolchain details
|
||||
run: gcc -v
|
||||
|
||||
# There is currently an issue in the race detector in Go on Windows when
|
||||
# used with a newer version of GCC.
|
||||
# See https://github.com/tailscale/tailscale/issues/4926.
|
||||
- name: Downgrade MinGW
|
||||
shell: bash
|
||||
run: |
|
||||
choco install mingw --version 10.2.0 --allow-downgrade
|
||||
|
||||
- name: Test with -race flag
|
||||
# Don't use -bench=. -benchtime=1x.
|
||||
# Somewhere in the layers (powershell?)
|
||||
|
||||
1
ALPINE.txt
Normal file
1
ALPINE.txt
Normal file
@@ -0,0 +1 @@
|
||||
3.16
|
||||
17
Dockerfile
17
Dockerfile
@@ -39,6 +39,18 @@ WORKDIR /go/src/tailscale
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Pre-build some stuff before the following COPY line invalidates the Docker cache.
|
||||
RUN go install \
|
||||
github.com/aws/aws-sdk-go-v2/aws \
|
||||
github.com/aws/aws-sdk-go-v2/config \
|
||||
gvisor.dev/gvisor/pkg/tcpip/adapters/gonet \
|
||||
gvisor.dev/gvisor/pkg/tcpip/stack \
|
||||
golang.org/x/crypto/ssh \
|
||||
golang.org/x/crypto/acme \
|
||||
nhooyr.io/websocket \
|
||||
github.com/mdlayher/netlink \
|
||||
golang.zx2c4.com/wireguard/device
|
||||
|
||||
COPY . .
|
||||
|
||||
# see build_docker.sh
|
||||
@@ -56,5 +68,8 @@ RUN GOARCH=$TARGETARCH go install -ldflags="\
|
||||
-X tailscale.com/version.GitCommit=$VERSION_GIT_HASH" \
|
||||
-v ./cmd/tailscale ./cmd/tailscaled
|
||||
|
||||
FROM ghcr.io/tailscale/alpine-base:3.14
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
|
||||
COPY --from=build-env /go/bin/* /usr/local/bin/
|
||||
COPY --from=build-env /go/src/tailscale/docs/k8s/run.sh /usr/local/bin/
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
FROM alpine:3.15
|
||||
FROM alpine:3.16
|
||||
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.23.0
|
||||
1.29.0
|
||||
|
||||
151
api.md
151
api.md
@@ -22,9 +22,9 @@ Currently based on {some authentication method}. Visit the [admin panel](https:/
|
||||
* **[Tailnets](#tailnet)**
|
||||
- ACLs
|
||||
- [GET tailnet ACL](#tailnet-acl-get)
|
||||
- [POST tailnet ACL](#tailnet-acl-post): set ACL for a tailnet
|
||||
- [POST tailnet ACL preview](#tailnet-acl-preview-post): preview rule matches on an ACL for a resource
|
||||
- [POST tailnet ACL validate](#tailnet-acl-validate-post): run validation tests against the tailnet's existing ACL
|
||||
- [POST tailnet ACL](#tailnet-acl-post)
|
||||
- [POST tailnet ACL preview](#tailnet-acl-preview-post)
|
||||
- [POST tailnet ACL validate](#tailnet-acl-validate-post)
|
||||
- [Devices](#tailnet-devices)
|
||||
- [GET tailnet devices](#tailnet-devices-get)
|
||||
- [Keys](#tailnet-keys)
|
||||
@@ -42,12 +42,12 @@ Currently based on {some authentication method}. Visit the [admin panel](https:/
|
||||
|
||||
## Device
|
||||
<!-- TODO: description about what devices are -->
|
||||
Each Tailscale-connected device has a globally-unique identifier number which we refer as the "deviceID" or sometimes, just "id".
|
||||
Each Tailscale-connected device has a globally-unique identifier number which we refer as the "deviceID" or sometimes, just "id".
|
||||
You can use the deviceID to specify operations on a specific device, like retrieving its subnet routes.
|
||||
|
||||
To find the deviceID of a particular device, you can use the ["GET /devices"](#getdevices) API call and generate a list of devices on your network.
|
||||
To find the deviceID of a particular device, you can use the ["GET /devices"](#getdevices) API call and generate a list of devices on your network.
|
||||
Find the device you're looking for and get the "id" field.
|
||||
This is your deviceID.
|
||||
This is your deviceID.
|
||||
|
||||
<a name=device-get></a>
|
||||
|
||||
@@ -60,7 +60,7 @@ Use the `fields` query parameter to explicitly indicate which fields are returne
|
||||
##### Parameters
|
||||
##### Query Parameters
|
||||
`fields` - Controls which fields will be included in the returned response.
|
||||
Currently, supported options are:
|
||||
Currently, supported options are:
|
||||
* `all`: returns all fields in the response.
|
||||
* `default`: return all fields except:
|
||||
* `enabledRoutes`
|
||||
@@ -72,7 +72,7 @@ If more than one option is indicated, then the union is used.
|
||||
For example, for `fields=default,all`, all fields are returned.
|
||||
If the `fields` parameter is not provided, then the default option is used.
|
||||
|
||||
##### Example
|
||||
##### Example
|
||||
```
|
||||
GET /api/v2/device/12345
|
||||
curl 'https://api.tailscale.com/api/v2/device/12345?fields=all' \
|
||||
@@ -102,10 +102,10 @@ Response
|
||||
"nodeKey":"nodekey:user1-node-key",
|
||||
"blocksIncomingConnections":false,
|
||||
"enabledRoutes":[
|
||||
|
||||
|
||||
],
|
||||
"advertisedRoutes":[
|
||||
|
||||
|
||||
],
|
||||
"clientConnectivity": {
|
||||
"endpoints":[
|
||||
@@ -141,7 +141,7 @@ Response
|
||||
<a name=device-delete></a>
|
||||
|
||||
#### `DELETE /api/v2/device/:deviceID` - deletes the device from its tailnet
|
||||
Deletes the provided device from its tailnet.
|
||||
Deletes the provided device from its tailnet.
|
||||
The device must belong to the user's tailnet.
|
||||
Deleting shared/external devices is not supported.
|
||||
Supply the device of interest in the path using its ID.
|
||||
@@ -159,7 +159,7 @@ curl -X DELETE 'https://api.tailscale.com/api/v2/device/12345' \
|
||||
|
||||
Response
|
||||
|
||||
If successful, the response should be empty:
|
||||
If successful, the response should be empty:
|
||||
```
|
||||
< HTTP/1.1 200 OK
|
||||
...
|
||||
@@ -167,7 +167,7 @@ If successful, the response should be empty:
|
||||
* Closing connection 0
|
||||
```
|
||||
|
||||
If the device is not owned by your tailnet:
|
||||
If the device is not owned by your tailnet:
|
||||
```
|
||||
< HTTP/1.1 501 Not Implemented
|
||||
...
|
||||
@@ -317,14 +317,9 @@ Allows for updating properties on the device key.
|
||||
- Provide `false` to enable the device's key expiry. Sets the key to expire at the original expiry time prior to disabling. The key may already have expired. In that case, the device must be re-authenticated.
|
||||
- Empty value will not change the key expiry.
|
||||
|
||||
`preauthorized`
|
||||
|
||||
- If `true`, don't require machine authorization (if enabled on the tailnet)
|
||||
|
||||
```
|
||||
{
|
||||
"keyExpiryDisabled": true,
|
||||
"preauthorized": true
|
||||
"keyExpiryDisabled": true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -339,8 +334,8 @@ curl 'https://api.tailscale.com/api/v2/device/11055/key' \
|
||||
The response is 2xx on success. The response body is currently an empty JSON
|
||||
object.
|
||||
|
||||
## Tailnet
|
||||
A tailnet is the name of your Tailscale network.
|
||||
## Tailnet
|
||||
A tailnet is the name of your Tailscale network.
|
||||
You can find it in the top left corner of the [Admin Panel](https://login.tailscale.com/admin) beside the Tailscale logo.
|
||||
|
||||
|
||||
@@ -620,7 +615,12 @@ Response:
|
||||
|
||||
#### `POST /api/v2/tailnet/:tailnet/acl/validate` - run validation tests against the tailnet's active ACL
|
||||
|
||||
Runs the provided ACL tests against the tailnet's existing ACL. This endpoint does not modify the ACL in any way.
|
||||
This endpoint works in one of two modes:
|
||||
|
||||
1. with a request body that's a JSON array, the body is interpreted as ACL tests to run against the domain's current ACLs.
|
||||
2. with a request body that's a JSON object, the body is interpreted as a hypothetical new JSON (HuJSON) body with new ACLs, including any tests.
|
||||
|
||||
In either case, this endpoint does not modify the ACL in any way.
|
||||
|
||||
##### Parameters
|
||||
|
||||
@@ -630,7 +630,7 @@ The POST body should be a JSON formatted array of ACL Tests.
|
||||
|
||||
See https://tailscale.com/kb/1018/acls for more information on the format of ACL tests.
|
||||
|
||||
##### Example
|
||||
##### Example with tests
|
||||
```
|
||||
POST /api/v2/tailnet/example.com/acl/validate
|
||||
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \
|
||||
@@ -641,11 +641,28 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \
|
||||
]'
|
||||
```
|
||||
|
||||
Response:
|
||||
If all the tests pass, the response will be empty, with an http status code of 200.
|
||||
##### Example with an ACL body
|
||||
```
|
||||
POST /api/v2/tailnet/example.com/acl/validate
|
||||
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \
|
||||
-u "tskey-yourapikey123:" \
|
||||
--data-binary '
|
||||
{
|
||||
"ACLs": [
|
||||
{ "Action": "accept", "src": ["100.105.106.107"], "dst": ["1.2.3.4:*"] },
|
||||
],
|
||||
"Tests", [
|
||||
{"src": "100.105.106.107", "allow": ["1.2.3.4:80"]}
|
||||
],
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
The HTTP status code will be 200 if the request was well formed and there were no server errors, even in the case of failing tests or an invalid ACL. Look at the response body to determine whether there was a problem within your ACL or tests.
|
||||
|
||||
If there's a problem, the response body will be a JSON object with a non-empty `message` property and optionally additional details in `data`:
|
||||
|
||||
Failed test error response:
|
||||
A 400 http status code and the errors in the response body.
|
||||
```
|
||||
{
|
||||
"message":"test(s) failed",
|
||||
@@ -658,6 +675,8 @@ A 400 http status code and the errors in the response body.
|
||||
}
|
||||
```
|
||||
|
||||
An empty body or a JSON object with no `message` is returned on success.
|
||||
|
||||
<a name=tailnet-devices></a>
|
||||
|
||||
### Devices
|
||||
@@ -665,7 +684,7 @@ A 400 http status code and the errors in the response body.
|
||||
<a name=tailnet-devices-get></a>
|
||||
|
||||
#### <a name="getdevices"></a> `GET /api/v2/tailnet/:tailnet/devices` - list the devices for a tailnet
|
||||
Lists the devices in a tailnet.
|
||||
Lists the devices in a tailnet.
|
||||
Supply the tailnet of interest in the path.
|
||||
Use the `fields` query parameter to explicitly indicate which fields are returned.
|
||||
|
||||
@@ -674,7 +693,7 @@ Use the `fields` query parameter to explicitly indicate which fields are returne
|
||||
|
||||
###### Query Parameters
|
||||
`fields` - Controls which fields will be included in the returned response.
|
||||
Currently, supported options are:
|
||||
Currently, supported options are:
|
||||
* `all`: Returns all fields in the response.
|
||||
* `default`: return all fields except:
|
||||
* `enabledRoutes`
|
||||
@@ -694,7 +713,7 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/devices' \
|
||||
-u "tskey-yourapikey123:"
|
||||
```
|
||||
|
||||
Response
|
||||
Response
|
||||
```
|
||||
{
|
||||
"devices":[
|
||||
@@ -796,11 +815,15 @@ Supply the tailnet in the path.
|
||||
`capabilities` - A mapping of resources to permissible actions.
|
||||
```
|
||||
{
|
||||
"capabilities": {
|
||||
"capabilities": {
|
||||
"devices": {
|
||||
"create": {
|
||||
"reusable": false,
|
||||
"ephemeral": false
|
||||
"ephemeral": false,
|
||||
"preauthorized": false,
|
||||
"tags": [
|
||||
"tag:example"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -820,11 +843,13 @@ The full key can no longer be retrieved by the server.
|
||||
|
||||
```
|
||||
echo '{
|
||||
"capabilities": {
|
||||
"capabilities": {
|
||||
"devices": {
|
||||
"create": {
|
||||
"reusable": false,
|
||||
"ephemeral": false
|
||||
"ephemeral": false,
|
||||
"preauthorized": false,
|
||||
"tags": [ "tag:example" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -840,7 +865,7 @@ Response:
|
||||
"key": "tskey-k123456CNTRL-abcdefghijklmnopqrstuvwxyz",
|
||||
"created": "2021-12-09T23:22:39Z",
|
||||
"expires": "2022-03-09T23:22:39Z",
|
||||
"capabilities": {"devices": {"create": {"reusable": false, "ephemeral": false}}}
|
||||
"capabilities": {"devices": {"create": {"reusable": false, "ephemeral": false, "preauthorized": false, "tags": [ "tag:example" ]}}}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -870,10 +895,22 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/keys/k123456CNTRL' \
|
||||
Response:
|
||||
```
|
||||
{
|
||||
"id": "k123456CNTRL",
|
||||
"created": "2021-12-09T22:13:53Z",
|
||||
"expires": "2022-03-09T22:13:53Z",
|
||||
"capabilities": {"devices": {"create": {"reusable": false, "ephemeral": false}}}
|
||||
"id": "k123456CNTRL",
|
||||
"created": "2022-05-05T18:55:44Z",
|
||||
"expires": "2022-08-03T18:55:44Z",
|
||||
"capabilities": {
|
||||
"devices": {
|
||||
"create": {
|
||||
"reusable": false,
|
||||
"ephemeral": true,
|
||||
"preauthorized": false,
|
||||
"tags": [
|
||||
"tag:bar",
|
||||
"tag:foo"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -904,13 +941,13 @@ curl -X DELETE 'https://api.tailscale.com/api/v2/tailnet/example.com/keys/k12345
|
||||
<a name=tailnet-dns-nameservers-get></a>
|
||||
|
||||
#### `GET /api/v2/tailnet/:tailnet/dns/nameservers` - list the DNS nameservers for a tailnet
|
||||
Lists the DNS nameservers for a tailnet.
|
||||
Lists the DNS nameservers for a tailnet.
|
||||
Supply the tailnet of interest in the path.
|
||||
|
||||
##### Parameters
|
||||
No parameters.
|
||||
|
||||
##### Example
|
||||
##### Example
|
||||
|
||||
```
|
||||
GET /api/v2/tailnet/example.com/dns/nameservers
|
||||
@@ -918,7 +955,7 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers' \
|
||||
-u "tskey-yourapikey123:"
|
||||
```
|
||||
|
||||
Response
|
||||
Response
|
||||
```
|
||||
{
|
||||
"dns": ["8.8.8.8"],
|
||||
@@ -928,7 +965,7 @@ Response
|
||||
<a name=tailnet-dns-nameservers-post></a>
|
||||
|
||||
#### `POST /api/v2/tailnet/:tailnet/dns/nameservers` - replaces the list of DNS nameservers for a tailnet
|
||||
Replaces the list of DNS nameservers for the given tailnet with the list supplied by the user.
|
||||
Replaces the list of DNS nameservers for the given tailnet with the list supplied by the user.
|
||||
Supply the tailnet of interest in the path.
|
||||
Note that changing the list of DNS nameservers may also affect the status of MagicDNS (if MagicDNS is on).
|
||||
|
||||
@@ -942,7 +979,7 @@ Note that changing the list of DNS nameservers may also affect the status of Mag
|
||||
```
|
||||
|
||||
##### Returns
|
||||
Returns the new list of nameservers and the status of MagicDNS.
|
||||
Returns the new list of nameservers and the status of MagicDNS.
|
||||
|
||||
If all nameservers have been removed, MagicDNS will be automatically disabled (until explicitly turned back on by the user).
|
||||
|
||||
@@ -986,31 +1023,31 @@ Retrieves the DNS preferences that are currently set for the given tailnet.
|
||||
Supply the tailnet of interest in the path.
|
||||
|
||||
##### Parameters
|
||||
No parameters.
|
||||
No parameters.
|
||||
|
||||
##### Example
|
||||
```
|
||||
GET /api/v2/tailnet/example.com/dns/preferences
|
||||
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/preferences' \
|
||||
-u "tskey-yourapikey123:"
|
||||
-u "tskey-yourapikey123:"
|
||||
```
|
||||
|
||||
Response:
|
||||
```
|
||||
{
|
||||
"magicDNS":false,
|
||||
"magicDNS":false,
|
||||
}
|
||||
```
|
||||
|
||||
<a name=tailnet-dns-preferences-post></a>
|
||||
|
||||
#### `POST /api/v2/tailnet/:tailnet/dns/preferences` - replaces the DNS preferences for a tailnet
|
||||
#### `POST /api/v2/tailnet/:tailnet/dns/preferences` - replaces the DNS preferences for a tailnet
|
||||
Replaces the DNS preferences for a tailnet, specifically, the MagicDNS setting.
|
||||
Note that MagicDNS is dependent on DNS servers.
|
||||
Note that MagicDNS is dependent on DNS servers.
|
||||
|
||||
If there is at least one DNS server, then MagicDNS can be enabled.
|
||||
If there is at least one DNS server, then MagicDNS can be enabled.
|
||||
Otherwise, it returns an error.
|
||||
Note that removing all nameservers will turn off MagicDNS.
|
||||
Note that removing all nameservers will turn off MagicDNS.
|
||||
To reenable it, nameservers must be added back, and MagicDNS must be explicitly turned on.
|
||||
|
||||
##### Parameters
|
||||
@@ -1041,7 +1078,7 @@ If there are no DNS servers, it returns an error message:
|
||||
}
|
||||
```
|
||||
|
||||
If there are DNS servers:
|
||||
If there are DNS servers:
|
||||
```
|
||||
{
|
||||
"magicDNS":true,
|
||||
@@ -1050,8 +1087,8 @@ If there are DNS servers:
|
||||
|
||||
<a name=tailnet-dns-searchpaths-get></a>
|
||||
|
||||
#### `GET /api/v2/tailnet/:tailnet/dns/searchpaths` - retrieves the search paths for a tailnet
|
||||
Retrieves the list of search paths that is currently set for the given tailnet.
|
||||
#### `GET /api/v2/tailnet/:tailnet/dns/searchpaths` - retrieves the search paths for a tailnet
|
||||
Retrieves the list of search paths that is currently set for the given tailnet.
|
||||
Supply the tailnet of interest in the path.
|
||||
|
||||
|
||||
@@ -1062,7 +1099,7 @@ No parameters.
|
||||
```
|
||||
GET /api/v2/tailnet/example.com/dns/searchpaths
|
||||
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/searchpaths' \
|
||||
-u "tskey-yourapikey123:"
|
||||
-u "tskey-yourapikey123:"
|
||||
```
|
||||
|
||||
Response:
|
||||
@@ -1074,7 +1111,7 @@ Response:
|
||||
|
||||
<a name=tailnet-dns-searchpaths-post></a>
|
||||
|
||||
#### `POST /api/v2/tailnet/:tailnet/dns/searchpaths` - replaces the search paths for a tailnet
|
||||
#### `POST /api/v2/tailnet/:tailnet/dns/searchpaths` - replaces the search paths for a tailnet
|
||||
Replaces the list of searchpaths with the list supplied by the user and returns an error otherwise.
|
||||
|
||||
##### Parameters
|
||||
@@ -1083,7 +1120,7 @@ Replaces the list of searchpaths with the list supplied by the user and returns
|
||||
`searchPaths` - A list of searchpaths in JSON.
|
||||
```
|
||||
{
|
||||
"searchPaths: ["user1.example.com", "user2.example.com"]
|
||||
"searchPaths": ["user1.example.com", "user2.example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -25,14 +25,14 @@ export PATH=$PWD/tool:$PATH
|
||||
eval $(./build_dist.sh shellvars)
|
||||
DEFAULT_TAGS="v${VERSION_SHORT},v${VERSION_MINOR}"
|
||||
DEFAULT_REPOS="tailscale/tailscale,ghcr.io/tailscale/tailscale"
|
||||
DEFAULT_BASE="ghcr.io/tailscale/alpine-base:3.14"
|
||||
DEFAULT_BASE="ghcr.io/tailscale/alpine-base:3.16"
|
||||
|
||||
PUSH="${PUSH:-false}"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
TAGS="${TAGS:-${DEFAULT_TAGS}}"
|
||||
BASE="${BASE:-${DEFAULT_BASE}}"
|
||||
|
||||
go run github.com/tailscale/mkctr@latest \
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="\
|
||||
tailscale.com/cmd/tailscale:/usr/local/bin/tailscale, \
|
||||
tailscale.com/cmd/tailscaled:/usr/local/bin/tailscaled" \
|
||||
@@ -40,7 +40,9 @@ go run github.com/tailscale/mkctr@latest \
|
||||
-X tailscale.com/version.Long=${VERSION_LONG} \
|
||||
-X tailscale.com/version.Short=${VERSION_SHORT} \
|
||||
-X tailscale.com/version.GitCommit=${VERSION_GIT_HASH}" \
|
||||
--files="docs/k8s/run.sh:/tailscale/run.sh" \
|
||||
--base="${BASE}" \
|
||||
--tags="${TAGS}" \
|
||||
--repos="${REPOS}" \
|
||||
--push="${PUSH}"
|
||||
--push="${PUSH}" \
|
||||
/bin/sh /tailscale/run.sh
|
||||
|
||||
478
client/tailscale/acl.go
Normal file
478
client/tailscale/acl.go
Normal file
@@ -0,0 +1,478 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"inet.af/netaddr"
|
||||
)
|
||||
|
||||
// ACLRow defines a rule that grants access by a set of users or groups to a set
|
||||
// of servers and ports.
|
||||
// Only one of Src/Dst or Users/Ports may be specified.
|
||||
type ACLRow struct {
|
||||
Action string `json:"action,omitempty"` // valid values: "accept"
|
||||
Users []string `json:"users,omitempty"` // old name for src
|
||||
Ports []string `json:"ports,omitempty"` // old name for dst
|
||||
Src []string `json:"src,omitempty"`
|
||||
Dst []string `json:"dst,omitempty"`
|
||||
}
|
||||
|
||||
// ACLTest defines a test for your ACLs to prevent accidental exposure or
|
||||
// revoking of access to key servers and ports. Only one of Src or User may be
|
||||
// specified, and only one of Allow/Accept may be specified.
|
||||
type ACLTest struct {
|
||||
Src string `json:"src,omitempty"` // source
|
||||
User string `json:"user,omitempty"` // old name for source
|
||||
Accept []string `json:"accept,omitempty"` // expected destination ip:port that user can access
|
||||
Deny []string `json:"deny,omitempty"` // expected destination ip:port that user cannot access
|
||||
|
||||
Allow []string `json:"allow,omitempty"` // old name for accept
|
||||
}
|
||||
|
||||
// ACLDetails contains all the details for an ACL.
|
||||
type ACLDetails struct {
|
||||
Tests []ACLTest `json:"tests,omitempty"`
|
||||
ACLs []ACLRow `json:"acls,omitempty"`
|
||||
Groups map[string][]string `json:"groups,omitempty"`
|
||||
TagOwners map[string][]string `json:"tagowners,omitempty"`
|
||||
Hosts map[string]string `json:"hosts,omitempty"`
|
||||
}
|
||||
|
||||
// ACL contains an ACLDetails and metadata.
|
||||
type ACL struct {
|
||||
ACL ACLDetails
|
||||
ETag string // to check with version on server
|
||||
}
|
||||
|
||||
// ACLHuJSON contains the HuJSON string of the ACL and metadata.
|
||||
type ACLHuJSON struct {
|
||||
ACL string
|
||||
Warnings []string
|
||||
ETag string // to check with version on server
|
||||
}
|
||||
|
||||
// ACL makes a call to the Tailscale server to get a JSON-parsed version of the ACL.
|
||||
// The JSON-parsed version of the ACL contains no comments as proper JSON does not support
|
||||
// comments.
|
||||
func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.ACL: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
// Otherwise, try to decode the response.
|
||||
var aclDetails ACLDetails
|
||||
if err = json.Unmarshal(b, &aclDetails); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acl = &ACL{
|
||||
ACL: aclDetails,
|
||||
ETag: resp.Header.Get("ETag"),
|
||||
}
|
||||
return acl, nil
|
||||
}
|
||||
|
||||
// ACLHuJSON makes a call to the Tailscale server to get the ACL HuJSON and returns
|
||||
// it as a string.
|
||||
// HuJSON is JSON with a few modifications to make it more human-friendly. The primary
|
||||
// changes are allowing comments and trailing comments. See the following links for more info:
|
||||
// https://tailscale.com/kb/1018/acls?q=acl#tailscale-acl-policy-format
|
||||
// https://github.com/tailscale/hujson
|
||||
func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.ACLHuJSON: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl?details=1", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/hujson")
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
ACL []byte `json:"acl"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}{}
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
acl = &ACLHuJSON{
|
||||
ACL: string(data.ACL),
|
||||
Warnings: data.Warnings,
|
||||
ETag: resp.Header.Get("ETag"),
|
||||
}
|
||||
return acl, nil
|
||||
}
|
||||
|
||||
// ACLTestFailureSummary specifies a user for which ACL tests
|
||||
// failed and the related user-friendly error messages.
|
||||
//
|
||||
// ACLTestFailureSummary specifies the JSON format sent to the
|
||||
// JavaScript client to be rendered in the HTML.
|
||||
type ACLTestFailureSummary struct {
|
||||
User string `json:"user"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// ACLTestError is ErrResponse but with an extra field to account for ACLTestFailureSummary.
|
||||
type ACLTestError struct {
|
||||
ErrResponse
|
||||
Data []ACLTestFailureSummary `json:"data"`
|
||||
}
|
||||
|
||||
func (e ACLTestError) Error() string {
|
||||
return fmt.Sprintf("%s, Data: %+v", e.ErrResponse.Error(), e.Data)
|
||||
}
|
||||
|
||||
func (c *Client) aclPOSTRequest(ctx context.Context, body []byte, avoidCollisions bool, etag, acceptHeader string) ([]byte, string, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if avoidCollisions {
|
||||
req.Header.Set("If-Match", etag)
|
||||
}
|
||||
req.Header.Set("Accept", acceptHeader)
|
||||
req.Header.Set("Content-Type", "application/hujson")
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// check if test error
|
||||
var ate ACLTestError
|
||||
if err := json.Unmarshal(b, &ate); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
ate.Status = resp.StatusCode
|
||||
return nil, "", ate
|
||||
}
|
||||
return b, resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
// SetACL sends a POST request to update the ACL according to the provided ACL object. If
|
||||
// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match
|
||||
// header to check if the previously obtained ACL was the latest version and that no updates
|
||||
// were missed.
|
||||
//
|
||||
// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true.
|
||||
// Returns error if ACL has tests that fail.
|
||||
// Returns error if there are other errors with the ACL.
|
||||
func (c *Client) SetACL(ctx context.Context, acl ACL, avoidCollisions bool) (res *ACL, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetACL: %w", err)
|
||||
}
|
||||
}()
|
||||
postData, err := json.Marshal(acl.ACL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Otherwise, try to decode the response.
|
||||
var aclDetails ACLDetails
|
||||
if err = json.Unmarshal(b, &aclDetails); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res = &ACL{
|
||||
ACL: aclDetails,
|
||||
ETag: etag,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SetACLHuJSON sends a POST request to update the ACL according to the provided ACL object. If
|
||||
// `avoidCollisions` is true, it will use the ETag obtained in the GET request in an If-Match
|
||||
// header to check if the previously obtained ACL was the latest version and that no updates
|
||||
// were missed.
|
||||
//
|
||||
// Returns error with status code 412 if mistmached ETag and avoidCollisions is set to true.
|
||||
// Returns error if the HuJSON is invalid.
|
||||
// Returns error if ACL has tests that fail.
|
||||
// Returns error if there are other errors with the ACL.
|
||||
func (c *Client) SetACLHuJSON(ctx context.Context, acl ACLHuJSON, avoidCollisions bool) (res *ACLHuJSON, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetACLHuJSON: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
postData := []byte(acl.ACL)
|
||||
b, etag, err := c.aclPOSTRequest(ctx, postData, avoidCollisions, acl.ETag, "application/hujson")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res = &ACLHuJSON{
|
||||
ACL: string(b),
|
||||
ETag: etag,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UserRuleMatch specifies the source users/groups/hosts that a rule targets
|
||||
// and the destination ports that they can access.
|
||||
// LineNumber is only useful for requests provided in HuJSON form.
|
||||
// While JSON requests will have LineNumber, the value is not useful.
|
||||
type UserRuleMatch struct {
|
||||
Users []string `json:"users"`
|
||||
Ports []string `json:"ports"`
|
||||
LineNumber int `json:"lineNumber"`
|
||||
}
|
||||
|
||||
// ACLPreviewResponse is the response type of previewACLPostRequest
|
||||
type ACLPreviewResponse struct {
|
||||
Matches []UserRuleMatch `json:"matches"` // ACL rules that match the specified user or ipport.
|
||||
Type string `json:"type"` // The request type: currently only "user" or "ipport".
|
||||
PreviewFor string `json:"previewFor"` // A specific user or ipport.
|
||||
}
|
||||
|
||||
// ACLPreview is the response type of PreviewACLForUser, PreviewACLForIPPort, PreviewACLHuJSONForUser, and PreviewACLHuJSONForIPPort
|
||||
type ACLPreview struct {
|
||||
Matches []UserRuleMatch `json:"matches"`
|
||||
User string `json:"user,omitempty"` // Filled if response of PreviewACLForUser or PreviewACLHuJSONForUser
|
||||
IPPort string `json:"ipport,omitempty"` // Filled if response of PreviewACLForIPPort or PreviewACLHuJSONForIPPort
|
||||
}
|
||||
|
||||
func (c *Client) previewACLPostRequest(ctx context.Context, body []byte, previewType string, previewFor string) (res *ACLPreviewResponse, err error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/preview", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Add("type", previewType)
|
||||
q.Add("previewFor", previewFor)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Content-Type", "application/hujson")
|
||||
c.setAuth(req)
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
if err = json.Unmarshal(b, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// PreviewACLForUser determines what rules match a given ACL for a user.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLForUser(ctx context.Context, acl ACL, user string) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLForUser: %w", err)
|
||||
}
|
||||
}()
|
||||
postData, err := json.Marshal(acl.ACL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "user", user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PreviewACLForIPPort determines what rules match a given ACL for a ipport.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLForIPPort(ctx context.Context, acl ACL, ipport netaddr.IPPort) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLForIPPort: %w", err)
|
||||
}
|
||||
}()
|
||||
postData, err := json.Marshal(acl.ACL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PreviewACLHuJSONForUser determines what rules match a given ACL for a user.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLHuJSONForUser(ctx context.Context, acl ACLHuJSON, user string) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLHuJSONForUser: %w", err)
|
||||
}
|
||||
}()
|
||||
postData := []byte(acl.ACL)
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "user", user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
User: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PreviewACLHuJSONForIPPort determines what rules match a given ACL for a ipport.
|
||||
// The ACL can be a locally modified or clean ACL obtained from server.
|
||||
//
|
||||
// Returns ACLPreview on success with matches in a slice. If there are no matches,
|
||||
// the call is still successful but Matches will be an empty slice.
|
||||
// Returns error if the provided ACL is invalid.
|
||||
func (c *Client) PreviewACLHuJSONForIPPort(ctx context.Context, acl ACLHuJSON, ipport string) (res *ACLPreview, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.PreviewACLHuJSONForIPPort: %w", err)
|
||||
}
|
||||
}()
|
||||
postData := []byte(acl.ACL)
|
||||
b, err := c.previewACLPostRequest(ctx, postData, "ipport", ipport)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ACLPreview{
|
||||
Matches: b.Matches,
|
||||
IPPort: b.PreviewFor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateACLJSON takes in the given source and destination (in this situation,
|
||||
// it is assumed that you are checking whether the source can connect to destination)
|
||||
// and creates an ACLTest from that. It then sends the ACLTest to the control api acl
|
||||
// validate endpoint, where the test is run. It returns a nil ACLTestError pointer if
|
||||
// no test errors occur.
|
||||
func (c *Client) ValidateACLJSON(ctx context.Context, source, dest string) (testErr *ACLTestError, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.ValidateACLJSON: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []ACLTest{ACLTest{User: source, Allow: []string{dest}}}
|
||||
postData, err := json.Marshal(tests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/acl/validate", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(postData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setAuth(req)
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("control api responsed with %d status code", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The test ran without fail
|
||||
if len(b) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var res ACLTestError
|
||||
// The test returned errors.
|
||||
if err = json.Unmarshal(b, &res); err != nil {
|
||||
// failed to unmarshal
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package apitype contains types for the Tailscale local API.
|
||||
// Package apitype contains types for the Tailscale local API and control plane API.
|
||||
package apitype
|
||||
|
||||
import "tailscale.com/tailcfg"
|
||||
|
||||
20
client/tailscale/apitype/controltype.go
Normal file
20
client/tailscale/apitype/controltype.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package apitype
|
||||
|
||||
type DNSConfig struct {
|
||||
Resolvers []DNSResolver `json:"resolvers"`
|
||||
FallbackResolvers []DNSResolver `json:"fallbackResolvers"`
|
||||
Routes map[string][]DNSResolver `json:"routes"`
|
||||
Domains []string `json:"domains"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
Proxied bool `json:"proxied"`
|
||||
PerDomain bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
type DNSResolver struct {
|
||||
Addr string `json:"addr"`
|
||||
BootstrapResolution []string `json:"bootstrapResolution,omitempty"`
|
||||
}
|
||||
262
client/tailscale/devices.go
Normal file
262
client/tailscale/devices.go
Normal file
@@ -0,0 +1,262 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/types/opt"
|
||||
)
|
||||
|
||||
type GetDevicesResponse struct {
|
||||
Devices []*Device `json:"devices"`
|
||||
}
|
||||
|
||||
type DerpRegion struct {
|
||||
Preferred bool `json:"preferred,omitempty"`
|
||||
LatencyMilliseconds float64 `json:"latencyMs"`
|
||||
}
|
||||
|
||||
type ClientConnectivity struct {
|
||||
Endpoints []string `json:"endpoints"`
|
||||
DERP string `json:"derp"`
|
||||
MappingVariesByDestIP opt.Bool `json:"mappingVariesByDestIP"`
|
||||
// DERPLatency is mapped by region name (e.g. "New York City", "Seattle").
|
||||
DERPLatency map[string]DerpRegion `json:"latency"`
|
||||
ClientSupports map[string]opt.Bool `json:"clientSupports"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
// Addresses is a list of the devices's Tailscale IP addresses.
|
||||
// It's currently just 1 element, the 100.x.y.z Tailscale IP.
|
||||
Addresses []string `json:"addresses"`
|
||||
DeviceID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
|
||||
ClientVersion string `json:"clientVersion"` // Empty for external devices.
|
||||
UpdateAvailable bool `json:"updateAvailable"` // Empty for external devices.
|
||||
OS string `json:"os"`
|
||||
Created string `json:"created"` // Empty for external devices.
|
||||
LastSeen string `json:"lastSeen"`
|
||||
KeyExpiryDisabled bool `json:"keyExpiryDisabled"`
|
||||
Expires string `json:"expires"`
|
||||
Authorized bool `json:"authorized"`
|
||||
IsExternal bool `json:"isExternal"`
|
||||
MachineKey string `json:"machineKey"` // Empty for external devices.
|
||||
NodeKey string `json:"nodeKey"`
|
||||
|
||||
// BlocksIncomingConnections is configured via the device's
|
||||
// Tailscale client preferences. This field is only reported
|
||||
// to the API starting with Tailscale 1.3.x clients.
|
||||
BlocksIncomingConnections bool `json:"blocksIncomingConnections"`
|
||||
|
||||
// The following fields are not included by default:
|
||||
|
||||
// EnabledRoutes are the previously-approved subnet routes
|
||||
// (e.g. "192.168.4.16/24", "10.5.2.4/32").
|
||||
EnabledRoutes []string `json:"enabledRoutes"` // Empty for external devices.
|
||||
// AdvertisedRoutes are the subnets (both enabled and not enabled)
|
||||
// being requested from the node.
|
||||
AdvertisedRoutes []string `json:"advertisedRoutes"` // Empty for external devices.
|
||||
|
||||
ClientConnectivity *ClientConnectivity `json:"clientConnectivity"`
|
||||
}
|
||||
|
||||
// DeviceFieldsOpts determines which fields should be returned in the response.
|
||||
//
|
||||
// Please only use DeviceAllFields and DeviceDefaultFields.
|
||||
// Other DeviceFieldsOpts are not supported.
|
||||
//
|
||||
// TODO: Support other DeviceFieldsOpts.
|
||||
// In the future, users should be able to create their own DeviceFieldsOpts
|
||||
// as valid arguments by setting the fields they want returned to a "non-nil"
|
||||
// value. For example, DeviceFieldsOpts{NodeID: "true"} should only return NodeIDs.
|
||||
type DeviceFieldsOpts Device
|
||||
|
||||
func (d *DeviceFieldsOpts) addFieldsToQueryParameter() string {
|
||||
if d == DeviceDefaultFields || d == nil {
|
||||
return "default"
|
||||
}
|
||||
if d == DeviceAllFields {
|
||||
return "all"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
DeviceAllFields = &DeviceFieldsOpts{}
|
||||
|
||||
// DeviceDefaultFields specifies that the following fields are returned:
|
||||
// Addresses, NodeID, User, Name, Hostname, ClientVersion, UpdateAvailable,
|
||||
// OS, Created, LastSeen, KeyExpiryDisabled, Expires, Authorized, IsExternal
|
||||
// MachineKey, NodeKey, BlocksIncomingConnections.
|
||||
DeviceDefaultFields = &DeviceFieldsOpts{}
|
||||
)
|
||||
|
||||
// Devices retrieves the list of devices for a tailnet.
|
||||
//
|
||||
// See the Device structure for the list of fields hidden for external devices.
|
||||
// The optional fields parameter specifies which fields of the devices to return; currently
|
||||
// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported.
|
||||
// Other values are currently undefined.
|
||||
func (c *Client) Devices(ctx context.Context, fields *DeviceFieldsOpts) (deviceList []*Device, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.Devices: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/devices", c.baseURL(), c.tailnet)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Add fields.
|
||||
fieldStr := fields.addFieldsToQueryParameter()
|
||||
q := req.URL.Query()
|
||||
q.Add("fields", fieldStr)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var devices GetDevicesResponse
|
||||
err = json.Unmarshal(b, &devices)
|
||||
return devices.Devices, err
|
||||
}
|
||||
|
||||
// Device retrieved the details for a specific device.
|
||||
//
|
||||
// See the Device structure for the list of fields hidden for an external device.
|
||||
// The optional fields parameter specifies which fields of the devices to return; currently
|
||||
// only DeviceDefaultFields (equivalent to nil) and DeviceAllFields are supported.
|
||||
// Other values are currently undefined.
|
||||
func (c *Client) Device(ctx context.Context, deviceID string, fields *DeviceFieldsOpts) (device *Device, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.Device: %w", err)
|
||||
}
|
||||
}()
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s", c.baseURL(), deviceID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add fields.
|
||||
fieldStr := fields.addFieldsToQueryParameter()
|
||||
q := req.URL.Query()
|
||||
q.Add("fields", fieldStr)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(b, &device)
|
||||
return device, err
|
||||
}
|
||||
|
||||
// DeleteDevice deletes the specified device from the Client's tailnet.
|
||||
// NOTE: Only devices that belong to the Client's tailnet can be deleted.
|
||||
// Deleting external devices is not supported.
|
||||
func (c *Client) DeleteDevice(ctx context.Context, deviceID string) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DeleteDevice: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s", c.baseURL(), url.PathEscape(deviceID))
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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 {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizeDevice marks a device as authorized.
|
||||
func (c *Client) AuthorizeDevice(ctx context.Context, deviceID string) error {
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/authorized", c.baseURL(), url.PathEscape(deviceID))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, strings.NewReader(`{"authorized":true}`))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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 {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTags updates the ACL tags on a device.
|
||||
func (c *Client) SetTags(ctx context.Context, deviceID string, tags []string) error {
|
||||
params := &struct {
|
||||
Tags []string `json:"tags"`
|
||||
}{Tags: tags}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/tags", c.baseURL(), url.PathEscape(deviceID))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 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 {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
235
client/tailscale/dns.go
Normal file
235
client/tailscale/dns.go
Normal file
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
)
|
||||
|
||||
// DNSNameServers is returned when retrieving the list of nameservers.
|
||||
// It is also the structure provided when setting nameservers.
|
||||
type DNSNameServers struct {
|
||||
DNS []string `json:"dns"` // DNS name servers
|
||||
}
|
||||
|
||||
// DNSNameServersPostResponse is returned when setting the list of DNS nameservers.
|
||||
//
|
||||
// It includes the MagicDNS status since nameservers changes may affect MagicDNS.
|
||||
type DNSNameServersPostResponse struct {
|
||||
DNS []string `json:"dns"` // DNS name servers
|
||||
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
|
||||
}
|
||||
|
||||
// DNSSearchpaths is the list of search paths for a given domain.
|
||||
type DNSSearchPaths struct {
|
||||
SearchPaths []string `json:"searchPaths"` // DNS search paths
|
||||
}
|
||||
|
||||
// DNSPreferences is the preferences set for a given tailnet.
|
||||
//
|
||||
// It includes MagicDNS which can be turned on or off. To enable MagicDNS,
|
||||
// there must be at least one nameserver. When all nameservers are removed,
|
||||
// MagicDNS is disabled.
|
||||
type DNSPreferences struct {
|
||||
MagicDNS bool `json:"magicDNS"` // whether MagicDNS is active for this tailnet (enabled + has fallback nameservers)
|
||||
}
|
||||
|
||||
func (c *Client) dnsGETRequest(ctx context.Context, endpoint string) ([]byte, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (c *Client) dnsPOSTRequest(ctx context.Context, endpoint string, postData interface{}) ([]byte, error) {
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s/dns/%s", c.baseURL(), c.tailnet, endpoint)
|
||||
data, err := json.Marshal(&postData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// DNSConfig retrieves the DNSConfig settings for a domain.
|
||||
func (c *Client) DNSConfig(ctx context.Context) (cfg *apitype.DNSConfig, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DNSConfig: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp apitype.DNSConfig
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return &dnsResp, err
|
||||
}
|
||||
|
||||
func (c *Client) SetDNSConfig(ctx context.Context, cfg apitype.DNSConfig) (resp *apitype.DNSConfig, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetDNSConfig: %w", err)
|
||||
}
|
||||
}()
|
||||
var dnsResp apitype.DNSConfig
|
||||
b, err := c.dnsPOSTRequest(ctx, "config", cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return &dnsResp, err
|
||||
}
|
||||
|
||||
// NameServers retrieves the list of nameservers set for a domain.
|
||||
func (c *Client) NameServers(ctx context.Context) (nameservers []string, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.NameServers: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "nameservers")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp DNSNameServers
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp.DNS, err
|
||||
}
|
||||
|
||||
// SetNameServers sets the list of nameservers for a tailnet to the list provided
|
||||
// by the user.
|
||||
//
|
||||
// It returns the new list of nameservers and the MagicDNS status in case it was
|
||||
// affected by the change. For example, removing all nameservers will turn off
|
||||
// MagicDNS.
|
||||
func (c *Client) SetNameServers(ctx context.Context, nameservers []string) (dnsResp *DNSNameServersPostResponse, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetNameServers: %w", err)
|
||||
}
|
||||
}()
|
||||
dnsReq := DNSNameServers{DNS: nameservers}
|
||||
b, err := c.dnsPOSTRequest(ctx, "nameservers", dnsReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp, err
|
||||
}
|
||||
|
||||
// DNSPreferences retrieves the DNS preferences set for a tailnet.
|
||||
//
|
||||
// It returns the status of MagicDNS.
|
||||
func (c *Client) DNSPreferences(ctx context.Context) (dnsResp *DNSPreferences, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DNSPreferences: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "preferences")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp, err
|
||||
}
|
||||
|
||||
// SetDNSPreferences sets the DNS preferences for a tailnet.
|
||||
//
|
||||
// MagicDNS can only be enabled when there is at least one nameserver provided.
|
||||
// When all nameservers are removed, MagicDNS is disabled and will stay disabled,
|
||||
// unless explicitly enabled by a user again.
|
||||
func (c *Client) SetDNSPreferences(ctx context.Context, magicDNS bool) (dnsResp *DNSPreferences, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetDNSPreferences: %w", err)
|
||||
}
|
||||
}()
|
||||
dnsReq := DNSPreferences{MagicDNS: magicDNS}
|
||||
b, err := c.dnsPOSTRequest(ctx, "preferences", dnsReq)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp, err
|
||||
}
|
||||
|
||||
// SearchPaths retrieves the list of searchpaths set for a tailnet.
|
||||
func (c *Client) SearchPaths(ctx context.Context) (searchpaths []string, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SearchPaths: %w", err)
|
||||
}
|
||||
}()
|
||||
b, err := c.dnsGETRequest(ctx, "searchpaths")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp *DNSSearchPaths
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp.SearchPaths, err
|
||||
}
|
||||
|
||||
// SetSearchPaths sets the list of searchpaths for a tailnet.
|
||||
func (c *Client) SetSearchPaths(ctx context.Context, searchpaths []string) (newSearchPaths []string, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetSearchPaths: %w", err)
|
||||
}
|
||||
}()
|
||||
dnsReq := DNSSearchPaths{SearchPaths: searchpaths}
|
||||
b, err := c.dnsPOSTRequest(ctx, "searchpaths", dnsReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dnsResp DNSSearchPaths
|
||||
err = json.Unmarshal(b, &dnsResp)
|
||||
return dnsResp.SearchPaths, err
|
||||
}
|
||||
711
client/tailscale/localclient.go
Normal file
711
client/tailscale/localclient.go
Normal file
@@ -0,0 +1,711 @@
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// defaultLocalClient is the default LocalClient when using the legacy
|
||||
// package-level functions.
|
||||
var defaultLocalClient LocalClient
|
||||
|
||||
// LocalClient is a client to Tailscale's "local API", communicating with the
|
||||
// Tailscale daemon on the local machine. Its API is not necessarily stable and
|
||||
// subject to changes between releases. Some API calls have stricter
|
||||
// compatibility guarantees, once they've been widely adopted. See method docs
|
||||
// for details.
|
||||
//
|
||||
// Its zero value is valid to use.
|
||||
//
|
||||
// Any exported fields should be set before using methods on the type
|
||||
// and not changed thereafter.
|
||||
type LocalClient struct {
|
||||
// Dial optionally specifies an alternate func that connects to the local
|
||||
// machine's tailscaled or equivalent. If nil, a default is used.
|
||||
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// Socket specifies an alternate path to the local Tailscale socket.
|
||||
// If empty, a platform-specific default is used.
|
||||
Socket string
|
||||
|
||||
// UseSocketOnly, if true, tries to only connect to tailscaled via the
|
||||
// Unix socket and not via fallback mechanisms as done on macOS when
|
||||
// connecting to the GUI client variants.
|
||||
UseSocketOnly bool
|
||||
|
||||
// tsClient does HTTP requests to the local Tailscale daemon.
|
||||
// It's lazily initialized on first use.
|
||||
tsClient *http.Client
|
||||
tsClientOnce sync.Once
|
||||
}
|
||||
|
||||
func (lc *LocalClient) socket() string {
|
||||
if lc.Socket != "" {
|
||||
return lc.Socket
|
||||
}
|
||||
return paths.DefaultTailscaledSocket()
|
||||
}
|
||||
|
||||
func (lc *LocalClient) dialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if lc.Dial != nil {
|
||||
return lc.Dial
|
||||
}
|
||||
return lc.defaultDialer
|
||||
}
|
||||
|
||||
func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if addr != "local-tailscaled.sock:80" {
|
||||
return nil, fmt.Errorf("unexpected URL address %q", addr)
|
||||
}
|
||||
if !lc.UseSocketOnly {
|
||||
// On macOS, when dialing from non-sandboxed program to sandboxed GUI running
|
||||
// a TCP server on a random port, find the random port. For HTTP connections,
|
||||
// we don't send the token. It gets added in an HTTP Basic-Auth header.
|
||||
if port, _, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port))
|
||||
}
|
||||
}
|
||||
s := safesocket.DefaultConnectionStrategy(lc.socket())
|
||||
// The user provided a non-default tailscaled socket address.
|
||||
// Connect only to exactly what they provided.
|
||||
s.UseFallback(false)
|
||||
return safesocket.Connect(s)
|
||||
}
|
||||
|
||||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||||
//
|
||||
// URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4.
|
||||
//
|
||||
// The hostname must be "local-tailscaled.sock", even though it
|
||||
// doesn't actually do any DNS lookup. The actual means of connecting to and
|
||||
// authenticating to the local Tailscale daemon vary by platform.
|
||||
//
|
||||
// DoLocalRequest may mutate the request to add Authorization headers.
|
||||
func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error) {
|
||||
lc.tsClientOnce.Do(func() {
|
||||
lc.tsClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: lc.dialer(),
|
||||
},
|
||||
}
|
||||
})
|
||||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
req.SetBasicAuth("", token)
|
||||
}
|
||||
return lc.tsClient.Do(req)
|
||||
}
|
||||
|
||||
func (lc *LocalClient) doLocalRequestNiceError(req *http.Request) (*http.Response, error) {
|
||||
res, err := lc.DoLocalRequest(req)
|
||||
if err == nil {
|
||||
if server := res.Header.Get("Tailscale-Version"); server != "" && server != ipn.IPCVersion() && onVersionMismatch != nil {
|
||||
onVersionMismatch(ipn.IPCVersion(), server)
|
||||
}
|
||||
if res.StatusCode == 403 {
|
||||
all, _ := ioutil.ReadAll(res.Body)
|
||||
return nil, &AccessDeniedError{errors.New(errorMessageFromBody(all))}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
if ue, ok := err.(*url.Error); ok {
|
||||
if oe, ok := ue.Err.(*net.OpError); ok && oe.Op == "dial" {
|
||||
path := req.URL.Path
|
||||
pathPrefix, _, _ := strings.Cut(path, "?")
|
||||
return nil, fmt.Errorf("Failed to connect to local Tailscale daemon for %s; %s Error: %w", pathPrefix, tailscaledConnectHint(), oe)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type errorJSON struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// AccessDeniedError is an error due to permissions.
|
||||
type AccessDeniedError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *AccessDeniedError) Error() string { return fmt.Sprintf("Access denied: %v", e.err) }
|
||||
func (e *AccessDeniedError) Unwrap() error { return e.err }
|
||||
|
||||
// IsAccessDeniedError reports whether err is or wraps an AccessDeniedError.
|
||||
func IsAccessDeniedError(err error) bool {
|
||||
var ae *AccessDeniedError
|
||||
return errors.As(err, &ae)
|
||||
}
|
||||
|
||||
// bestError returns either err, or if body contains a valid JSON
|
||||
// object of type errorJSON, its non-empty error body.
|
||||
func bestError(err error, body []byte) error {
|
||||
var j errorJSON
|
||||
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
|
||||
return errors.New(j.Error)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func errorMessageFromBody(body []byte) string {
|
||||
var j errorJSON
|
||||
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
|
||||
return j.Error
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
var onVersionMismatch func(clientVer, serverVer string)
|
||||
|
||||
// SetVersionMismatchHandler sets f as the version mismatch handler
|
||||
// to be called when the client (the current process) has a version
|
||||
// number that doesn't match the server's declared version.
|
||||
func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
|
||||
onVersionMismatch = f
|
||||
}
|
||||
|
||||
func (lc *LocalClient) send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, "http://local-tailscaled.sock"+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := lc.doLocalRequestNiceError(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
slurp, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != wantStatus {
|
||||
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
|
||||
return nil, bestError(err, slurp)
|
||||
}
|
||||
return slurp, nil
|
||||
}
|
||||
|
||||
func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) {
|
||||
return lc.send(ctx, "GET", path, 200, nil)
|
||||
}
|
||||
|
||||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||||
//
|
||||
// Deprecated: use LocalClient.WhoIs.
|
||||
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
return defaultLocalClient.WhoIs(ctx, remoteAddr)
|
||||
}
|
||||
|
||||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||||
func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := new(apitype.WhoIsResponse)
|
||||
if err := json.Unmarshal(body, r); err != nil {
|
||||
if max := 200; len(body) > max {
|
||||
body = append(body[:max], "..."...)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse JSON WhoIsResponse from %q", body)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Goroutines returns a dump of the Tailscale daemon's current goroutines.
|
||||
func (lc *LocalClient) Goroutines(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/goroutines")
|
||||
}
|
||||
|
||||
// DaemonMetrics returns the Tailscale daemon's metrics in
|
||||
// the Prometheus text exposition format.
|
||||
func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) {
|
||||
return lc.get200(ctx, "/localapi/v0/metrics")
|
||||
}
|
||||
|
||||
// Profile returns a pprof profile of the Tailscale daemon.
|
||||
func (lc *LocalClient) Profile(ctx context.Context, pprofType string, sec int) ([]byte, error) {
|
||||
var secArg string
|
||||
if sec < 0 || sec > 300 {
|
||||
return nil, errors.New("duration out of range")
|
||||
}
|
||||
if sec != 0 || pprofType == "profile" {
|
||||
secArg = fmt.Sprint(sec)
|
||||
}
|
||||
return lc.get200(ctx, fmt.Sprintf("/localapi/v0/profile?name=%s&seconds=%v", url.QueryEscape(pprofType), secArg))
|
||||
}
|
||||
|
||||
// BugReport logs and returns a log marker that can be shared by the user with support.
|
||||
func (lc *LocalClient) BugReport(ctx context.Context, note string) (string, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/bugreport?note="+url.QueryEscape(note), 200, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
// DebugAction invokes a debug action, such as "rebind" or "restun".
|
||||
// These are development tools and subject to change or removal over time.
|
||||
func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the Tailscale daemon's status.
|
||||
func Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return defaultLocalClient.Status(ctx)
|
||||
}
|
||||
|
||||
// Status returns the Tailscale daemon's status.
|
||||
func (lc *LocalClient) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return lc.status(ctx, "")
|
||||
}
|
||||
|
||||
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
|
||||
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return defaultLocalClient.StatusWithoutPeers(ctx)
|
||||
}
|
||||
|
||||
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
|
||||
func (lc *LocalClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return lc.status(ctx, "?peers=false")
|
||||
}
|
||||
|
||||
func (lc *LocalClient) status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/status"+queryString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := new(ipnstate.Status)
|
||||
if err := json.Unmarshal(body, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// IDToken is a request to get an OIDC ID token for an audience.
|
||||
// The token can be presented to any resource provider which offers OIDC
|
||||
// Federation.
|
||||
func (lc *LocalClient) IDToken(ctx context.Context, aud string) (*tailcfg.TokenResponse, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/id-token?aud="+url.QueryEscape(aud))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := new(tailcfg.TokenResponse)
|
||||
if err := json.Unmarshal(body, tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tr, nil
|
||||
}
|
||||
|
||||
func (lc *LocalClient) WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/files/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wfs []apitype.WaitingFile
|
||||
if err := json.Unmarshal(body, &wfs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wfs, nil
|
||||
}
|
||||
|
||||
func (lc *LocalClient) DeleteWaitingFile(ctx context.Context, baseName string) error {
|
||||
_, err := lc.send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (lc *LocalClient) GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/files/"+url.PathEscape(baseName), nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
res, err := lc.doLocalRequestNiceError(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if res.ContentLength == -1 {
|
||||
res.Body.Close()
|
||||
return nil, 0, fmt.Errorf("unexpected chunking")
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
body, _ := ioutil.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
return nil, 0, fmt.Errorf("HTTP %s: %s", res.Status, body)
|
||||
}
|
||||
return res.Body, res.ContentLength, nil
|
||||
}
|
||||
|
||||
func (lc *LocalClient) FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/file-targets")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fts []apitype.FileTarget
|
||||
if err := json.Unmarshal(body, &fts); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
return fts, nil
|
||||
}
|
||||
|
||||
// PushFile sends Taildrop file r to target.
|
||||
//
|
||||
// A size of -1 means unknown.
|
||||
// The name parameter is the original filename, not escaped.
|
||||
func (lc *LocalClient) PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", "http://local-tailscaled.sock/localapi/v0/file-put/"+string(target)+"/"+url.PathEscape(name), r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size != -1 {
|
||||
req.ContentLength = size
|
||||
}
|
||||
res, err := lc.doLocalRequestNiceError(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode == 200 {
|
||||
io.Copy(io.Discard, res.Body)
|
||||
return nil
|
||||
}
|
||||
all, _ := io.ReadAll(res.Body)
|
||||
return bestError(fmt.Errorf("%s: %s", res.Status, all), all)
|
||||
}
|
||||
|
||||
// CheckIPForwarding asks the local Tailscale daemon whether it looks like the
|
||||
// machine is properly configured to forward IP packets as a subnet router
|
||||
// or exit node.
|
||||
func (lc *LocalClient) CheckIPForwarding(ctx context.Context) error {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/check-ip-forwarding")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jres struct {
|
||||
Warning string
|
||||
}
|
||||
if err := json.Unmarshal(body, &jres); err != nil {
|
||||
return fmt.Errorf("invalid JSON from check-ip-forwarding: %w", err)
|
||||
}
|
||||
if jres.Warning != "" {
|
||||
return errors.New(jres.Warning)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPrefs validates the provided preferences, without making any changes.
|
||||
//
|
||||
// The CLI uses this before a Start call to fail fast if the preferences won't
|
||||
// work. Currently (2022-04-18) this only checks for SSH server compatibility.
|
||||
// Note that EditPrefs does the same validation as this, so call CheckPrefs before
|
||||
// EditPrefs is not necessary.
|
||||
func (lc *LocalClient) CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
|
||||
pj, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = lc.send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, bytes.NewReader(pj))
|
||||
return err
|
||||
}
|
||||
|
||||
func (lc *LocalClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
|
||||
body, err := lc.get200(ctx, "/localapi/v0/prefs")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p ipn.Prefs
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||||
mpj, err := json.Marshal(mp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := lc.send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, bytes.NewReader(mpj))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p ipn.Prefs
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (lc *LocalClient) Logout(ctx context.Context) error {
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDNS adds a DNS TXT record for the given domain name, containing
|
||||
// the provided TXT value. The intended use case is answering
|
||||
// LetsEncrypt/ACME dns-01 challenges.
|
||||
//
|
||||
// The control plane will only permit SetDNS requests with very
|
||||
// specific names and values. The name should be
|
||||
// "_acme-challenge." + your node's MagicDNS name. It's expected that
|
||||
// clients cache the certs from LetsEncrypt (or whichever CA is
|
||||
// providing them) and only request new ones as needed; the control plane
|
||||
// rate limits SetDNS requests.
|
||||
//
|
||||
// This is a low-level interface; it's expected that most Tailscale
|
||||
// users use a higher level interface to getting/using TLS
|
||||
// certificates.
|
||||
func (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error {
|
||||
v := url.Values{}
|
||||
v.Set("name", name)
|
||||
v.Set("value", value)
|
||||
_, err := lc.send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DialTCP connects to the host's port via Tailscale.
|
||||
//
|
||||
// The host may be a base DNS name (resolved from the netmap inside
|
||||
// tailscaled), a FQDN, or an IP address.
|
||||
//
|
||||
// The ctx is only used for the duration of the call, not the lifetime of the net.Conn.
|
||||
func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
|
||||
connCh := make(chan net.Conn, 1)
|
||||
trace := httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
connCh <- info.Conn
|
||||
},
|
||||
}
|
||||
ctx = httptrace.WithClientTrace(ctx, &trace)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://local-tailscaled.sock/localapi/v0/dial", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header = http.Header{
|
||||
"Upgrade": []string{"ts-dial"},
|
||||
"Connection": []string{"upgrade"},
|
||||
"Dial-Host": []string{host},
|
||||
"Dial-Port": []string{fmt.Sprint(port)},
|
||||
}
|
||||
res, err := lc.DoLocalRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != http.StatusSwitchingProtocols {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body)
|
||||
}
|
||||
// From here on, the underlying net.Conn is ours to use, but there
|
||||
// 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:
|
||||
}
|
||||
if switchedConn == nil {
|
||||
res.Body.Close()
|
||||
return nil, fmt.Errorf("httptrace didn't provide a connection")
|
||||
}
|
||||
rwc, ok := res.Body.(io.ReadWriteCloser)
|
||||
if !ok {
|
||||
res.Body.Close()
|
||||
return nil, errors.New("http Transport did not provide a writable body")
|
||||
}
|
||||
return netutil.NewAltReadWriteCloserConn(rwc, switchedConn), nil
|
||||
}
|
||||
|
||||
// CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled.
|
||||
// It is intended to be used with netcheck to see availability of DERPs.
|
||||
func (lc *LocalClient) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
|
||||
var derpMap tailcfg.DERPMap
|
||||
res, err := lc.send(ctx, "GET", "/localapi/v0/derpmap", 200, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(res, &derpMap); err != nil {
|
||||
return nil, fmt.Errorf("invalid derp map json: %w", err)
|
||||
}
|
||||
return &derpMap, nil
|
||||
}
|
||||
|
||||
// CertPair returns a cert and private key for the provided DNS domain.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// Deprecated: use LocalClient.CertPair.
|
||||
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
return defaultLocalClient.CertPair(ctx, domain)
|
||||
}
|
||||
|
||||
// CertPair returns a cert and private key for the provided DNS domain.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// with ?type=pair, the response PEM is first the one private
|
||||
// key PEM block, then the cert PEM blocks.
|
||||
i := mem.Index(mem.B(res), mem.S("--\n--"))
|
||||
if i == -1 {
|
||||
return nil, nil, fmt.Errorf("unexpected output: no delimiter")
|
||||
}
|
||||
i += len("--\n")
|
||||
keyPEM, certPEM = res[:i], res[i:]
|
||||
if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) {
|
||||
return nil, nil, fmt.Errorf("unexpected output: key in cert")
|
||||
}
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// It's the right signature to use as the value of
|
||||
// tls.Config.GetCertificate.
|
||||
//
|
||||
// Deprecated: use LocalClient.GetCertificate.
|
||||
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return defaultLocalClient.GetCertificate(hi)
|
||||
}
|
||||
|
||||
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// It's the right signature to use as the value of
|
||||
// tls.Config.GetCertificate.
|
||||
//
|
||||
// API maturity: this is considered a stable API.
|
||||
func (lc *LocalClient) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if hi == nil || hi.ServerName == "" {
|
||||
return nil, errors.New("no SNI ServerName")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
name := hi.ServerName
|
||||
if !strings.Contains(name, ".") {
|
||||
if v, ok := lc.ExpandSNIName(ctx, name); ok {
|
||||
name = v
|
||||
}
|
||||
}
|
||||
certPEM, keyPEM, err := lc.CertPair(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// ExpandSNIName expands bare label name into the the most likely actual TLS cert name.
|
||||
//
|
||||
// Deprecated: use LocalClient.ExpandSNIName.
|
||||
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||||
return defaultLocalClient.ExpandSNIName(ctx, name)
|
||||
}
|
||||
|
||||
// ExpandSNIName expands bare label name into the the most likely actual TLS cert name.
|
||||
func (lc *LocalClient) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||||
st, err := lc.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, d := range st.CertDomains {
|
||||
if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Ping sends a ping of the provided type to the provided IP and waits
|
||||
// for its response.
|
||||
func (lc *LocalClient) Ping(ctx context.Context, ip netaddr.IP, pingtype tailcfg.PingType) (*ipnstate.PingResult, error) {
|
||||
v := url.Values{}
|
||||
v.Set("ip", ip.String())
|
||||
v.Set("type", string(pingtype))
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/ping?"+v.Encode(), 200, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
pr := new(ipnstate.PingResult)
|
||||
if err := json.Unmarshal(body, pr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// tailscaledConnectHint gives a little thing about why tailscaled (or
|
||||
// platform equivalent) is not answering localapi connections.
|
||||
//
|
||||
// It ends in a punctuation. See caller.
|
||||
func tailscaledConnectHint() string {
|
||||
if runtime.GOOS != "linux" {
|
||||
// TODO(bradfitz): flesh this out
|
||||
return "not running?"
|
||||
}
|
||||
out, err := exec.Command("systemctl", "show", "tailscaled.service", "--no-page", "--property", "LoadState,ActiveState,SubState").Output()
|
||||
if err != nil {
|
||||
return "not running?"
|
||||
}
|
||||
// Parse:
|
||||
// LoadState=loaded
|
||||
// ActiveState=inactive
|
||||
// SubState=dead
|
||||
st := map[string]string{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if k, v, ok := strings.Cut(line, "="); ok {
|
||||
st[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if st["LoadState"] == "loaded" &&
|
||||
(st["SubState"] != "running" || st["ActiveState"] != "active") {
|
||||
return "systemd tailscaled.service not running."
|
||||
}
|
||||
return "not running?"
|
||||
}
|
||||
98
client/tailscale/routes.go
Normal file
98
client/tailscale/routes.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"inet.af/netaddr"
|
||||
)
|
||||
|
||||
// Routes contains the lists of subnet routes that are currently advertised by a device,
|
||||
// as well as the subnets that are enabled to be routed by the device.
|
||||
type Routes struct {
|
||||
AdvertisedRoutes []netaddr.IPPrefix `json:"advertisedRoutes"`
|
||||
EnabledRoutes []netaddr.IPPrefix `json:"enabledRoutes"`
|
||||
}
|
||||
|
||||
// Routes retrieves the list of subnet routes that have been enabled for a device.
|
||||
// The routes that are returned are not necessarily advertised by the device,
|
||||
// they have only been preapproved.
|
||||
func (c *Client) Routes(ctx context.Context, deviceID string) (routes *Routes, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.Routes: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var sr Routes
|
||||
err = json.Unmarshal(b, &sr)
|
||||
return &sr, err
|
||||
}
|
||||
|
||||
type postRoutesParams struct {
|
||||
Routes []netaddr.IPPrefix `json:"routes"`
|
||||
}
|
||||
|
||||
// SetRoutes updates the list of subnets that are enabled for a device.
|
||||
// Subnets must be parsable by inet.af/netaddr.ParseIPPrefix.
|
||||
// Subnets do not have to be currently advertised by a device, they may be pre-enabled.
|
||||
// Returns the updated list of enabled and advertised subnet routes in a *Routes object.
|
||||
func (c *Client) SetRoutes(ctx context.Context, deviceID string, subnets []netaddr.IPPrefix) (routes *Routes, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.SetRoutes: %w", err)
|
||||
}
|
||||
}()
|
||||
params := &postRoutesParams{Routes: subnets}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := fmt.Sprintf("%s/api/v2/device/%s/routes", c.baseURL(), deviceID)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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 {
|
||||
return nil, handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
var srr *Routes
|
||||
if err := json.Unmarshal(b, &srr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return srr, err
|
||||
}
|
||||
42
client/tailscale/tailnet.go
Normal file
42
client/tailscale/tailnet.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// TailnetDeleteRequest handles sending a DELETE request for a tailnet to control.
|
||||
func (c *Client) TailnetDeleteRequest(ctx context.Context, tailnetID string) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
err = fmt.Errorf("tailscale.DeleteTailnet: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/api/v2/tailnet/%s", c.baseURL(), url.PathEscape(string(tailnetID)))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.setAuth(req)
|
||||
b, resp, err := c.sendRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleErrorResponse(b, resp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,616 +1,160 @@
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
// Package tailscale contains Tailscale client code.
|
||||
// Package tailscale contains Go clients for the Tailscale Local API and
|
||||
// Tailscale control plane API.
|
||||
//
|
||||
// Warning: this package is in development and makes no API compatibility
|
||||
// promises as of 2022-04-29. It is subject to change at any time.
|
||||
package tailscale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
var (
|
||||
// TailscaledSocket is the tailscaled Unix socket. It's used by the TailscaledDialer.
|
||||
TailscaledSocket = paths.DefaultTailscaledSocket()
|
||||
|
||||
// TailscaledSocketSetExplicitly reports whether the user explicitly set TailscaledSocket.
|
||||
TailscaledSocketSetExplicitly bool
|
||||
|
||||
// TailscaledDialer is the DialContext func that connects to the local machine's
|
||||
// tailscaled or equivalent.
|
||||
TailscaledDialer = defaultDialer
|
||||
)
|
||||
|
||||
func defaultDialer(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if addr != "local-tailscaled.sock:80" {
|
||||
return nil, fmt.Errorf("unexpected URL address %q", addr)
|
||||
}
|
||||
// TODO: make this part of a safesocket.ConnectionStrategy
|
||||
if !TailscaledSocketSetExplicitly {
|
||||
// On macOS, when dialing from non-sandboxed program to sandboxed GUI running
|
||||
// a TCP server on a random port, find the random port. For HTTP connections,
|
||||
// we don't send the token. It gets added in an HTTP Basic-Auth header.
|
||||
if port, _, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port))
|
||||
}
|
||||
}
|
||||
s := safesocket.DefaultConnectionStrategy(TailscaledSocket)
|
||||
// The user provided a non-default tailscaled socket address.
|
||||
// Connect only to exactly what they provided.
|
||||
s.UseFallback(false)
|
||||
return safesocket.Connect(s)
|
||||
}
|
||||
|
||||
var (
|
||||
// tsClient does HTTP requests to the local Tailscale daemon.
|
||||
// We lazily initialize the client in case the caller wants to
|
||||
// override TailscaledDialer.
|
||||
tsClient *http.Client
|
||||
tsClientOnce sync.Once
|
||||
)
|
||||
|
||||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||||
// I_Acknowledge_This_API_Is_Unstable must be set true to use this package
|
||||
// for now. It was added 2022-04-29 when it was moved to this git repo
|
||||
// and will be removed when the public API has settled.
|
||||
//
|
||||
// URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4.
|
||||
// TODO(bradfitz): remove this after the we're happy with the public API.
|
||||
var I_Acknowledge_This_API_Is_Unstable = false
|
||||
|
||||
// TODO: use url.PathEscape() for deviceID and tailnets when constructing requests.
|
||||
|
||||
const defaultAPIBase = "https://api.tailscale.com"
|
||||
|
||||
// maxSize is the maximum read size (10MB) of responses from the server.
|
||||
const maxReadSize = 10 << 20
|
||||
|
||||
// Client makes API calls to the Tailscale control plane API server.
|
||||
//
|
||||
// The hostname must be "local-tailscaled.sock", even though it
|
||||
// doesn't actually do any DNS lookup. The actual means of connecting to and
|
||||
// authenticating to the local Tailscale daemon vary by platform.
|
||||
// Use NewClient to instantiate one. Exported fields should be set before
|
||||
// the client is used and not changed thereafter.
|
||||
type Client struct {
|
||||
// tailnet is the globally unique identifier for a Tailscale network, such
|
||||
// as "example.com" or "user@gmail.com".
|
||||
tailnet string
|
||||
// auth is the authentication method to use for this client.
|
||||
// nil means none, which generally won't work, but won't crash.
|
||||
auth AuthMethod
|
||||
|
||||
// BaseURL optionally specifies an alternate API server to use.
|
||||
// If empty, "https://api.tailscale.com" is used.
|
||||
BaseURL string
|
||||
|
||||
// HTTPClient optionally specifies an alternate HTTP client to use.
|
||||
// If nil, http.DefaultClient is used.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func (c *Client) baseURL() string {
|
||||
if c.BaseURL != "" {
|
||||
return c.BaseURL
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// AuthMethod is the interface for API authentication methods.
|
||||
//
|
||||
// DoLocalRequest may mutate the request to add Authorization headers.
|
||||
func DoLocalRequest(req *http.Request) (*http.Response, error) {
|
||||
tsClientOnce.Do(func() {
|
||||
tsClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: TailscaledDialer,
|
||||
},
|
||||
}
|
||||
})
|
||||
if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil {
|
||||
req.SetBasicAuth("", token)
|
||||
}
|
||||
return tsClient.Do(req)
|
||||
// Most users will use AuthKey.
|
||||
type AuthMethod interface {
|
||||
modifyRequest(req *http.Request)
|
||||
}
|
||||
|
||||
func doLocalRequestNiceError(req *http.Request) (*http.Response, error) {
|
||||
res, err := DoLocalRequest(req)
|
||||
if err == nil {
|
||||
if server := res.Header.Get("Tailscale-Version"); server != "" && server != version.Long && onVersionMismatch != nil {
|
||||
onVersionMismatch(version.Long, server)
|
||||
}
|
||||
if res.StatusCode == 403 {
|
||||
all, _ := ioutil.ReadAll(res.Body)
|
||||
return nil, &AccessDeniedError{errors.New(errorMessageFromBody(all))}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
if ue, ok := err.(*url.Error); ok {
|
||||
if oe, ok := ue.Err.(*net.OpError); ok && oe.Op == "dial" {
|
||||
path := req.URL.Path
|
||||
pathPrefix, _, _ := strings.Cut(path, "?")
|
||||
return nil, fmt.Errorf("Failed to connect to local Tailscale daemon for %s; %s Error: %w", pathPrefix, tailscaledConnectHint(), oe)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
// APIKey is an AuthMethod for NewClient that authenticates requests
|
||||
// using an authkey.
|
||||
type APIKey string
|
||||
|
||||
func (ak APIKey) modifyRequest(req *http.Request) {
|
||||
req.SetBasicAuth(string(ak), "")
|
||||
}
|
||||
|
||||
type errorJSON struct {
|
||||
Error string
|
||||
func (c *Client) setAuth(r *http.Request) {
|
||||
if c.auth != nil {
|
||||
c.auth.modifyRequest(r)
|
||||
}
|
||||
}
|
||||
|
||||
// AccessDeniedError is an error due to permissions.
|
||||
type AccessDeniedError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *AccessDeniedError) Error() string { return fmt.Sprintf("Access denied: %v", e.err) }
|
||||
func (e *AccessDeniedError) Unwrap() error { return e.err }
|
||||
|
||||
// IsAccessDeniedError reports whether err is or wraps an AccessDeniedError.
|
||||
func IsAccessDeniedError(err error) bool {
|
||||
var ae *AccessDeniedError
|
||||
return errors.As(err, &ae)
|
||||
}
|
||||
|
||||
// bestError returns either err, or if body contains a valid JSON
|
||||
// object of type errorJSON, its non-empty error body.
|
||||
func bestError(err error, body []byte) error {
|
||||
var j errorJSON
|
||||
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
|
||||
return errors.New(j.Error)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func errorMessageFromBody(body []byte) string {
|
||||
var j errorJSON
|
||||
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
|
||||
return j.Error
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
var onVersionMismatch func(clientVer, serverVer string)
|
||||
|
||||
// SetVersionMismatchHandler sets f as the version mismatch handler
|
||||
// to be called when the client (the current process) has a version
|
||||
// number that doesn't match the server's declared version.
|
||||
func SetVersionMismatchHandler(f func(clientVer, serverVer string)) {
|
||||
onVersionMismatch = f
|
||||
}
|
||||
|
||||
func send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, "http://local-tailscaled.sock"+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := doLocalRequestNiceError(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
slurp, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != wantStatus {
|
||||
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
|
||||
return nil, bestError(err, slurp)
|
||||
}
|
||||
return slurp, nil
|
||||
}
|
||||
|
||||
func get200(ctx context.Context, path string) ([]byte, error) {
|
||||
return send(ctx, "GET", path, 200, nil)
|
||||
}
|
||||
|
||||
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
|
||||
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := new(apitype.WhoIsResponse)
|
||||
if err := json.Unmarshal(body, r); err != nil {
|
||||
if max := 200; len(body) > max {
|
||||
body = append(body[:max], "..."...)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse JSON WhoIsResponse from %q", body)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Goroutines returns a dump of the Tailscale daemon's current goroutines.
|
||||
func Goroutines(ctx context.Context) ([]byte, error) {
|
||||
return get200(ctx, "/localapi/v0/goroutines")
|
||||
}
|
||||
|
||||
// DaemonMetrics returns the Tailscale daemon's metrics in
|
||||
// the Prometheus text exposition format.
|
||||
func DaemonMetrics(ctx context.Context) ([]byte, error) {
|
||||
return get200(ctx, "/localapi/v0/metrics")
|
||||
}
|
||||
|
||||
// Profile returns a pprof profile of the Tailscale daemon.
|
||||
func Profile(ctx context.Context, pprofType string, sec int) ([]byte, error) {
|
||||
var secArg string
|
||||
if sec < 0 || sec > 300 {
|
||||
return nil, errors.New("duration out of range")
|
||||
}
|
||||
if sec != 0 || pprofType == "profile" {
|
||||
secArg = fmt.Sprint(sec)
|
||||
}
|
||||
return get200(ctx, fmt.Sprintf("/localapi/v0/profile?name=%s&seconds=%v", url.QueryEscape(pprofType), secArg))
|
||||
}
|
||||
|
||||
// BugReport logs and returns a log marker that can be shared by the user with support.
|
||||
func BugReport(ctx context.Context, note string) (string, error) {
|
||||
body, err := send(ctx, "POST", "/localapi/v0/bugreport?note="+url.QueryEscape(note), 200, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
// DebugAction invokes a debug action, such as "rebind" or "restun".
|
||||
// These are development tools and subject to change or removal over time.
|
||||
func DebugAction(ctx context.Context, action string) error {
|
||||
body, err := send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the Tailscale daemon's status.
|
||||
func Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return status(ctx, "")
|
||||
}
|
||||
|
||||
// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info.
|
||||
func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return status(ctx, "?peers=false")
|
||||
}
|
||||
|
||||
func status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/status"+queryString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := new(ipnstate.Status)
|
||||
if err := json.Unmarshal(body, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// IDToken is a request to get an OIDC ID token for an audience.
|
||||
// The token can be presented to any resource provider which offers OIDC
|
||||
// Federation.
|
||||
func IDToken(ctx context.Context, aud string) (*tailcfg.TokenResponse, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/id-token?aud="+url.QueryEscape(aud))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := new(tailcfg.TokenResponse)
|
||||
if err := json.Unmarshal(body, tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tr, nil
|
||||
}
|
||||
|
||||
func WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/files/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wfs []apitype.WaitingFile
|
||||
if err := json.Unmarshal(body, &wfs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wfs, nil
|
||||
}
|
||||
|
||||
func DeleteWaitingFile(ctx context.Context, baseName string) error {
|
||||
_, err := send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/files/"+url.PathEscape(baseName), nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
res, err := doLocalRequestNiceError(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if res.ContentLength == -1 {
|
||||
res.Body.Close()
|
||||
return nil, 0, fmt.Errorf("unexpected chunking")
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
body, _ := ioutil.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
return nil, 0, fmt.Errorf("HTTP %s: %s", res.Status, body)
|
||||
}
|
||||
return res.Body, res.ContentLength, nil
|
||||
}
|
||||
|
||||
func FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/file-targets")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fts []apitype.FileTarget
|
||||
if err := json.Unmarshal(body, &fts); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
return fts, nil
|
||||
}
|
||||
|
||||
// PushFile sends Taildrop file r to target.
|
||||
// NewClient is a convenience method for instantiating a new Client.
|
||||
//
|
||||
// A size of -1 means unknown.
|
||||
// The name parameter is the original filename, not escaped.
|
||||
func PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", "http://local-tailscaled.sock/localapi/v0/file-put/"+string(target)+"/"+url.PathEscape(name), r)
|
||||
// tailnet is the globally unique identifier for a Tailscale network, such
|
||||
// as "example.com" or "user@gmail.com".
|
||||
// If httpClient is nil, then http.DefaultClient is used.
|
||||
// "api.tailscale.com" is set as the BaseURL for the returned client
|
||||
// and can be changed manually by the user.
|
||||
func NewClient(tailnet string, auth AuthMethod) *Client {
|
||||
return &Client{
|
||||
tailnet: tailnet,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Tailnet() string { return c.tailnet }
|
||||
|
||||
// Do sends a raw HTTP request, after adding any authentication headers.
|
||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
if !I_Acknowledge_This_API_Is_Unstable {
|
||||
return nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
return c.httpClient().Do(req)
|
||||
}
|
||||
|
||||
// sendRequest add the authenication key to the request and sends it. It
|
||||
// receives the response and reads up to 10MB of it.
|
||||
func (c *Client) sendRequest(req *http.Request) ([]byte, *http.Response, error) {
|
||||
if !I_Acknowledge_This_API_Is_Unstable {
|
||||
return nil, nil, errors.New("use of Client without setting I_Acknowledge_This_API_Is_Unstable")
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response. Limit the response to 10MB.
|
||||
body := io.LimitReader(resp.Body, maxReadSize+1)
|
||||
b, err := ioutil.ReadAll(body)
|
||||
if len(b) > maxReadSize {
|
||||
err = errors.New("API response too large")
|
||||
}
|
||||
return b, resp, err
|
||||
}
|
||||
|
||||
// ErrResponse is the HTTP error returned by the Tailscale server.
|
||||
type ErrResponse struct {
|
||||
Status int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e ErrResponse) Error() string {
|
||||
return fmt.Sprintf("Status: %d, Message: %q", e.Status, e.Message)
|
||||
}
|
||||
|
||||
// handleErrorResponse decodes the error message from the server and returns
|
||||
// an ErrResponse from it.
|
||||
func handleErrorResponse(b []byte, resp *http.Response) error {
|
||||
var errResp ErrResponse
|
||||
if err := json.Unmarshal(b, &errResp); err != nil {
|
||||
return err
|
||||
}
|
||||
if size != -1 {
|
||||
req.ContentLength = size
|
||||
}
|
||||
res, err := doLocalRequestNiceError(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode == 200 {
|
||||
io.Copy(io.Discard, res.Body)
|
||||
return nil
|
||||
}
|
||||
all, _ := io.ReadAll(res.Body)
|
||||
return bestError(fmt.Errorf("%s: %s", res.Status, all), all)
|
||||
}
|
||||
|
||||
func CheckIPForwarding(ctx context.Context) error {
|
||||
body, err := get200(ctx, "/localapi/v0/check-ip-forwarding")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jres struct {
|
||||
Warning string
|
||||
}
|
||||
if err := json.Unmarshal(body, &jres); err != nil {
|
||||
return fmt.Errorf("invalid JSON from check-ip-forwarding: %w", err)
|
||||
}
|
||||
if jres.Warning != "" {
|
||||
return errors.New(jres.Warning)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPrefs validates the provided preferences, without making any changes.
|
||||
//
|
||||
// The CLI uses this before a Start call to fail fast if the preferences won't
|
||||
// work. Currently (2022-04-18) this only checks for SSH server compatibility.
|
||||
// Note that EditPrefs does the same validation as this, so call CheckPrefs before
|
||||
// EditPrefs is not necessary.
|
||||
func CheckPrefs(ctx context.Context, p *ipn.Prefs) error {
|
||||
pj, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, bytes.NewReader(pj))
|
||||
return err
|
||||
}
|
||||
|
||||
func GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
|
||||
body, err := get200(ctx, "/localapi/v0/prefs")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p ipn.Prefs
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||||
mpj, err := json.Marshal(mp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, bytes.NewReader(mpj))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p ipn.Prefs
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func Logout(ctx context.Context) error {
|
||||
_, err := send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDNS adds a DNS TXT record for the given domain name, containing
|
||||
// the provided TXT value. The intended use case is answering
|
||||
// LetsEncrypt/ACME dns-01 challenges.
|
||||
//
|
||||
// The control plane will only permit SetDNS requests with very
|
||||
// specific names and values. The name should be
|
||||
// "_acme-challenge." + your node's MagicDNS name. It's expected that
|
||||
// clients cache the certs from LetsEncrypt (or whichever CA is
|
||||
// providing them) and only request new ones as needed; the control plane
|
||||
// rate limits SetDNS requests.
|
||||
//
|
||||
// This is a low-level interface; it's expected that most Tailscale
|
||||
// users use a higher level interface to getting/using TLS
|
||||
// certificates.
|
||||
func SetDNS(ctx context.Context, name, value string) error {
|
||||
v := url.Values{}
|
||||
v.Set("name", name)
|
||||
v.Set("value", value)
|
||||
_, err := send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DialTCP connects to the host's port via Tailscale.
|
||||
//
|
||||
// The host may be a base DNS name (resolved from the netmap inside
|
||||
// tailscaled), a FQDN, or an IP address.
|
||||
//
|
||||
// The ctx is only used for the duration of the call, not the lifetime of the net.Conn.
|
||||
func DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) {
|
||||
connCh := make(chan net.Conn, 1)
|
||||
trace := httptrace.ClientTrace{
|
||||
GotConn: func(info httptrace.GotConnInfo) {
|
||||
connCh <- info.Conn
|
||||
},
|
||||
}
|
||||
ctx = httptrace.WithClientTrace(ctx, &trace)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "http://local-tailscaled.sock/localapi/v0/dial", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header = http.Header{
|
||||
"Upgrade": []string{"ts-dial"},
|
||||
"Connection": []string{"upgrade"},
|
||||
"Dial-Host": []string{host},
|
||||
"Dial-Port": []string{fmt.Sprint(port)},
|
||||
}
|
||||
res, err := DoLocalRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != http.StatusSwitchingProtocols {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
return nil, fmt.Errorf("unexpected HTTP response: %s, %s", res.Status, body)
|
||||
}
|
||||
// From here on, the underlying net.Conn is ours to use, but there
|
||||
// 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:
|
||||
}
|
||||
if switchedConn == nil {
|
||||
res.Body.Close()
|
||||
return nil, fmt.Errorf("httptrace didn't provide a connection")
|
||||
}
|
||||
rwc, ok := res.Body.(io.ReadWriteCloser)
|
||||
if !ok {
|
||||
res.Body.Close()
|
||||
return nil, errors.New("http Transport did not provide a writable body")
|
||||
}
|
||||
return netutil.NewAltReadWriteCloserConn(rwc, switchedConn), nil
|
||||
}
|
||||
|
||||
// CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled.
|
||||
// It is intended to be used with netcheck to see availability of DERPs.
|
||||
func CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) {
|
||||
var derpMap tailcfg.DERPMap
|
||||
res, err := send(ctx, "GET", "/localapi/v0/derpmap", 200, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(res, &derpMap); err != nil {
|
||||
return nil, fmt.Errorf("invalid derp map json: %w", err)
|
||||
}
|
||||
return &derpMap, nil
|
||||
}
|
||||
|
||||
// CertPair returns a cert and private key for the provided DNS domain.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
|
||||
res, err := send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// with ?type=pair, the response PEM is first the one private
|
||||
// key PEM block, then the cert PEM blocks.
|
||||
i := mem.Index(mem.B(res), mem.S("--\n--"))
|
||||
if i == -1 {
|
||||
return nil, nil, fmt.Errorf("unexpected output: no delimiter")
|
||||
}
|
||||
i += len("--\n")
|
||||
keyPEM, certPEM = res[:i], res[i:]
|
||||
if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) {
|
||||
return nil, nil, fmt.Errorf("unexpected output: key in cert")
|
||||
}
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi.
|
||||
//
|
||||
// It returns a cached certificate from disk if it's still valid.
|
||||
//
|
||||
// It's the right signature to use as the value of
|
||||
// tls.Config.GetCertificate.
|
||||
func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if hi == nil || hi.ServerName == "" {
|
||||
return nil, errors.New("no SNI ServerName")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
name := hi.ServerName
|
||||
if !strings.Contains(name, ".") {
|
||||
if v, ok := ExpandSNIName(ctx, name); ok {
|
||||
name = v
|
||||
}
|
||||
}
|
||||
certPEM, keyPEM, err := CertPair(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// ExpandSNIName expands bare label name into the the most likely actual TLS cert name.
|
||||
func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) {
|
||||
st, err := StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, d := range st.CertDomains {
|
||||
if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// tailscaledConnectHint gives a little thing about why tailscaled (or
|
||||
// platform equivalent) is not answering localapi connections.
|
||||
//
|
||||
// It ends in a punctuation. See caller.
|
||||
func tailscaledConnectHint() string {
|
||||
if runtime.GOOS != "linux" {
|
||||
// TODO(bradfitz): flesh this out
|
||||
return "not running?"
|
||||
}
|
||||
out, err := exec.Command("systemctl", "show", "tailscaled.service", "--no-page", "--property", "LoadState,ActiveState,SubState").Output()
|
||||
if err != nil {
|
||||
return "not running?"
|
||||
}
|
||||
// Parse:
|
||||
// LoadState=loaded
|
||||
// ActiveState=inactive
|
||||
// SubState=dead
|
||||
st := map[string]string{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if k, v, ok := strings.Cut(line, "="); ok {
|
||||
st[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if st["LoadState"] == "loaded" &&
|
||||
(st["SubState"] != "running" || st["ActiveState"] != "active") {
|
||||
return "systemd tailscaled.service not running."
|
||||
}
|
||||
return "not running?"
|
||||
errResp.Status = resp.StatusCode
|
||||
return errResp
|
||||
}
|
||||
|
||||
@@ -22,13 +22,11 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
"tailscale.com/util/codegen"
|
||||
)
|
||||
|
||||
var (
|
||||
flagTypes = flag.String("type", "", "comma-separated list of types; required")
|
||||
flagOutput = flag.String("output", "", "output file; required")
|
||||
flagBuildTags = flag.String("tags", "", "compiler build tags to apply")
|
||||
flagCloneFunc = flag.Bool("clonefunc", false, "add a top-level Clone func")
|
||||
)
|
||||
@@ -43,30 +41,18 @@ func main() {
|
||||
}
|
||||
typeNames := strings.Split(*flagTypes, ",")
|
||||
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedTypes | packages.NeedTypesInfo | packages.NeedSyntax | packages.NeedName,
|
||||
Tests: false,
|
||||
}
|
||||
if *flagBuildTags != "" {
|
||||
cfg.BuildFlags = []string{"-tags=" + *flagBuildTags}
|
||||
}
|
||||
pkgs, err := packages.Load(cfg, ".")
|
||||
pkg, namedTypes, err := codegen.LoadTypes(*flagBuildTags, ".")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if len(pkgs) != 1 {
|
||||
log.Fatalf("wrong number of packages: %d", len(pkgs))
|
||||
}
|
||||
pkg := pkgs[0]
|
||||
it := codegen.NewImportTracker(pkg.Types)
|
||||
buf := new(bytes.Buffer)
|
||||
imports := make(map[string]struct{})
|
||||
namedTypes := codegen.NamedTypes(pkg)
|
||||
for _, typeName := range typeNames {
|
||||
typ, ok := namedTypes[typeName]
|
||||
if !ok {
|
||||
log.Fatalf("could not find type %s", typeName)
|
||||
}
|
||||
gen(buf, imports, typ, pkg.Types)
|
||||
gen(buf, it, typ)
|
||||
}
|
||||
|
||||
w := func(format string, args ...any) {
|
||||
@@ -93,62 +79,13 @@ func main() {
|
||||
w(" return false")
|
||||
w("}")
|
||||
}
|
||||
|
||||
contents := new(bytes.Buffer)
|
||||
var flagArgs []string
|
||||
if *flagTypes != "" {
|
||||
flagArgs = append(flagArgs, "-type="+*flagTypes)
|
||||
}
|
||||
if *flagOutput != "" {
|
||||
flagArgs = append(flagArgs, "-output="+*flagOutput)
|
||||
}
|
||||
if *flagBuildTags != "" {
|
||||
flagArgs = append(flagArgs, "-tags="+*flagBuildTags)
|
||||
}
|
||||
if *flagCloneFunc {
|
||||
flagArgs = append(flagArgs, "-clonefunc")
|
||||
}
|
||||
fmt.Fprintf(contents, header, strings.Join(flagArgs, " "), pkg.Name)
|
||||
fmt.Fprintf(contents, "import (\n")
|
||||
for s := range imports {
|
||||
fmt.Fprintf(contents, "\t%q\n", s)
|
||||
}
|
||||
fmt.Fprintf(contents, ")\n\n")
|
||||
contents.Write(buf.Bytes())
|
||||
|
||||
output := *flagOutput
|
||||
if output == "" {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := codegen.WriteFormatted(contents.Bytes(), output); err != nil {
|
||||
cloneOutput := pkg.Name + "_clone.go"
|
||||
if err := codegen.WritePackageFile("tailscale.com/cmd/cloner", pkg, cloneOutput, it, buf); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const header = `// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
|
||||
//` + `go:generate` + ` go run tailscale.com/cmd/cloner %s
|
||||
|
||||
package %s
|
||||
|
||||
`
|
||||
|
||||
func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisPkg *types.Package) {
|
||||
pkgQual := func(pkg *types.Package) string {
|
||||
if thisPkg == pkg {
|
||||
return ""
|
||||
}
|
||||
imports[pkg.Path()] = struct{}{}
|
||||
return pkg.Name()
|
||||
}
|
||||
importedName := func(t types.Type) string {
|
||||
return types.TypeString(t, pkgQual)
|
||||
}
|
||||
|
||||
func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
|
||||
t, ok := typ.Underlying().(*types.Struct)
|
||||
if !ok {
|
||||
return
|
||||
@@ -169,11 +106,11 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
|
||||
for i := 0; i < t.NumFields(); i++ {
|
||||
fname := t.Field(i).Name()
|
||||
ft := t.Field(i).Type()
|
||||
if !codegen.ContainsPointers(ft) {
|
||||
if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) {
|
||||
continue
|
||||
}
|
||||
if named, _ := ft.(*types.Named); named != nil {
|
||||
if isViewType(ft) {
|
||||
if codegen.IsViewType(ft) {
|
||||
writef("dst.%s = src.%s", fname, fname)
|
||||
continue
|
||||
}
|
||||
@@ -185,11 +122,16 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
|
||||
switch ft := ft.Underlying().(type) {
|
||||
case *types.Slice:
|
||||
if codegen.ContainsPointers(ft.Elem()) {
|
||||
n := importedName(ft.Elem())
|
||||
n := it.QualifiedName(ft.Elem())
|
||||
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
|
||||
writef("for i := range dst.%s {", fname)
|
||||
if _, isPtr := ft.Elem().(*types.Pointer); isPtr {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
|
||||
if _, isBasic := ptr.Elem().Underlying().(*types.Basic); isBasic {
|
||||
writef("\tx := *src.%s[i]", fname)
|
||||
writef("\tdst.%s[i] = &x", fname)
|
||||
} else {
|
||||
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
} else {
|
||||
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
|
||||
}
|
||||
@@ -202,7 +144,7 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
|
||||
writef("dst.%s = src.%s.Clone()", fname, fname)
|
||||
continue
|
||||
}
|
||||
n := importedName(ft.Elem())
|
||||
n := it.QualifiedName(ft.Elem())
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = new(%s)", fname, n)
|
||||
writef("\t*dst.%s = *src.%s", fname, fname)
|
||||
@@ -212,9 +154,9 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
|
||||
writef("}")
|
||||
case *types.Map:
|
||||
writef("if dst.%s != nil {", fname)
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, importedName(ft.Key()), importedName(ft.Elem()))
|
||||
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(ft.Elem()))
|
||||
if sliceType, isSlice := ft.Elem().(*types.Slice); isSlice {
|
||||
n := importedName(sliceType.Elem())
|
||||
n := it.QualifiedName(sliceType.Elem())
|
||||
writef("\tfor k := range src.%s {", fname)
|
||||
// use zero-length slice instead of nil to ensure
|
||||
// the key is always copied.
|
||||
@@ -237,20 +179,10 @@ func gen(buf *bytes.Buffer, imports map[string]struct{}, typ *types.Named, thisP
|
||||
writef("return dst")
|
||||
fmt.Fprintf(buf, "}\n\n")
|
||||
|
||||
buf.Write(codegen.AssertStructUnchanged(t, thisPkg, name, "Clone", imports))
|
||||
}
|
||||
|
||||
func isViewType(typ types.Type) bool {
|
||||
t, ok := typ.Underlying().(*types.Struct)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if t.NumFields() != 1 {
|
||||
return false
|
||||
}
|
||||
return t.Field(0).Name() == "ж"
|
||||
buf.Write(codegen.AssertStructUnchanged(t, name, "Clone", it))
|
||||
}
|
||||
|
||||
// hasBasicUnderlying reports true when typ.Underlying() is a slice or a map.
|
||||
func hasBasicUnderlying(typ types.Type) bool {
|
||||
switch typ.Underlying().(type) {
|
||||
case *types.Slice, *types.Map:
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/derp"
|
||||
"tailscale.com/derp/wsconn"
|
||||
)
|
||||
|
||||
var counterWebSocketAccepts = expvar.NewInt("derp_websocket_accepts")
|
||||
@@ -45,8 +44,8 @@ func addWebSocketSupport(s *derp.Server, base http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
counterWebSocketAccepts.Add(1)
|
||||
wc := wsconn.New(c)
|
||||
wc := websocket.NetConn(r.Context(), c, websocket.MessageBinary)
|
||||
brw := bufio.NewReadWriter(bufio.NewReader(wc), bufio.NewWriter(wc))
|
||||
s.Accept(wc, brw, r.RemoteAddr)
|
||||
s.Accept(r.Context(), wc, brw, r.RemoteAddr)
|
||||
})
|
||||
}
|
||||
|
||||
1
cmd/gitops-pusher/.gitignore
vendored
Normal file
1
cmd/gitops-pusher/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
version-cache.json
|
||||
48
cmd/gitops-pusher/README.md
Normal file
48
cmd/gitops-pusher/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# gitops-pusher
|
||||
|
||||
This is a small tool to help people achieve a
|
||||
[GitOps](https://about.gitlab.com/topics/gitops/) workflow with Tailscale ACL
|
||||
changes. This tool is intended to be used in a CI flow that looks like this:
|
||||
|
||||
```yaml
|
||||
name: Tailscale ACL syncing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
acls:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Go environment
|
||||
uses: actions/setup-go@v3.2.0
|
||||
|
||||
- name: Install gitops-pusher
|
||||
run: go install tailscale.com/cmd/gitops-pusher@latest
|
||||
|
||||
- name: Deploy ACL
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
TS_API_KEY: ${{ secrets.TS_API_KEY }}
|
||||
TS_TAILNET: ${{ secrets.TS_TAILNET }}
|
||||
run: |
|
||||
~/go/bin/gitops-pusher --policy-file ./policy.hujson apply
|
||||
|
||||
- name: ACL tests
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
TS_API_KEY: ${{ secrets.TS_API_KEY }}
|
||||
TS_TAILNET: ${{ secrets.TS_TAILNET }}
|
||||
run: |
|
||||
~/go/bin/gitops-pusher --policy-file ./policy.hujson test
|
||||
```
|
||||
|
||||
Change the value of the `--policy-file` flag to point to the policy file on
|
||||
disk. Policy files should be in [HuJSON](https://github.com/tailscale/hujson)
|
||||
format.
|
||||
67
cmd/gitops-pusher/cache.go
Normal file
67
cmd/gitops-pusher/cache.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Cache contains cached information about the last time this tool was run.
|
||||
//
|
||||
// This is serialized to a JSON file that should NOT be checked into git.
|
||||
// It should be managed with either CI cache tools or stored locally somehow. The
|
||||
// exact mechanism is irrelevant as long as it is consistent.
|
||||
//
|
||||
// This allows gitops-pusher to detect external ACL changes. I'm not sure what to
|
||||
// call this problem, so I've been calling it the "three version problem" in my
|
||||
// notes. The basic problem is that at any given time we only have two versions
|
||||
// of the ACL file at any given point. In order to check if there has been
|
||||
// tampering of the ACL files in the admin panel, we need to have a _third_ version
|
||||
// to compare against.
|
||||
//
|
||||
// In this case I am not storing the old ACL entirely (though that could be a
|
||||
// reasonable thing to add in the future), but only its sha256sum. This allows
|
||||
// us to detect if the shasum in control matches the shasum we expect, and if that
|
||||
// expectation fails, then we can react accordingly.
|
||||
type Cache struct {
|
||||
PrevETag string // Stores the previous ETag of the ACL to allow
|
||||
}
|
||||
|
||||
// Save persists the cache to a given file.
|
||||
func (c *Cache) Save(fname string) error {
|
||||
os.Remove(fname)
|
||||
fout, err := os.Create(fname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fout.Close()
|
||||
|
||||
return json.NewEncoder(fout).Encode(c)
|
||||
}
|
||||
|
||||
// LoadCache loads the cache from a given file.
|
||||
func LoadCache(fname string) (*Cache, error) {
|
||||
var result Cache
|
||||
|
||||
fin, err := os.Open(fname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
err = json.NewDecoder(fin).Decode(&result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Shuck removes the first and last character of a string, analogous to
|
||||
// shucking off the husk of an ear of corn.
|
||||
func Shuck(s string) string {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
373
cmd/gitops-pusher/gitops-pusher.go
Normal file
373
cmd/gitops-pusher/gitops-pusher.go
Normal file
@@ -0,0 +1,373 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Command gitops-pusher allows users to use a GitOps flow for managing Tailscale ACLs.
|
||||
//
|
||||
// See README.md for more details.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"github.com/tailscale/hujson"
|
||||
)
|
||||
|
||||
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)")
|
||||
|
||||
modifiedExternallyFailure = make(chan struct{}, 1)
|
||||
)
|
||||
|
||||
func modifiedExternallyError() {
|
||||
if *githubSyntax {
|
||||
fmt.Printf("::error file=%s,line=1,col=1,title=Policy File Modified Externally::The policy file was modified externally in the admin console.\n", *policyFname)
|
||||
} else {
|
||||
fmt.Printf("The policy file was modified externally in the admin console.\n")
|
||||
}
|
||||
modifiedExternallyFailure <- struct{}{}
|
||||
}
|
||||
|
||||
func apply(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
|
||||
return func(ctx context.Context, args []string) error {
|
||||
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localEtag, err := sumFile(*policyFname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cache.PrevETag == "" {
|
||||
log.Println("no previous etag found, assuming local file is correct and recording that")
|
||||
cache.PrevETag = localEtag
|
||||
}
|
||||
|
||||
log.Printf("control: %s", controlEtag)
|
||||
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 err := applyNewACL(ctx, tailnet, apiKey, *policyFname, controlEtag); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cache.PrevETag = localEtag
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func test(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
|
||||
return func(ctx context.Context, args []string) error {
|
||||
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localEtag, err := sumFile(*policyFname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cache.PrevETag == "" {
|
||||
log.Println("no previous etag found, assuming local file is correct and recording that")
|
||||
cache.PrevETag = localEtag
|
||||
}
|
||||
|
||||
log.Printf("control: %s", controlEtag)
|
||||
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 err := testNewACLs(ctx, tailnet, apiKey, *policyFname); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func getChecksums(cache *Cache, tailnet, apiKey string) func(context.Context, []string) error {
|
||||
return func(ctx context.Context, args []string) error {
|
||||
controlEtag, err := getACLETag(ctx, tailnet, apiKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localEtag, err := sumFile(*policyFname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cache.PrevETag == "" {
|
||||
log.Println("no previous etag found, assuming local file is correct and recording that")
|
||||
cache.PrevETag = Shuck(localEtag)
|
||||
}
|
||||
|
||||
log.Printf("control: %s", controlEtag)
|
||||
log.Printf("local: %s", localEtag)
|
||||
log.Printf("cache: %s", cache.PrevETag)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
tailnet, ok := os.LookupEnv("TS_TAILNET")
|
||||
if !ok {
|
||||
log.Fatal("set envvar TS_TAILNET to your tailnet's name")
|
||||
}
|
||||
apiKey, ok := os.LookupEnv("TS_API_KEY")
|
||||
if !ok {
|
||||
log.Fatal("set envvar TS_API_KEY to your Tailscale API key")
|
||||
}
|
||||
cache, err := LoadCache(*cacheFname)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
cache = &Cache{}
|
||||
} else {
|
||||
log.Fatalf("error loading cache: %v", err)
|
||||
}
|
||||
}
|
||||
defer cache.Save(*cacheFname)
|
||||
|
||||
applyCmd := &ffcli.Command{
|
||||
Name: "apply",
|
||||
ShortUsage: "gitops-pusher [options] apply",
|
||||
ShortHelp: "Pushes changes to CONTROL",
|
||||
LongHelp: `Pushes changes to CONTROL`,
|
||||
Exec: apply(cache, tailnet, apiKey),
|
||||
}
|
||||
|
||||
testCmd := &ffcli.Command{
|
||||
Name: "test",
|
||||
ShortUsage: "gitops-pusher [options] test",
|
||||
ShortHelp: "Tests ACL changes",
|
||||
LongHelp: "Tests ACL changes",
|
||||
Exec: test(cache, tailnet, apiKey),
|
||||
}
|
||||
|
||||
cksumCmd := &ffcli.Command{
|
||||
Name: "checksum",
|
||||
ShortUsage: "Shows checksums of ACL files",
|
||||
ShortHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison",
|
||||
LongHelp: "Fetch checksum of CONTROL's ACL and the local ACL for comparison",
|
||||
Exec: getChecksums(cache, tailnet, apiKey),
|
||||
}
|
||||
|
||||
root := &ffcli.Command{
|
||||
ShortUsage: "gitops-pusher [options] <command>",
|
||||
ShortHelp: "Push Tailscale ACLs to CONTROL using a GitOps workflow",
|
||||
Subcommands: []*ffcli.Command{applyCmd, cksumCmd, testCmd},
|
||||
FlagSet: rootFlagSet,
|
||||
}
|
||||
|
||||
if err := root.Parse(os.Args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
|
||||
if err := root.Run(ctx); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(modifiedExternallyFailure) != 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func sumFile(fname string) (string, error) {
|
||||
data, err := os.ReadFile(fname)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
formatted, err := hujson.Format(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
_, err = h.Write(formatted)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func applyNewACL(ctx context.Context, tailnet, apiKey, policyFname, oldEtag string) error {
|
||||
fin, err := os.Open(policyFname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl", tailnet), fin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(apiKey, "")
|
||||
req.Header.Set("Content-Type", "application/hujson")
|
||||
req.Header.Set("If-Match", `"`+oldEtag+`"`)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := resp.StatusCode
|
||||
want := http.StatusOK
|
||||
if got != want {
|
||||
var ate ACLTestError
|
||||
err := json.NewDecoder(resp.Body).Decode(&ate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ate
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func testNewACLs(ctx context.Context, tailnet, apiKey, policyFname string) error {
|
||||
fin, err := os.Open(policyFname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl/validate", tailnet), fin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(apiKey, "")
|
||||
req.Header.Set("Content-Type", "application/hujson")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := resp.StatusCode
|
||||
want := http.StatusOK
|
||||
if got != want {
|
||||
return fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
|
||||
}
|
||||
|
||||
var ate ACLTestError
|
||||
err = json.NewDecoder(resp.Body).Decode(&ate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(ate.Message) != 0 || len(ate.Data) != 0 {
|
||||
return ate
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var lineColMessageSplit = regexp.MustCompile(`line ([0-9]+), column ([0-9]+): (.*)$`)
|
||||
|
||||
type ACLTestError struct {
|
||||
Message string `json:"message"`
|
||||
Data []ACLTestErrorDetail `json:"data"`
|
||||
}
|
||||
|
||||
func (ate ACLTestError) Error() string {
|
||||
var sb strings.Builder
|
||||
|
||||
if *githubSyntax && lineColMessageSplit.MatchString(ate.Message) {
|
||||
sp := lineColMessageSplit.FindStringSubmatch(ate.Message)
|
||||
|
||||
line := sp[1]
|
||||
col := sp[2]
|
||||
msg := sp[3]
|
||||
|
||||
fmt.Fprintf(&sb, "::error file=%s,line=%s,col=%s::%s", *policyFname, line, col, msg)
|
||||
} else {
|
||||
fmt.Fprintln(&sb, ate.Message)
|
||||
}
|
||||
fmt.Fprintln(&sb)
|
||||
|
||||
for _, data := range ate.Data {
|
||||
fmt.Fprintf(&sb, "For user %s:\n", data.User)
|
||||
for _, err := range data.Errors {
|
||||
fmt.Fprintf(&sb, "- %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type ACLTestErrorDetail struct {
|
||||
User string `json:"user"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
func getACLETag(ctx context.Context, tailnet, apiKey string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl", tailnet), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(apiKey, "")
|
||||
req.Header.Set("Accept", "application/hujson")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := resp.StatusCode
|
||||
want := http.StatusOK
|
||||
if got != want {
|
||||
return "", fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
|
||||
}
|
||||
|
||||
return Shuck(resp.Header.Get("ETag")), nil
|
||||
}
|
||||
@@ -19,10 +19,15 @@ import (
|
||||
var (
|
||||
goToolchain = flag.Bool("go", false, "print the supported Go toolchain git hash (a github.com/tailscale/go commit)")
|
||||
goToolchainURL = flag.Bool("go-url", false, "print the URL to the tarball of the Tailscale Go toolchain")
|
||||
alpine = flag.Bool("alpine", false, "print the tag of alpine docker image")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *alpine {
|
||||
fmt.Println(strings.TrimSpace(ts.AlpineDockerTag))
|
||||
return
|
||||
}
|
||||
if *goToolchain {
|
||||
fmt.Println(strings.TrimSpace(ts.GoToolchainRev))
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ func main() {
|
||||
Hostname: *hostname,
|
||||
}
|
||||
|
||||
// TODO(bradfitz,maisem): move this to a method on tsnet.Server probably.
|
||||
if err := ts.Start(); err != nil {
|
||||
log.Fatalf("Error starting tsnet.Server: %v", err)
|
||||
}
|
||||
localClient, _ := ts.LocalClient()
|
||||
|
||||
url, err := url.Parse(fmt.Sprintf("http://%s", *backendAddr))
|
||||
if err != nil {
|
||||
log.Fatalf("couldn't parse backend address: %v", err)
|
||||
@@ -71,20 +77,20 @@ func main() {
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
modifyRequest(req)
|
||||
modifyRequest(req, localClient)
|
||||
}
|
||||
|
||||
var ln net.Listener
|
||||
if *useHTTPS {
|
||||
ln, err = ts.Listen("tcp", ":443")
|
||||
ln = tls.NewListener(ln, &tls.Config{
|
||||
GetCertificate: tailscale.GetCertificate,
|
||||
GetCertificate: localClient.GetCertificate,
|
||||
})
|
||||
|
||||
go func() {
|
||||
// wait for tailscale to start before trying to fetch cert names
|
||||
for i := 0; i < 60; i++ {
|
||||
st, err := tailscale.Status(context.Background())
|
||||
st, err := localClient.Status(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("error retrieving tailscale status; retrying: %v", err)
|
||||
} else {
|
||||
@@ -100,7 +106,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
name, ok := tailscale.ExpandSNIName(context.Background(), *hostname)
|
||||
name, ok := localClient.ExpandSNIName(context.Background(), *hostname)
|
||||
if !ok {
|
||||
log.Fatalf("can't get hostname for https redirect")
|
||||
}
|
||||
@@ -120,14 +126,14 @@ func main() {
|
||||
log.Fatal(http.Serve(ln, proxy))
|
||||
}
|
||||
|
||||
func modifyRequest(req *http.Request) {
|
||||
func modifyRequest(req *http.Request, localClient *tailscale.LocalClient) {
|
||||
// with enable_login_token set to true, we get a cookie that handles
|
||||
// auth for paths that are not /login
|
||||
if req.URL.Path != "/login" {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := getTailscaleUser(req.Context(), req.RemoteAddr)
|
||||
user, err := getTailscaleUser(req.Context(), localClient, req.RemoteAddr)
|
||||
if err != nil {
|
||||
log.Printf("error getting Tailscale user: %v", err)
|
||||
return
|
||||
@@ -137,8 +143,8 @@ func modifyRequest(req *http.Request) {
|
||||
req.Header.Set("X-Webauth-Name", user.DisplayName)
|
||||
}
|
||||
|
||||
func getTailscaleUser(ctx context.Context, ipPort string) (*tailcfg.UserProfile, error) {
|
||||
whois, err := tailscale.WhoIs(ctx, ipPort)
|
||||
func getTailscaleUser(ctx context.Context, localClient *tailscale.LocalClient, ipPort string) (*tailcfg.UserProfile, error) {
|
||||
whois, err := localClient.WhoIs(ctx, ipPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to identify remote host: %w", err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"errors"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
var bugReportCmd = &ffcli.Command{
|
||||
@@ -28,7 +27,7 @@ func runBugReport(ctx context.Context, args []string) error {
|
||||
default:
|
||||
return errors.New("unknown argumets")
|
||||
}
|
||||
logMarker, err := tailscale.BugReport(ctx, note)
|
||||
logMarker, err := localClient.BugReport(ctx, note)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/atomicfile"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
@@ -46,11 +45,11 @@ func runCert(ctx context.Context, args []string) error {
|
||||
if certArgs.serve {
|
||||
s := &http.Server{
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: tailscale.GetCertificate,
|
||||
GetCertificate: localClient.GetCertificate,
|
||||
},
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil && !strings.Contains(r.Host, ".") && r.Method == "GET" {
|
||||
if v, ok := tailscale.ExpandSNIName(r.Context(), r.Host); ok {
|
||||
if v, ok := localClient.ExpandSNIName(r.Context(), r.Host); ok {
|
||||
http.Redirect(w, r, "https://"+v+r.URL.Path, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
@@ -64,7 +63,7 @@ func runCert(ctx context.Context, args []string) error {
|
||||
|
||||
if len(args) != 1 {
|
||||
var hint bytes.Buffer
|
||||
if st, err := tailscale.Status(ctx); err == nil {
|
||||
if st, err := localClient.Status(ctx); err == nil {
|
||||
if st.BackendState != ipn.Running.String() {
|
||||
fmt.Fprintf(&hint, "\nTailscale is not running.\n")
|
||||
} else if len(st.CertDomains) == 0 {
|
||||
@@ -90,7 +89,7 @@ func runCert(ctx context.Context, args []string) error {
|
||||
certArgs.certFile = domain + ".crt"
|
||||
certArgs.keyFile = domain + ".key"
|
||||
}
|
||||
certPEM, keyPEM, err := tailscale.CertPair(ctx, domain)
|
||||
certPEM, keyPEM, err := localClient.CertPair(ctx, domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -125,8 +125,12 @@ func CleanUpArgs(args []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
var localClient tailscale.LocalClient
|
||||
|
||||
// Run runs the CLI. The args do not include the binary name.
|
||||
func Run(args []string) (err error) {
|
||||
args = CleanUpArgs(args)
|
||||
|
||||
if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") {
|
||||
args = []string{"version"}
|
||||
}
|
||||
@@ -193,10 +197,10 @@ change in the future.
|
||||
return err
|
||||
}
|
||||
|
||||
tailscale.TailscaledSocket = rootArgs.socket
|
||||
localClient.Socket = rootArgs.socket
|
||||
rootfs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "socket" {
|
||||
tailscale.TailscaledSocketSetExplicitly = true
|
||||
localClient.UseSocketOnly = true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -493,14 +493,16 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyImplicitPrefs(newPrefs, tt.curPrefs, tt.curUser)
|
||||
var got string
|
||||
if err := checkForAccidentalSettingReverts(newPrefs, tt.curPrefs, upCheckEnv{
|
||||
upEnv := upCheckEnv{
|
||||
goos: goos,
|
||||
flagSet: flagSet,
|
||||
curExitNodeIP: tt.curExitNodeIP,
|
||||
distro: tt.distro,
|
||||
}); err != nil {
|
||||
user: tt.curUser,
|
||||
}
|
||||
applyImplicitPrefs(newPrefs, tt.curPrefs, upEnv)
|
||||
var got string
|
||||
if err := checkForAccidentalSettingReverts(newPrefs, tt.curPrefs, upEnv); err != nil {
|
||||
got = err.Error()
|
||||
}
|
||||
if strings.TrimSpace(got) != tt.want {
|
||||
@@ -664,11 +666,11 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
||||
goos: "linux",
|
||||
args: upArgsT{
|
||||
advertiseRoutes: "fd7a:115c:a1e0:b1a::bb:10.0.0.0/112",
|
||||
netfilterMode: "off",
|
||||
netfilterMode: "off",
|
||||
},
|
||||
want: &ipn.Prefs{
|
||||
WantRunning: true,
|
||||
NoSNAT: true,
|
||||
WantRunning: true,
|
||||
NoSNAT: true,
|
||||
AdvertiseRoutes: []netaddr.IPPrefix{
|
||||
netaddr.MustParseIPPrefix("fd7a:115c:a1e0:b1a::bb:10.0.0.0/112"),
|
||||
},
|
||||
@@ -679,7 +681,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
||||
goos: "linux",
|
||||
args: upArgsT{
|
||||
advertiseRoutes: "fd7a:115c:a1e0:b1a::/64",
|
||||
netfilterMode: "off",
|
||||
netfilterMode: "off",
|
||||
},
|
||||
wantErr: "fd7a:115c:a1e0:b1a::/64 4-in-6 prefix must be at least a /96",
|
||||
},
|
||||
@@ -688,7 +690,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
|
||||
goos: "linux",
|
||||
args: upArgsT{
|
||||
advertiseRoutes: "fd7a:115c:a1e0:b1a:1234:5678::/112",
|
||||
netfilterMode: "off",
|
||||
netfilterMode: "off",
|
||||
},
|
||||
wantErr: "route fd7a:115c:a1e0:b1a:1234:5678::/112 contains invalid site ID 12345678; must be 0xff or less",
|
||||
},
|
||||
@@ -784,6 +786,10 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
curPrefs *ipn.Prefs
|
||||
env upCheckEnv // empty goos means "linux"
|
||||
|
||||
// checkUpdatePrefsMutations, if non-nil, is run with the new prefs after
|
||||
// updatePrefs might've mutated them (from applyImplicitPrefs).
|
||||
checkUpdatePrefsMutations func(t *testing.T, newPrefs *ipn.Prefs)
|
||||
|
||||
wantSimpleUp bool
|
||||
wantJustEditMP *ipn.MaskedPrefs
|
||||
wantErrSubtr string
|
||||
@@ -885,6 +891,28 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
},
|
||||
env: upCheckEnv{backendState: "Running"},
|
||||
},
|
||||
{
|
||||
// Issue 3808: explicitly empty --operator= should clear value.
|
||||
name: "explicit_empty_operator",
|
||||
flags: []string{"--operator="},
|
||||
curPrefs: &ipn.Prefs{
|
||||
ControlURL: "https://login.tailscale.com",
|
||||
CorpDNS: true,
|
||||
AllowSingleHosts: true,
|
||||
NetfilterMode: preftype.NetfilterOn,
|
||||
OperatorUser: "somebody",
|
||||
},
|
||||
env: upCheckEnv{user: "somebody", backendState: "Running"},
|
||||
wantJustEditMP: &ipn.MaskedPrefs{
|
||||
OperatorUserSet: true,
|
||||
WantRunningSet: true,
|
||||
},
|
||||
checkUpdatePrefsMutations: func(t *testing.T, prefs *ipn.Prefs) {
|
||||
if prefs.OperatorUser != "" {
|
||||
t.Errorf("operator sent to backend should be empty; got %q", prefs.OperatorUser)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -909,6 +937,9 @@ func TestUpdatePrefs(t *testing.T) {
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.checkUpdatePrefsMutations != nil {
|
||||
tt.checkUpdatePrefsMutations(t, newPrefs)
|
||||
}
|
||||
if simpleUp != tt.wantSimpleUp {
|
||||
t.Fatalf("simpleUp=%v, want %v", simpleUp, tt.wantSimpleUp)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
@@ -23,12 +25,14 @@ import (
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/control/controlhttp"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
var debugCmd = &ffcli.Command{
|
||||
@@ -69,6 +73,11 @@ var debugCmd = &ffcli.Command{
|
||||
Exec: runEnv,
|
||||
ShortHelp: "print cmd/tailscale environment",
|
||||
},
|
||||
{
|
||||
Name: "stat",
|
||||
Exec: runStat,
|
||||
ShortHelp: "stat a file",
|
||||
},
|
||||
{
|
||||
Name: "hostinfo",
|
||||
Exec: runHostinfo,
|
||||
@@ -114,6 +123,17 @@ var debugCmd = &ffcli.Command{
|
||||
Exec: runVia,
|
||||
ShortHelp: "convert between site-specific IPv4 CIDRs and IPv6 'via' routes",
|
||||
},
|
||||
{
|
||||
Name: "ts2021",
|
||||
Exec: runTS2021,
|
||||
ShortHelp: "debug ts2021 protocol connectivity",
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("ts2021")
|
||||
fs.StringVar(&ts2021Args.host, "host", "controlplane.tailscale.com", "hostname of control plane")
|
||||
fs.IntVar(&ts2021Args.version, "version", int(tailcfg.CurrentCapabilityVersion), "protocol version")
|
||||
return fs
|
||||
})(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -150,7 +170,7 @@ func runDebug(ctx context.Context, args []string) error {
|
||||
if out := debugArgs.cpuFile; out != "" {
|
||||
usedFlag = true // TODO(bradfitz): add "profile" subcommand
|
||||
log.Printf("Capturing CPU profile for %v seconds ...", debugArgs.cpuSec)
|
||||
if v, err := tailscale.Profile(ctx, "profile", debugArgs.cpuSec); err != nil {
|
||||
if v, err := localClient.Profile(ctx, "profile", debugArgs.cpuSec); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if err := writeProfile(out, v); err != nil {
|
||||
@@ -162,7 +182,7 @@ func runDebug(ctx context.Context, args []string) error {
|
||||
if out := debugArgs.memFile; out != "" {
|
||||
usedFlag = true // TODO(bradfitz): add "profile" subcommand
|
||||
log.Printf("Capturing memory profile ...")
|
||||
if v, err := tailscale.Profile(ctx, "heap", 0); err != nil {
|
||||
if v, err := localClient.Profile(ctx, "heap", 0); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if err := writeProfile(out, v); err != nil {
|
||||
@@ -174,7 +194,7 @@ func runDebug(ctx context.Context, args []string) error {
|
||||
if debugArgs.file != "" {
|
||||
usedFlag = true // TODO(bradfitz): add "file" subcommand
|
||||
if debugArgs.file == "get" {
|
||||
wfs, err := tailscale.WaitingFiles(ctx)
|
||||
wfs, err := localClient.WaitingFiles(ctx)
|
||||
if err != nil {
|
||||
fatalf("%v\n", err)
|
||||
}
|
||||
@@ -185,9 +205,9 @@ func runDebug(ctx context.Context, args []string) error {
|
||||
}
|
||||
delete := strings.HasPrefix(debugArgs.file, "delete:")
|
||||
if delete {
|
||||
return tailscale.DeleteWaitingFile(ctx, strings.TrimPrefix(debugArgs.file, "delete:"))
|
||||
return localClient.DeleteWaitingFile(ctx, strings.TrimPrefix(debugArgs.file, "delete:"))
|
||||
}
|
||||
rc, size, err := tailscale.GetWaitingFile(ctx, debugArgs.file)
|
||||
rc, size, err := localClient.GetWaitingFile(ctx, debugArgs.file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -222,7 +242,7 @@ var prefsArgs struct {
|
||||
}
|
||||
|
||||
func runPrefs(ctx context.Context, args []string) error {
|
||||
prefs, err := tailscale.GetPrefs(ctx)
|
||||
prefs, err := localClient.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -256,7 +276,7 @@ func runWatchIPN(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
func runDERPMap(ctx context.Context, args []string) error {
|
||||
dm, err := tailscale.CurrentDERPMap(ctx)
|
||||
dm, err := localClient.CurrentDERPMap(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to get local derp map, instead `curl %s/derpmap/default`: %w", ipn.DefaultControlURL, err,
|
||||
@@ -273,7 +293,7 @@ func localAPIAction(action string) func(context.Context, []string) error {
|
||||
if len(args) > 0 {
|
||||
return errors.New("unexpected arguments")
|
||||
}
|
||||
return tailscale.DebugAction(ctx, action)
|
||||
return localClient.DebugAction(ctx, action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +304,28 @@ func runEnv(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStat(ctx context.Context, args []string) error {
|
||||
for _, a := range args {
|
||||
fi, err := os.Lstat(a)
|
||||
if err != nil {
|
||||
fmt.Printf("%s: %v\n", a, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s: %v, %v\n", a, fi.Mode(), fi.Size())
|
||||
if fi.IsDir() {
|
||||
ents, _ := os.ReadDir(a)
|
||||
for i, ent := range ents {
|
||||
if i == 25 {
|
||||
fmt.Printf(" ...\n")
|
||||
break
|
||||
}
|
||||
fmt.Printf(" - %s\n", ent.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runHostinfo(ctx context.Context, args []string) error {
|
||||
hi := hostinfo.New()
|
||||
j, _ := json.MarshalIndent(hi, "", " ")
|
||||
@@ -292,7 +334,7 @@ func runHostinfo(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
func runDaemonGoroutines(ctx context.Context, args []string) error {
|
||||
goroutines, err := tailscale.Goroutines(ctx)
|
||||
goroutines, err := localClient.Goroutines(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -307,7 +349,7 @@ var metricsArgs struct {
|
||||
func runDaemonMetrics(ctx context.Context, args []string) error {
|
||||
last := map[string]int64{}
|
||||
for {
|
||||
out, err := tailscale.DaemonMetrics(ctx)
|
||||
out, err := localClient.DaemonMetrics(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -399,3 +441,67 @@ func runVia(ctx context.Context, args []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var ts2021Args struct {
|
||||
host string // "controlplane.tailscale.com"
|
||||
version int // 27 or whatever
|
||||
}
|
||||
|
||||
func runTS2021(ctx context.Context, args []string) error {
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetFlags(log.Ltime | log.Lmicroseconds)
|
||||
|
||||
machinePrivate := key.NewMachine()
|
||||
var dialer net.Dialer
|
||||
|
||||
var keys struct {
|
||||
PublicKey key.MachinePublic
|
||||
}
|
||||
keysURL := "https://" + ts2021Args.host + "/key?v=" + strconv.Itoa(ts2021Args.version)
|
||||
log.Printf("Fetching keys from %s ...", keysURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", keysURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Do: %v", err)
|
||||
return err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
log.Printf("Status: %v", res.Status)
|
||||
return errors.New(res.Status)
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&keys); err != nil {
|
||||
log.Printf("JSON: %v", err)
|
||||
return fmt.Errorf("decoding /keys JSON: %w", err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
dialFunc := func(ctx context.Context, network, address string) (net.Conn, 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)
|
||||
} else {
|
||||
log.Printf("Dial(%q, %q) = %v / %v", network, address, c.LocalAddr(), c.RemoteAddr())
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
conn, err := controlhttp.Dial(ctx, net.JoinHostPort(ts2021Args.host, "80"), machinePrivate, keys.PublicKey, uint16(ts2021Args.version), dialFunc)
|
||||
log.Printf("controlhttp.Dial = %p, %v", conn, err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("did noise handshake")
|
||||
|
||||
gotPeer := conn.Peer()
|
||||
if gotPeer != keys.PublicKey {
|
||||
log.Printf("peer = %v, want %v", gotPeer, keys.PublicKey)
|
||||
return errors.New("key mismatch")
|
||||
}
|
||||
|
||||
log.Printf("final underlying conn: %v / %v", conn.LocalAddr(), conn.RemoteAddr())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
)
|
||||
|
||||
@@ -18,7 +18,14 @@ var downCmd = &ffcli.Command{
|
||||
ShortUsage: "down",
|
||||
ShortHelp: "Disconnect from Tailscale",
|
||||
|
||||
Exec: runDown,
|
||||
Exec: runDown,
|
||||
FlagSet: newDownFlagSet(),
|
||||
}
|
||||
|
||||
func newDownFlagSet() *flag.FlagSet {
|
||||
downf := newFlagSet("down")
|
||||
registerAcceptRiskFlag(downf)
|
||||
return downf
|
||||
}
|
||||
|
||||
func runDown(ctx context.Context, args []string) error {
|
||||
@@ -26,7 +33,13 @@ func runDown(ctx context.Context, args []string) error {
|
||||
return fmt.Errorf("too many non-flag arguments: %q", args)
|
||||
}
|
||||
|
||||
st, err := tailscale.Status(ctx)
|
||||
if isSSHOverTailscale() {
|
||||
if err := presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will disable Tailscale and result in your session disconnecting.`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fetching current status: %w", err)
|
||||
}
|
||||
@@ -34,7 +47,7 @@ func runDown(ctx context.Context, args []string) error {
|
||||
fmt.Fprintf(Stderr, "Tailscale was already stopped.\n")
|
||||
return nil
|
||||
}
|
||||
_, err = tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
_, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
WantRunning: false,
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"golang.org/x/time/rate"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
@@ -157,7 +156,7 @@ func runCp(ctx context.Context, args []string) error {
|
||||
if cpArgs.verbose {
|
||||
log.Printf("sending %q to %v/%v/%v ...", name, target, ip, stableID)
|
||||
}
|
||||
err := tailscale.PushFile(ctx, stableID, contentLength, name, fileContents)
|
||||
err := localClient.PushFile(ctx, stableID, contentLength, name, fileContents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -173,7 +172,7 @@ func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNode
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
fts, err := tailscale.FileTargets(ctx)
|
||||
fts, err := localClient.FileTargets(ctx)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
@@ -194,7 +193,7 @@ func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNode
|
||||
// invalid file sharing target.
|
||||
func fileTargetErrorDetail(ctx context.Context, ip netaddr.IP) error {
|
||||
found := false
|
||||
if st, err := tailscale.Status(ctx); err == nil && st.Self != nil {
|
||||
if st, err := localClient.Status(ctx); err == nil && st.Self != nil {
|
||||
for _, peer := range st.Peer {
|
||||
for _, pip := range peer.TailscaleIPs {
|
||||
if pip == ip {
|
||||
@@ -261,7 +260,7 @@ func runCpTargets(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return errors.New("invalid arguments with --targets")
|
||||
}
|
||||
fts, err := tailscale.FileTargets(ctx)
|
||||
fts, err := localClient.FileTargets(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -385,7 +384,7 @@ func openFileOrSubstitute(dir, base string, action onConflict) (*os.File, error)
|
||||
}
|
||||
|
||||
func receiveFile(ctx context.Context, wf apitype.WaitingFile, dir string) (targetFile string, size int64, err error) {
|
||||
rc, size, err := tailscale.GetWaitingFile(ctx, wf.Name)
|
||||
rc, size, err := localClient.GetWaitingFile(ctx, wf.Name)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("opening inbox file %q: %w", wf.Name, err)
|
||||
}
|
||||
@@ -407,7 +406,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
|
||||
var err error
|
||||
var errs []error
|
||||
for len(errs) == 0 {
|
||||
wfs, err = tailscale.WaitingFiles(ctx)
|
||||
wfs, err = localClient.WaitingFiles(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("getting WaitingFiles: %w", err))
|
||||
break
|
||||
@@ -428,7 +427,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
|
||||
if len(errs) > 100 {
|
||||
// Likely, everything is broken.
|
||||
// Don't try to receive any more files in this batch.
|
||||
errs = append(errs, fmt.Errorf("too many errors in runFileGetOneBatch(). %d files unexamined", len(wfs) - i))
|
||||
errs = append(errs, fmt.Errorf("too many errors in runFileGetOneBatch(). %d files unexamined", len(wfs)-i))
|
||||
break
|
||||
}
|
||||
writtenFile, size, err := receiveFile(ctx, wf, dir)
|
||||
@@ -439,7 +438,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
|
||||
if getArgs.verbose {
|
||||
printf("wrote %v as %v (%d bytes)\n", wf.Name, writtenFile, size)
|
||||
}
|
||||
if err = tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil {
|
||||
if err = localClient.DeleteWaitingFile(ctx, wf.Name); err != nil {
|
||||
errs = append(errs, fmt.Errorf("deleting %q from inbox: %v", wf.Name, err))
|
||||
continue
|
||||
}
|
||||
@@ -503,7 +502,7 @@ func wipeInbox(ctx context.Context) error {
|
||||
if getArgs.wait {
|
||||
return errors.New("can't use --wait with /dev/null target")
|
||||
}
|
||||
wfs, err := tailscale.WaitingFiles(ctx)
|
||||
wfs, err := localClient.WaitingFiles(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting WaitingFiles: %w", err)
|
||||
}
|
||||
@@ -512,7 +511,7 @@ func wipeInbox(ctx context.Context) error {
|
||||
if getArgs.verbose {
|
||||
log.Printf("deleting %v ...", wf.Name)
|
||||
}
|
||||
if err := tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil {
|
||||
if err := localClient.DeleteWaitingFile(ctx, wf.Name); err != nil {
|
||||
return fmt.Errorf("deleting %q: %v", wf.Name, err)
|
||||
}
|
||||
deleted++
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
var idTokenCmd = &ffcli.Command{
|
||||
@@ -25,7 +24,7 @@ func runIDToken(ctx context.Context, args []string) error {
|
||||
return errors.New("usage: id-token <aud>")
|
||||
}
|
||||
|
||||
tr, err := tailscale.IDToken(ctx, args[0])
|
||||
tr, err := localClient.IDToken(ctx, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
@@ -59,7 +58,7 @@ func runIP(ctx context.Context, args []string) error {
|
||||
if !v4 && !v6 {
|
||||
v4, v6 = true, true
|
||||
}
|
||||
st, err := tailscale.Status(ctx)
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
var logoutCmd = &ffcli.Command{
|
||||
@@ -30,5 +29,5 @@ func runLogout(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("too many non-flag arguments: %q", args)
|
||||
}
|
||||
return tailscale.Logout(ctx)
|
||||
return localClient.Logout(ctx)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
)
|
||||
|
||||
var ncCmd = &ffcli.Command{
|
||||
@@ -24,7 +23,7 @@ var ncCmd = &ffcli.Command{
|
||||
}
|
||||
|
||||
func runNC(ctx context.Context, args []string) error {
|
||||
st, err := tailscale.Status(ctx)
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return fixTailscaledConnectError(err)
|
||||
}
|
||||
@@ -45,7 +44,7 @@ func runNC(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
// TODO(bradfitz): also add UDP too, via flag?
|
||||
c, err := tailscale.DialTCP(ctx, hostOrIP, uint16(port))
|
||||
c, err := localClient.DialTCP(ctx, hostOrIP, uint16(port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Dial(%q, %v): %w", hostOrIP, port, err)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/netcheck"
|
||||
@@ -63,7 +62,7 @@ func runNetcheck(ctx context.Context, args []string) error {
|
||||
fmt.Fprintln(Stderr, "# Warning: this JSON format is not yet considered a stable interface")
|
||||
}
|
||||
|
||||
dm, err := tailscale.CurrentDERPMap(ctx)
|
||||
dm, err := localClient.CurrentDERPMap(ctx)
|
||||
noRegions := dm != nil && len(dm.Regions) == 0
|
||||
if noRegions {
|
||||
log.Printf("No DERP map from tailscaled; using default.")
|
||||
@@ -127,8 +126,10 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
|
||||
printf("\t* IPv6: yes, %v\n", report.GlobalV6)
|
||||
} else if report.IPv6 {
|
||||
printf("\t* IPv6: (no addr found)\n")
|
||||
} else if report.OSHasIPv6 {
|
||||
printf("\t* IPv6: no, but OS has support\n")
|
||||
} else {
|
||||
printf("\t* IPv6: no\n")
|
||||
printf("\t* IPv6: no, unavailable in OS\n")
|
||||
}
|
||||
printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP)
|
||||
printf("\t* HairPinning: %v\n", report.HairPinning)
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
var pingCmd = &ffcli.Command{
|
||||
@@ -27,7 +27,7 @@ var pingCmd = &ffcli.Command{
|
||||
ShortHelp: "Ping a host at the Tailscale layer, see how it routed",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
|
||||
The 'tailscale ping' command pings a peer node at the Tailscale layer
|
||||
The 'tailscale ping' command pings a peer node from the Tailscale layer
|
||||
and reports which route it took for each response. The first ping or
|
||||
so will likely go over DERP (Tailscale's TCP relay protocol) while NAT
|
||||
traversal finds a direct path through.
|
||||
@@ -49,7 +49,9 @@ relay node.
|
||||
fs := newFlagSet("ping")
|
||||
fs.BoolVar(&pingArgs.verbose, "verbose", false, "verbose output")
|
||||
fs.BoolVar(&pingArgs.untilDirect, "until-direct", true, "stop once a direct path is established")
|
||||
fs.BoolVar(&pingArgs.tsmp, "tsmp", false, "do a TSMP-level ping (through IP + wireguard, but not involving host OS stack)")
|
||||
fs.BoolVar(&pingArgs.tsmp, "tsmp", false, "do a TSMP-level ping (through WireGuard, but not either host OS stack)")
|
||||
fs.BoolVar(&pingArgs.icmp, "icmp", false, "do a ICMP-level ping (through WireGuard, but not the local host OS stack)")
|
||||
fs.BoolVar(&pingArgs.peerAPI, "peerapi", false, "try hitting the peer's peerapi HTTP server")
|
||||
fs.IntVar(&pingArgs.num, "c", 10, "max number of pings to send")
|
||||
fs.DurationVar(&pingArgs.timeout, "timeout", 5*time.Second, "timeout before giving up on a ping")
|
||||
return fs
|
||||
@@ -61,11 +63,26 @@ var pingArgs struct {
|
||||
untilDirect bool
|
||||
verbose bool
|
||||
tsmp bool
|
||||
icmp bool
|
||||
peerAPI bool
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func pingType() tailcfg.PingType {
|
||||
if pingArgs.tsmp {
|
||||
return tailcfg.PingTSMP
|
||||
}
|
||||
if pingArgs.icmp {
|
||||
return tailcfg.PingICMP
|
||||
}
|
||||
if pingArgs.peerAPI {
|
||||
return tailcfg.PingPeerAPI
|
||||
}
|
||||
return tailcfg.PingDisco
|
||||
}
|
||||
|
||||
func runPing(ctx context.Context, args []string) error {
|
||||
st, err := tailscale.Status(ctx)
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return fixTailscaledConnectError(err)
|
||||
}
|
||||
@@ -75,24 +92,10 @@ func runPing(ctx context.Context, args []string) error {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
c, bc, ctx, cancel := connect(ctx)
|
||||
defer cancel()
|
||||
|
||||
if len(args) != 1 || args[0] == "" {
|
||||
return errors.New("usage: ping <hostname-or-IP>")
|
||||
}
|
||||
var ip string
|
||||
prc := make(chan *ipnstate.PingResult, 1)
|
||||
bc.SetNotifyCallback(func(n ipn.Notify) {
|
||||
if n.ErrMessage != nil {
|
||||
fatalf("Notify.ErrMessage: %v", *n.ErrMessage)
|
||||
}
|
||||
if pr := n.PingResult; pr != nil && pr.IP == ip {
|
||||
prc <- pr
|
||||
}
|
||||
})
|
||||
pumpErr := make(chan error, 1)
|
||||
go func() { pumpErr <- pump(ctx, bc, c) }()
|
||||
|
||||
hostOrIP := args[0]
|
||||
ip, self, err := tailscaleIPFromArg(ctx, hostOrIP)
|
||||
@@ -112,48 +115,57 @@ func runPing(ctx context.Context, args []string) error {
|
||||
anyPong := false
|
||||
for {
|
||||
n++
|
||||
bc.Ping(ip, pingArgs.tsmp)
|
||||
timer := time.NewTimer(pingArgs.timeout)
|
||||
select {
|
||||
case <-timer.C:
|
||||
printf("timeout waiting for ping reply\n")
|
||||
case err := <-pumpErr:
|
||||
return err
|
||||
case pr := <-prc:
|
||||
timer.Stop()
|
||||
if pr.Err != "" {
|
||||
if pr.IsLocalIP {
|
||||
outln(pr.Err)
|
||||
ctx, cancel := context.WithTimeout(ctx, pingArgs.timeout)
|
||||
pr, err := localClient.Ping(ctx, netaddr.MustParseIP(ip), pingType())
|
||||
cancel()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
printf("ping %q timed out\n", ip)
|
||||
if n == pingArgs.num {
|
||||
if !anyPong {
|
||||
return errors.New("no reply")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New(pr.Err)
|
||||
continue
|
||||
}
|
||||
latency := time.Duration(pr.LatencySeconds * float64(time.Second)).Round(time.Millisecond)
|
||||
via := pr.Endpoint
|
||||
if pr.DERPRegionID != 0 {
|
||||
via = fmt.Sprintf("DERP(%s)", pr.DERPRegionCode)
|
||||
}
|
||||
if pingArgs.tsmp {
|
||||
// TODO(bradfitz): populate the rest of ipnstate.PingResult for TSMP queries?
|
||||
// For now just say it came via TSMP.
|
||||
via = "TSMP"
|
||||
}
|
||||
anyPong = true
|
||||
extra := ""
|
||||
if pr.PeerAPIPort != 0 {
|
||||
extra = fmt.Sprintf(", %d", pr.PeerAPIPort)
|
||||
}
|
||||
printf("pong from %s (%s%s) via %v in %v\n", pr.NodeName, pr.NodeIP, extra, via, latency)
|
||||
if pingArgs.tsmp {
|
||||
return nil
|
||||
}
|
||||
if pr.Endpoint != "" && pingArgs.untilDirect {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
return err
|
||||
}
|
||||
if pr.Err != "" {
|
||||
if pr.IsLocalIP {
|
||||
outln(pr.Err)
|
||||
return nil
|
||||
}
|
||||
return errors.New(pr.Err)
|
||||
}
|
||||
latency := time.Duration(pr.LatencySeconds * float64(time.Second)).Round(time.Millisecond)
|
||||
via := pr.Endpoint
|
||||
if pr.DERPRegionID != 0 {
|
||||
via = fmt.Sprintf("DERP(%s)", pr.DERPRegionCode)
|
||||
}
|
||||
if via == "" {
|
||||
// TODO(bradfitz): populate the rest of ipnstate.PingResult for TSMP queries?
|
||||
// For now just say which protocol it used.
|
||||
via = string(pingType())
|
||||
}
|
||||
if pingArgs.peerAPI {
|
||||
printf("hit peerapi of %s (%s) at %s in %s\n", pr.NodeIP, pr.NodeName, pr.PeerAPIURL, latency)
|
||||
return nil
|
||||
}
|
||||
anyPong = true
|
||||
extra := ""
|
||||
if pr.PeerAPIPort != 0 {
|
||||
extra = fmt.Sprintf(", %d", pr.PeerAPIPort)
|
||||
}
|
||||
printf("pong from %s (%s%s) via %v in %v\n", pr.NodeName, pr.NodeIP, extra, via, latency)
|
||||
if pingArgs.tsmp || pingArgs.icmp {
|
||||
return nil
|
||||
}
|
||||
if pr.Endpoint != "" && pingArgs.untilDirect {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
|
||||
if n == pingArgs.num {
|
||||
if !anyPong {
|
||||
return errors.New("no reply")
|
||||
@@ -173,7 +185,7 @@ func tailscaleIPFromArg(ctx context.Context, hostOrIP string) (ip string, self b
|
||||
}
|
||||
|
||||
// Otherwise, try to resolve it first from the network peer list.
|
||||
st, err := tailscale.Status(ctx)
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
78
cmd/tailscale/cli/risks.go
Normal file
78
cmd/tailscale/cli/risks.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
riskTypes []string
|
||||
acceptedRisks string
|
||||
riskLoseSSH = registerRiskType("lose-ssh")
|
||||
)
|
||||
|
||||
func registerRiskType(riskType string) string {
|
||||
riskTypes = append(riskTypes, riskType)
|
||||
return riskType
|
||||
}
|
||||
|
||||
// registerAcceptRiskFlag registers the --accept-risk flag. Accepted risks are accounted for
|
||||
// in presentRiskToUser.
|
||||
func registerAcceptRiskFlag(f *flag.FlagSet) {
|
||||
f.StringVar(&acceptedRisks, "accept-risk", "", "accept risk and skip confirmation for risk types: "+strings.Join(riskTypes, ","))
|
||||
}
|
||||
|
||||
// riskAccepted reports whether riskType is in acceptedRisks.
|
||||
func riskAccepted(riskType string) bool {
|
||||
for _, r := range strings.Split(acceptedRisks, ",") {
|
||||
if r == riskType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var errAborted = errors.New("aborted, no changes made")
|
||||
|
||||
// riskAbortTimeSeconds is the number of seconds to wait after displaying the
|
||||
// risk message before continuing with the operation.
|
||||
// It is used by the presentRiskToUser function below.
|
||||
const riskAbortTimeSeconds = 5
|
||||
|
||||
// presentRiskToUser displays the risk message and waits for the user to
|
||||
// cancel. It returns errorAborted if the user aborts.
|
||||
func presentRiskToUser(riskType, riskMessage string) error {
|
||||
if riskAccepted(riskType) {
|
||||
return nil
|
||||
}
|
||||
fmt.Println(riskMessage)
|
||||
fmt.Printf("To skip this warning, use --accept-risk=%s\n", riskType)
|
||||
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, syscall.SIGINT)
|
||||
var msgLen int
|
||||
for left := riskAbortTimeSeconds; left > 0; left-- {
|
||||
msg := fmt.Sprintf("\rContinuing in %d seconds...", left)
|
||||
msgLen = len(msg)
|
||||
fmt.Print(msg)
|
||||
select {
|
||||
case <-interrupt:
|
||||
fmt.Printf("\r%s\r", strings.Repeat(" ", msgLen+1))
|
||||
return errAborted
|
||||
case <-time.After(time.Second):
|
||||
continue
|
||||
}
|
||||
}
|
||||
fmt.Printf("\r%s\r", strings.Repeat(" ", msgLen))
|
||||
return errAborted
|
||||
}
|
||||
@@ -11,19 +11,17 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/alessio/shellescape"
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
var sshCmd = &ffcli.Command{
|
||||
@@ -34,6 +32,9 @@ var sshCmd = &ffcli.Command{
|
||||
}
|
||||
|
||||
func runSSH(ctx context.Context, args []string) error {
|
||||
if runtime.GOOS == "darwin" && version.IsSandboxedMacOS() && !envknob.UseWIPCode() {
|
||||
return errors.New("The 'tailscale ssh' subcommand is not available on sandboxed macOS builds.\nUse the regular 'ssh' client instead.")
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: ssh [user@]<host>")
|
||||
}
|
||||
@@ -48,7 +49,7 @@ func runSSH(ctx context.Context, args []string) error {
|
||||
username = lu.Username
|
||||
}
|
||||
|
||||
st, err := tailscale.Status(ctx)
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -61,7 +62,7 @@ func runSSH(ctx context.Context, args []string) error {
|
||||
hostForSSH = v
|
||||
}
|
||||
|
||||
ssh, err := exec.LookPath("ssh")
|
||||
ssh, err := findSSH()
|
||||
if err != nil {
|
||||
// TODO(bradfitz): use Go's crypto/ssh client instead
|
||||
// of failing. But for now:
|
||||
@@ -76,55 +77,49 @@ func runSSH(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
argv := append([]string{
|
||||
ssh,
|
||||
argv := []string{ssh}
|
||||
|
||||
if envknob.Bool("TS_DEBUG_SSH_EXEC") {
|
||||
argv = append(argv, "-vvv")
|
||||
}
|
||||
argv = append(argv,
|
||||
// Only trust SSH hosts that we know about.
|
||||
"-o", fmt.Sprintf("UserKnownHostsFile %s",
|
||||
shellescape.Quote(knownHostsFile),
|
||||
),
|
||||
"-o", fmt.Sprintf("UserKnownHostsFile %q", knownHostsFile),
|
||||
"-o", "UpdateHostKeys no",
|
||||
"-o", "StrictHostKeyChecking yes",
|
||||
)
|
||||
|
||||
"-o", fmt.Sprintf("ProxyCommand %s --socket=%s nc %%h %%p",
|
||||
shellescape.Quote(tailscaleBin),
|
||||
shellescape.Quote(rootArgs.socket),
|
||||
),
|
||||
|
||||
// Explicitly rebuild the user@host argument rather than
|
||||
// passing it through. In general, the use of OpenSSH's ssh
|
||||
// binary is a crutch for now. We don't want to be
|
||||
// Hyrum-locked into passing through all OpenSSH flags to the
|
||||
// OpenSSH client forever. We try to make our flags and args
|
||||
// be compatible, but only a subset. The "tailscale ssh"
|
||||
// command should be a simple and portable one. If they want
|
||||
// to use a different one, we'll later be making stock ssh
|
||||
// work well by default too. (doing things like automatically
|
||||
// setting known_hosts, etc)
|
||||
username + "@" + hostForSSH,
|
||||
}, argRest...)
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
// Don't use syscall.Exec on Windows.
|
||||
cmd := exec.Command(ssh, argv[1:]...)
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
var ee *exec.ExitError
|
||||
err := cmd.Run()
|
||||
if errors.As(err, &ee) {
|
||||
os.Exit(ee.ExitCode())
|
||||
}
|
||||
return err
|
||||
// TODO(bradfitz): nc is currently broken on macOS:
|
||||
// https://github.com/tailscale/tailscale/issues/4529
|
||||
// So don't use it for now. MagicDNS is usually working on macOS anyway
|
||||
// and they're not in userspace mode, so 'nc' isn't very useful.
|
||||
if runtime.GOOS != "darwin" {
|
||||
argv = append(argv,
|
||||
"-o", fmt.Sprintf("ProxyCommand %q --socket=%q nc %%h %%p",
|
||||
tailscaleBin,
|
||||
rootArgs.socket,
|
||||
))
|
||||
}
|
||||
|
||||
// Explicitly rebuild the user@host argument rather than
|
||||
// passing it through. In general, the use of OpenSSH's ssh
|
||||
// binary is a crutch for now. We don't want to be
|
||||
// Hyrum-locked into passing through all OpenSSH flags to the
|
||||
// OpenSSH client forever. We try to make our flags and args
|
||||
// be compatible, but only a subset. The "tailscale ssh"
|
||||
// command should be a simple and portable one. If they want
|
||||
// to use a different one, we'll later be making stock ssh
|
||||
// work well by default too. (doing things like automatically
|
||||
// setting known_hosts, etc)
|
||||
argv = append(argv, username+"@"+hostForSSH)
|
||||
|
||||
argv = append(argv, argRest...)
|
||||
|
||||
if envknob.Bool("TS_DEBUG_SSH_EXEC") {
|
||||
log.Printf("Running: %q, %q ...", ssh, argv)
|
||||
}
|
||||
if err := syscall.Exec(ssh, argv, os.Environ()); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("unreachable")
|
||||
|
||||
return execSSH(ssh, argv)
|
||||
}
|
||||
|
||||
func writeKnownHosts(st *ipnstate.Status) (knownHostsFile string, err error) {
|
||||
@@ -188,3 +183,28 @@ func nodeDNSNameFromArg(st *ipnstate.Status, arg string) (dnsName string, ok boo
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// getSSHClientEnvVar returns the "SSH_CLIENT" environment variable
|
||||
// for the current process group, if any.
|
||||
var getSSHClientEnvVar = func() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// isSSHOverTailscale checks if the invocation is in a SSH session over Tailscale.
|
||||
// It is used to detect if the user is about to take an action that might result in them
|
||||
// disconnecting from the machine (e.g. disabling SSH)
|
||||
func isSSHOverTailscale() bool {
|
||||
sshClient := getSSHClientEnvVar()
|
||||
if sshClient == "" {
|
||||
return false
|
||||
}
|
||||
ipStr, _, ok := strings.Cut(sshClient, " ")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ip, err := netaddr.ParseIP(ipStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return tsaddr.IsTailscaleIP(ip)
|
||||
}
|
||||
|
||||
26
cmd/tailscale/cli/ssh_exec.go
Normal file
26
cmd/tailscale/cli/ssh_exec.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !js && !windows
|
||||
// +build !js,!windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func findSSH() (string, error) {
|
||||
return exec.LookPath("ssh")
|
||||
}
|
||||
|
||||
func execSSH(ssh string, argv []string) error {
|
||||
if err := syscall.Exec(ssh, argv, os.Environ()); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("unreachable")
|
||||
}
|
||||
17
cmd/tailscale/cli/ssh_exec_js.go
Normal file
17
cmd/tailscale/cli/ssh_exec_js.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
func findSSH() (string, error) {
|
||||
return "", errors.New("Not implemented")
|
||||
}
|
||||
|
||||
func execSSH(ssh string, argv []string) error {
|
||||
return errors.New("Not implemented")
|
||||
}
|
||||
38
cmd/tailscale/cli/ssh_exec_windows.go
Normal file
38
cmd/tailscale/cli/ssh_exec_windows.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func findSSH() (string, error) {
|
||||
// use C:\Windows\System32\OpenSSH\ssh.exe since unexpected behavior
|
||||
// occured with ssh.exe provided by msys2/cygwin and other environments.
|
||||
if systemRoot := os.Getenv("SystemRoot"); systemRoot != "" {
|
||||
exe := filepath.Join(systemRoot, "System32", "OpenSSH", "ssh.exe")
|
||||
if st, err := os.Stat(exe); err == nil && !st.IsDir() {
|
||||
return exe, nil
|
||||
}
|
||||
}
|
||||
return exec.LookPath("ssh")
|
||||
}
|
||||
|
||||
func execSSH(ssh string, argv []string) error {
|
||||
// Don't use syscall.Exec on Windows, it's not fully implemented.
|
||||
cmd := exec.Command(ssh, argv[1:]...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
var ee *exec.ExitError
|
||||
err := cmd.Run()
|
||||
if errors.As(err, &ee) {
|
||||
os.Exit(ee.ExitCode())
|
||||
}
|
||||
return err
|
||||
}
|
||||
51
cmd/tailscale/cli/ssh_unix.go
Normal file
51
cmd/tailscale/cli/ssh_unix.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !js && !windows
|
||||
// +build !js,!windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func init() {
|
||||
getSSHClientEnvVar = func() string {
|
||||
if os.Getenv("SUDO_USER") == "" {
|
||||
// No sudo, just check the env.
|
||||
return os.Getenv("SSH_CLIENT")
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
// TODO(maisem): implement this for other platforms. It's not clear
|
||||
// if there is a way to get the environment for a given process on
|
||||
// darwin and bsd.
|
||||
return ""
|
||||
}
|
||||
// SID is the session ID of the user's login session.
|
||||
// It is also the process ID of the original shell that the user logged in with.
|
||||
// We only need to check the environment of that process.
|
||||
sid, err := unix.Getsid(os.Getpid())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(sid), "environ"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
prefix := []byte("SSH_CLIENT=")
|
||||
for _, env := range bytes.Split(b, []byte{0}) {
|
||||
if bytes.HasPrefix(env, prefix) {
|
||||
return string(env[len(prefix):])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"github.com/toqueteos/webbrowser"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/interfaces"
|
||||
@@ -73,9 +72,9 @@ func runStatus(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return errors.New("unexpected non-flag arguments to 'tailscale status'")
|
||||
}
|
||||
getStatus := tailscale.Status
|
||||
getStatus := localClient.Status
|
||||
if !statusArgs.peers {
|
||||
getStatus = tailscale.StatusWithoutPeers
|
||||
getStatus = localClient.StatusWithoutPeers
|
||||
}
|
||||
st, err := getStatus(ctx)
|
||||
if err != nil {
|
||||
@@ -115,7 +114,7 @@ func runStatus(ctx context.Context, args []string) error {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
st, err := tailscale.Status(ctx)
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
@@ -129,12 +128,8 @@ func runStatus(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
description, ok := isRunningOrStarting(st)
|
||||
if !ok {
|
||||
outln(description)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// print health check information prior to checking LocalBackend state as
|
||||
// it may provide an explanation to the user if we choose to exit early
|
||||
if len(st.Health) > 0 {
|
||||
printf("# Health check:\n")
|
||||
for _, m := range st.Health {
|
||||
@@ -143,6 +138,12 @@ func runStatus(ctx context.Context, args []string) error {
|
||||
outln()
|
||||
}
|
||||
|
||||
description, ok := isRunningOrStarting(st)
|
||||
if !ok {
|
||||
outln(description)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
f := func(format string, a ...any) { fmt.Fprintf(&buf, format, a...) }
|
||||
printPS := func(ps *ipnstate.PeerStatus) {
|
||||
|
||||
@@ -19,12 +19,12 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
shellquote "github.com/kballard/go-shellquote"
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/tsaddr"
|
||||
@@ -52,7 +52,7 @@ down").
|
||||
If flags are specified, the flags must be the complete set of desired
|
||||
settings. An error is returned if any setting would be changed as a
|
||||
result of an unspecified flag's default value, unless the --reset flag
|
||||
is also used. (The flags --authkey, --force-reauth, and --qr are not
|
||||
is also used. (The flags --auth-key, --force-reauth, and --qr are not
|
||||
considered settings that need to be re-specified when modifying
|
||||
settings.)
|
||||
`),
|
||||
@@ -115,6 +115,8 @@ func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet {
|
||||
case "windows":
|
||||
upf.BoolVar(&upArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)")
|
||||
}
|
||||
upf.DurationVar(&upArgs.timeout, "timeout", 0, "maximum amount of time to wait for tailscaled to enter a Running state; default (0s) blocks forever")
|
||||
registerAcceptRiskFlag(upf)
|
||||
return upf
|
||||
}
|
||||
|
||||
@@ -147,6 +149,7 @@ type upArgsT struct {
|
||||
hostname string
|
||||
opUser string
|
||||
json bool
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (a upArgsT) getAuthKey() (string, error) {
|
||||
@@ -359,7 +362,7 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo
|
||||
// without changing any settings.
|
||||
func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, justEditMP *ipn.MaskedPrefs, err error) {
|
||||
if !env.upArgs.reset {
|
||||
applyImplicitPrefs(prefs, curPrefs, env.user)
|
||||
applyImplicitPrefs(prefs, curPrefs, env)
|
||||
|
||||
if err := checkForAccidentalSettingReverts(prefs, curPrefs, env); err != nil {
|
||||
return false, nil, err
|
||||
@@ -401,12 +404,12 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
|
||||
return simpleUp, justEditMP, nil
|
||||
}
|
||||
|
||||
func runUp(ctx context.Context, args []string) error {
|
||||
func runUp(ctx context.Context, args []string) (retErr error) {
|
||||
if len(args) > 0 {
|
||||
fatalf("too many non-flag arguments: %q", args)
|
||||
}
|
||||
|
||||
st, err := tailscale.Status(ctx)
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return fixTailscaledConnectError(err)
|
||||
}
|
||||
@@ -447,12 +450,12 @@ func runUp(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
if len(prefs.AdvertiseRoutes) > 0 {
|
||||
if err := tailscale.CheckIPForwarding(context.Background()); err != nil {
|
||||
if err := localClient.CheckIPForwarding(context.Background()); err != nil {
|
||||
warnf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
curPrefs, err := tailscale.GetPrefs(ctx)
|
||||
curPrefs, err := localClient.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -466,12 +469,30 @@ func runUp(ctx context.Context, args []string) error {
|
||||
backendState: st.BackendState,
|
||||
curExitNodeIP: exitNodeIP(curPrefs, st),
|
||||
}
|
||||
|
||||
if upArgs.runSSH != curPrefs.RunSSH && isSSHOverTailscale() {
|
||||
if upArgs.runSSH {
|
||||
err = presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will reroute SSH traffic to Tailscale SSH and will result in your session disconnecting.`)
|
||||
} else {
|
||||
err = presentRiskToUser(riskLoseSSH, `You are connected using Tailscale SSH; this action will result in your session disconnecting.`)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if retErr == nil {
|
||||
checkSSHUpWarnings(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
simpleUp, justEditMP, err := updatePrefs(prefs, curPrefs, env)
|
||||
if err != nil {
|
||||
fatalf("%s", err)
|
||||
}
|
||||
if justEditMP != nil {
|
||||
_, err := tailscale.EditPrefs(ctx, justEditMP)
|
||||
_, err := localClient.EditPrefs(ctx, justEditMP)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -582,7 +603,7 @@ func runUp(ctx context.Context, args []string) error {
|
||||
// Special case: bare "tailscale up" means to just start
|
||||
// running, if there's ever been a login.
|
||||
if simpleUp {
|
||||
_, err := tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{
|
||||
WantRunning: true,
|
||||
},
|
||||
@@ -592,7 +613,7 @@ func runUp(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tailscale.CheckPrefs(ctx, prefs); err != nil {
|
||||
if err := localClient.CheckPrefs(ctx, prefs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -633,6 +654,12 @@ func runUp(ctx context.Context, args []string) error {
|
||||
// need to prioritize reads from 'running' if it's
|
||||
// readable; its send does happen before the pump mechanism
|
||||
// shuts down. (Issue 2333)
|
||||
var timeoutCh <-chan time.Time
|
||||
if upArgs.timeout > 0 {
|
||||
timeoutTimer := time.NewTimer(upArgs.timeout)
|
||||
defer timeoutTimer.Stop()
|
||||
timeoutCh = timeoutTimer.C
|
||||
}
|
||||
select {
|
||||
case <-running:
|
||||
return nil
|
||||
@@ -650,6 +677,30 @@ func runUp(ctx context.Context, args []string) error {
|
||||
default:
|
||||
}
|
||||
return err
|
||||
case <-timeoutCh:
|
||||
return errors.New(`timeout waiting for Tailscale service to enter a Running state; check health with "tailscale status"`)
|
||||
}
|
||||
}
|
||||
|
||||
func checkSSHUpWarnings(ctx context.Context) {
|
||||
if !upArgs.runSSH {
|
||||
return
|
||||
}
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
// Ignore. Don't spam more.
|
||||
return
|
||||
}
|
||||
if len(st.Health) == 0 {
|
||||
return
|
||||
}
|
||||
if len(st.Health) == 1 && strings.Contains(st.Health[0], "SSH") {
|
||||
printf("%s\n", st.Health[0])
|
||||
return
|
||||
}
|
||||
printf("# Health check:\n")
|
||||
for _, m := range st.Health {
|
||||
printf(" - %s\n", m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,7 +757,7 @@ func addPrefFlagMapping(flagName string, prefNames ...string) {
|
||||
// correspond to an ipn.Pref.
|
||||
func preflessFlag(flagName string) bool {
|
||||
switch flagName {
|
||||
case "auth-key", "force-reauth", "reset", "qr", "json":
|
||||
case "auth-key", "force-reauth", "reset", "qr", "json", "timeout", "accept-risk":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -830,13 +881,20 @@ func checkForAccidentalSettingReverts(newPrefs, curPrefs *ipn.Prefs, env upCheck
|
||||
return errors.New(sb.String())
|
||||
}
|
||||
|
||||
// applyImplicitPrefs mutates prefs to add implicit preferences. Currently
|
||||
// this is just the operator user, which only needs to be set if it doesn't
|
||||
// applyImplicitPrefs mutates prefs to add implicit preferences for the user operator.
|
||||
// If the operator flag is passed no action is taken, otherwise this only needs to be set if it doesn't
|
||||
// match the current user.
|
||||
//
|
||||
// curUser is os.Getenv("USER"). It's pulled out for testability.
|
||||
func applyImplicitPrefs(prefs, oldPrefs *ipn.Prefs, curUser string) {
|
||||
if prefs.OperatorUser == "" && oldPrefs.OperatorUser == curUser {
|
||||
func applyImplicitPrefs(prefs, oldPrefs *ipn.Prefs, env upCheckEnv) {
|
||||
explicitOperator := false
|
||||
env.flagSet.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "operator" {
|
||||
explicitOperator = true
|
||||
}
|
||||
})
|
||||
|
||||
if prefs.OperatorUser == "" && oldPrefs.OperatorUser == env.user && !explicitOperator {
|
||||
prefs.OperatorUser = oldPrefs.OperatorUser
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
|
||||
@@ -41,7 +40,7 @@ func runVersion(ctx context.Context, args []string) error {
|
||||
|
||||
printf("Client: %s\n", version.String())
|
||||
|
||||
st, err := tailscale.StatusWithoutPeers(ctx)
|
||||
st, err := localClient.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/preftype"
|
||||
@@ -209,12 +208,20 @@ func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
|
||||
return "", nil, err
|
||||
}
|
||||
token, err := r.Cookie("qtoken")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
if err == nil {
|
||||
return qnapAuthnQtoken(r, user.Value, token.Value)
|
||||
}
|
||||
sid, err := r.Cookie("NAS_SID")
|
||||
if err == nil {
|
||||
return qnapAuthnSid(r, user.Value, sid.Value)
|
||||
}
|
||||
return "", nil, fmt.Errorf("not authenticated by any mechanism")
|
||||
}
|
||||
|
||||
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
|
||||
query := url.Values{
|
||||
"qtoken": []string{token.Value},
|
||||
"user": []string{user.Value},
|
||||
"qtoken": []string{token},
|
||||
"user": []string{user},
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: r.URL.Scheme,
|
||||
@@ -222,7 +229,26 @@ func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
|
||||
Path: "/cgi-bin/authLogin.cgi",
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
resp, err := http.Get(u.String())
|
||||
|
||||
return qnapAuthnFinish(user, u.String())
|
||||
}
|
||||
|
||||
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
|
||||
query := url.Values{
|
||||
"sid": []string{sid},
|
||||
}
|
||||
u := url.URL{
|
||||
Scheme: r.URL.Scheme,
|
||||
Host: r.URL.Host,
|
||||
Path: "/cgi-bin/authLogin.cgi",
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
|
||||
return qnapAuthnFinish(user, u.String())
|
||||
}
|
||||
|
||||
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -238,7 +264,7 @@ func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
|
||||
if authResp.AuthPassed == 0 {
|
||||
return "", nil, fmt.Errorf("not authenticated")
|
||||
}
|
||||
return user.Value, authResp, nil
|
||||
return user, authResp, nil
|
||||
}
|
||||
|
||||
func synoAuthn() (string, error) {
|
||||
@@ -318,7 +344,7 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
prefs, err := tailscale.GetPrefs(r.Context())
|
||||
prefs, err := localClient.GetPrefs(r.Context())
|
||||
if err != nil && !postData.Reauthenticate {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
||||
@@ -348,12 +374,12 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
st, err := tailscale.Status(r.Context())
|
||||
st, err := localClient.Status(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
prefs, err := tailscale.GetPrefs(r.Context())
|
||||
prefs, err := localClient.GetPrefs(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -406,7 +432,7 @@ func tailscaleUp(ctx context.Context, prefs *ipn.Prefs, forceReauth bool) (authU
|
||||
prefs.NetfilterMode = preftype.NetfilterOff
|
||||
}
|
||||
|
||||
st, err := tailscale.Status(ctx)
|
||||
st, err := localClient.Status(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("can't fetch status: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/depaware)
|
||||
|
||||
github.com/alessio/shellescape from tailscale.com/cmd/tailscale/cli
|
||||
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/negotiate+
|
||||
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
|
||||
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
|
||||
@@ -9,7 +8,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces
|
||||
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
|
||||
github.com/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli
|
||||
L github.com/klauspost/compress/flate from nhooyr.io/websocket
|
||||
github.com/klauspost/compress/flate from nhooyr.io/websocket
|
||||
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
|
||||
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
|
||||
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
|
||||
@@ -32,39 +31,41 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
go4.org/unsafe/assume-no-moving-gc from go4.org/intern
|
||||
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
|
||||
inet.af/netaddr from tailscale.com/cmd/tailscale/cli+
|
||||
L nhooyr.io/websocket from tailscale.com/derp/derphttp+
|
||||
L nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
L nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
nhooyr.io/websocket from tailscale.com/derp/derphttp+
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
tailscale.com from tailscale.com/version
|
||||
tailscale.com/atomicfile from tailscale.com/ipn+
|
||||
tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/client/tailscale/apitype from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
|
||||
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
|
||||
tailscale.com/derp/derphttp from tailscale.com/net/netcheck
|
||||
L tailscale.com/derp/wsconn from tailscale.com/derp/derphttp
|
||||
tailscale.com/disco from tailscale.com/derp
|
||||
tailscale.com/envknob from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/hostinfo from tailscale.com/net/interfaces+
|
||||
tailscale.com/ipn from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/cmd/tailscale/cli+
|
||||
💣 tailscale.com/metrics from tailscale.com/derp
|
||||
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/dnscache from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/dnsfallback from tailscale.com/control/controlhttp
|
||||
tailscale.com/net/flowtrack from tailscale.com/wgengine/filter+
|
||||
💣 tailscale.com/net/interfaces from tailscale.com/cmd/tailscale/cli+
|
||||
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/netns from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/netutil from tailscale.com/client/tailscale
|
||||
tailscale.com/net/netutil from tailscale.com/client/tailscale+
|
||||
tailscale.com/net/packet from tailscale.com/wgengine/filter
|
||||
tailscale.com/net/portmapper from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/stun from tailscale.com/net/netcheck
|
||||
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp
|
||||
tailscale.com/net/tlsdial from tailscale.com/derp/derphttp+
|
||||
tailscale.com/net/tsaddr from tailscale.com/net/interfaces+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
|
||||
💣 tailscale.com/paths from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/paths from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/safesocket from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/syncs from tailscale.com/net/interfaces+
|
||||
tailscale.com/tailcfg from tailscale.com/cmd/tailscale/cli+
|
||||
@@ -84,23 +85,25 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
tailscale.com/types/structs from tailscale.com/ipn+
|
||||
tailscale.com/types/views from tailscale.com/tailcfg+
|
||||
tailscale.com/util/clientmetric from tailscale.com/net/netcheck+
|
||||
tailscale.com/util/cloudenv from tailscale.com/net/dnscache+
|
||||
W tailscale.com/util/cmpver from tailscale.com/net/tshttpproxy
|
||||
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
|
||||
W tailscale.com/util/endian from tailscale.com/net/netns
|
||||
tailscale.com/util/groupmember from tailscale.com/cmd/tailscale/cli
|
||||
tailscale.com/util/lineread from tailscale.com/net/interfaces+
|
||||
W tailscale.com/util/winutil from tailscale.com/hostinfo
|
||||
W 💣 tailscale.com/util/winutil/vss from tailscale.com/util/winutil
|
||||
tailscale.com/util/singleflight from tailscale.com/net/dnscache
|
||||
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
|
||||
tailscale.com/version from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/version/distro from tailscale.com/cmd/tailscale/cli+
|
||||
tailscale.com/wgengine/filter from tailscale.com/types/netmap
|
||||
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/blake2s from tailscale.com/control/controlbase
|
||||
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305
|
||||
golang.org/x/crypto/chacha20poly1305 from crypto/tls
|
||||
golang.org/x/crypto/chacha20poly1305 from crypto/tls+
|
||||
golang.org/x/crypto/cryptobyte from crypto/ecdsa+
|
||||
golang.org/x/crypto/cryptobyte/asn1 from crypto/ecdsa+
|
||||
golang.org/x/crypto/curve25519 from crypto/tls+
|
||||
golang.org/x/crypto/hkdf from crypto/tls
|
||||
golang.org/x/crypto/hkdf from crypto/tls+
|
||||
golang.org/x/crypto/nacl/box from tailscale.com/types/key
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
@@ -113,7 +116,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
golang.org/x/net/proxy from tailscale.com/net/netns
|
||||
D golang.org/x/net/route from net+
|
||||
golang.org/x/sync/errgroup from tailscale.com/derp+
|
||||
golang.org/x/sync/singleflight from tailscale.com/net/dnscache
|
||||
golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+
|
||||
LD golang.org/x/sys/unix from tailscale.com/net/netns+
|
||||
W golang.org/x/sys/windows from golang.org/x/sys/windows/registry+
|
||||
@@ -200,7 +202,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||
reflect from crypto/x509+
|
||||
regexp from github.com/tailscale/goupnp/httpu+
|
||||
regexp/syntax from regexp
|
||||
runtime/debug from golang.org/x/sync/singleflight+
|
||||
runtime/debug from tailscale.com/util/singleflight+
|
||||
sort from compress/flate+
|
||||
strconv from compress/flate+
|
||||
strings from bufio+
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
args = cli.CleanUpArgs(args)
|
||||
if name, _ := os.Executable(); strings.HasSuffix(filepath.Base(name), ".cgi") {
|
||||
args = []string{"web", "-cgi"}
|
||||
}
|
||||
|
||||
@@ -76,12 +76,15 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces+
|
||||
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
|
||||
github.com/klauspost/compress from github.com/klauspost/compress/zstd
|
||||
L github.com/klauspost/compress/flate from nhooyr.io/websocket
|
||||
github.com/klauspost/compress/flate from nhooyr.io/websocket
|
||||
github.com/klauspost/compress/fse from github.com/klauspost/compress/huff0
|
||||
github.com/klauspost/compress/huff0 from github.com/klauspost/compress/zstd
|
||||
github.com/klauspost/compress/internal/cpuinfo from github.com/klauspost/compress/zstd
|
||||
github.com/klauspost/compress/internal/snapref from github.com/klauspost/compress/zstd
|
||||
github.com/klauspost/compress/zstd from tailscale.com/smallzstd
|
||||
github.com/klauspost/compress/zstd/internal/xxhash from github.com/klauspost/compress/zstd
|
||||
github.com/kortschak/wol from tailscale.com/ipn/ipnlocal
|
||||
LD github.com/kr/fs from github.com/pkg/sftp
|
||||
L github.com/mdlayher/genetlink from tailscale.com/net/tstun
|
||||
L 💣 github.com/mdlayher/netlink from github.com/jsimonetti/rtnetlink+
|
||||
L 💣 github.com/mdlayher/netlink/nlenc from github.com/jsimonetti/rtnetlink+
|
||||
@@ -89,6 +92,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
|
||||
💣 github.com/mitchellh/go-ps from tailscale.com/safesocket
|
||||
W github.com/pkg/errors from github.com/tailscale/certstore
|
||||
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
|
||||
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
|
||||
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
|
||||
LD github.com/tailscale/golang-x-crypto/chacha20 from github.com/tailscale/golang-x-crypto/ssh
|
||||
LD 💣 github.com/tailscale/golang-x-crypto/internal/subtle from github.com/tailscale/golang-x-crypto/chacha20
|
||||
@@ -109,7 +114,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
|
||||
L github.com/vishvananda/netns from github.com/tailscale/netlink+
|
||||
💣 go4.org/intern from inet.af/netaddr
|
||||
💣 go4.org/mem from tailscale.com/client/tailscale+
|
||||
💣 go4.org/mem from tailscale.com/control/controlbase+
|
||||
go4.org/unsafe/assume-no-moving-gc from go4.org/intern
|
||||
W 💣 golang.zx2c4.com/wintun from golang.zx2c4.com/wireguard/tun
|
||||
💣 golang.zx2c4.com/wireguard/conn from golang.zx2c4.com/wireguard/device+
|
||||
@@ -124,7 +129,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
💣 golang.zx2c4.com/wireguard/tun from golang.zx2c4.com/wireguard/device+
|
||||
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/cmd/tailscaled+
|
||||
gvisor.dev/gvisor/pkg/atomicbitops from gvisor.dev/gvisor/pkg/tcpip+
|
||||
💣 gvisor.dev/gvisor/pkg/buffer from gvisor.dev/gvisor/pkg/tcpip/stack
|
||||
gvisor.dev/gvisor/pkg/bits from gvisor.dev/gvisor/pkg/bufferv2
|
||||
💣 gvisor.dev/gvisor/pkg/bufferv2 from gvisor.dev/gvisor/pkg/tcpip+
|
||||
gvisor.dev/gvisor/pkg/context from gvisor.dev/gvisor/pkg/refs+
|
||||
💣 gvisor.dev/gvisor/pkg/gohacks from gvisor.dev/gvisor/pkg/state/wire+
|
||||
gvisor.dev/gvisor/pkg/linewriter from gvisor.dev/gvisor/pkg/log
|
||||
@@ -136,9 +142,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
💣 gvisor.dev/gvisor/pkg/state from gvisor.dev/gvisor/pkg/atomicbitops+
|
||||
gvisor.dev/gvisor/pkg/state/wire from gvisor.dev/gvisor/pkg/state
|
||||
💣 gvisor.dev/gvisor/pkg/sync from gvisor.dev/gvisor/pkg/linewriter+
|
||||
gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
|
||||
gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/header+
|
||||
gvisor.dev/gvisor/pkg/tcpip/adapters/gonet from tailscale.com/wgengine/netstack
|
||||
💣 gvisor.dev/gvisor/pkg/tcpip/buffer from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
|
||||
gvisor.dev/gvisor/pkg/tcpip/hash/jenkins from gvisor.dev/gvisor/pkg/tcpip/stack+
|
||||
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+
|
||||
@@ -147,48 +152,48 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
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+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/internal/multicast from gvisor.dev/gvisor/pkg/tcpip/network/ipv4+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv4 from tailscale.com/net/tstun+
|
||||
gvisor.dev/gvisor/pkg/tcpip/network/ipv6 from tailscale.com/wgengine/netstack
|
||||
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/transport from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
|
||||
💣 gvisor.dev/gvisor/pkg/tcpip/stack from gvisor.dev/gvisor/pkg/tcpip/header/parse+
|
||||
gvisor.dev/gvisor/pkg/tcpip/transport from gvisor.dev/gvisor/pkg/tcpip/transport/internal/network+
|
||||
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+
|
||||
gvisor.dev/gvisor/pkg/tcpip/transport/internal/network from gvisor.dev/gvisor/pkg/tcpip/transport/raw+
|
||||
gvisor.dev/gvisor/pkg/tcpip/transport/internal/noop from gvisor.dev/gvisor/pkg/tcpip/transport/raw
|
||||
gvisor.dev/gvisor/pkg/tcpip/transport/packet from gvisor.dev/gvisor/pkg/tcpip/transport/raw
|
||||
gvisor.dev/gvisor/pkg/tcpip/transport/raw from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
|
||||
gvisor.dev/gvisor/pkg/tcpip/transport/raw from gvisor.dev/gvisor/pkg/tcpip/transport/udp+
|
||||
💣 gvisor.dev/gvisor/pkg/tcpip/transport/tcp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
|
||||
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/tcpip/transport/udp from tailscale.com/net/tstun+
|
||||
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
|
||||
inet.af/netaddr from inet.af/wf+
|
||||
inet.af/netaddr from tailscale.com/control/controlclient+
|
||||
inet.af/peercred from tailscale.com/ipn/ipnserver
|
||||
W 💣 inet.af/wf from tailscale.com/wf
|
||||
L nhooyr.io/websocket from tailscale.com/derp/derphttp+
|
||||
L nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
L nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
nhooyr.io/websocket from tailscale.com/derp/derphttp+
|
||||
nhooyr.io/websocket/internal/errd from nhooyr.io/websocket
|
||||
nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket
|
||||
tailscale.com from tailscale.com/version
|
||||
tailscale.com/atomicfile from tailscale.com/ipn+
|
||||
LD tailscale.com/chirp from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/client/tailscale from tailscale.com/derp
|
||||
tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale+
|
||||
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+
|
||||
tailscale.com/control/controlbase from tailscale.com/control/controlclient+
|
||||
tailscale.com/control/controlclient from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/control/controlclient from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/control/controlhttp from tailscale.com/control/controlclient
|
||||
tailscale.com/control/controlknobs from tailscale.com/control/controlclient+
|
||||
tailscale.com/derp from tailscale.com/derp/derphttp+
|
||||
tailscale.com/derp/derphttp from tailscale.com/cmd/tailscaled+
|
||||
L tailscale.com/derp/wsconn from tailscale.com/derp/derphttp
|
||||
tailscale.com/derp/derphttp from tailscale.com/net/netcheck+
|
||||
tailscale.com/disco from tailscale.com/derp+
|
||||
tailscale.com/envknob from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/envknob from tailscale.com/control/controlclient+
|
||||
tailscale.com/health from tailscale.com/control/controlclient+
|
||||
tailscale.com/hostinfo from tailscale.com/control/controlclient+
|
||||
tailscale.com/ipn from tailscale.com/client/tailscale+
|
||||
tailscale.com/ipn/ipnlocal from tailscale.com/ipn/ipnserver+
|
||||
tailscale.com/ipn from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/ipn/ipnlocal from tailscale.com/ssh/tailssh+
|
||||
tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/control/controlclient+
|
||||
tailscale.com/ipn/localapi from tailscale.com/ipn/ipnserver
|
||||
tailscale.com/ipn/policy from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/ipn/store from tailscale.com/cmd/tailscaled
|
||||
@@ -199,41 +204,41 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/log/filelogger from tailscale.com/logpolicy
|
||||
tailscale.com/log/logheap from tailscale.com/control/controlclient
|
||||
tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/logtail from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/logtail/backoff from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/logtail from tailscale.com/control/controlclient+
|
||||
tailscale.com/logtail/backoff from tailscale.com/control/controlclient+
|
||||
tailscale.com/logtail/filch from tailscale.com/logpolicy
|
||||
💣 tailscale.com/metrics from tailscale.com/derp+
|
||||
tailscale.com/net/dns from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dns/publicdns from tailscale.com/net/dns/resolver
|
||||
tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+
|
||||
tailscale.com/net/dns/resolver from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/dnscache from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/dnsfallback from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/flowtrack from tailscale.com/net/packet+
|
||||
💣 tailscale.com/net/interfaces from tailscale.com/cmd/tailscaled+
|
||||
💣 tailscale.com/net/interfaces from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
|
||||
tailscale.com/net/netknob from tailscale.com/logpolicy+
|
||||
tailscale.com/net/netns from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/net/netknob from tailscale.com/net/netns+
|
||||
tailscale.com/net/netns from tailscale.com/derp/derphttp+
|
||||
💣 tailscale.com/net/netstat from tailscale.com/ipn/ipnserver
|
||||
tailscale.com/net/netutil from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/packet from tailscale.com/net/tstun+
|
||||
tailscale.com/net/portmapper from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/net/portmapper from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/net/stun from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/tsaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/tsdial from tailscale.com/cmd/tailscaled+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/net/tstun from tailscale.com/cmd/tailscaled+
|
||||
💣 tailscale.com/paths from tailscale.com/client/tailscale+
|
||||
tailscale.com/net/tsdial from tailscale.com/control/controlclient+
|
||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/tstun from tailscale.com/net/dns+
|
||||
tailscale.com/paths from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/portlist from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/safesocket from tailscale.com/client/tailscale+
|
||||
tailscale.com/smallzstd from tailscale.com/ipn/ipnserver+
|
||||
LD 💣 tailscale.com/ssh/tailssh from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/syncs from tailscale.com/control/controlknobs+
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale+
|
||||
tailscale.com/tailcfg from tailscale.com/client/tailscale/apitype+
|
||||
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
|
||||
W tailscale.com/tsconst from tailscale.com/net/interfaces
|
||||
tailscale.com/tstime from tailscale.com/wgengine/magicsock
|
||||
@@ -244,8 +249,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/types/empty from tailscale.com/control/controlclient+
|
||||
tailscale.com/types/flagtype from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+
|
||||
tailscale.com/types/key from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/types/logger from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/types/key from tailscale.com/control/controlbase+
|
||||
tailscale.com/types/logger from tailscale.com/control/controlclient+
|
||||
tailscale.com/types/netmap from tailscale.com/control/controlclient+
|
||||
tailscale.com/types/nettype from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/types/opt from tailscale.com/control/controlclient+
|
||||
@@ -254,31 +259,33 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/types/preftype from tailscale.com/ipn+
|
||||
tailscale.com/types/structs from tailscale.com/control/controlclient+
|
||||
tailscale.com/types/views from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/clientmetric from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/util/clientmetric from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/cloudenv from tailscale.com/net/dns/resolver+
|
||||
LW tailscale.com/util/cmpver from tailscale.com/net/dns+
|
||||
💣 tailscale.com/util/deephash from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/dnsname from tailscale.com/hostinfo+
|
||||
LW tailscale.com/util/endian from tailscale.com/net/dns+
|
||||
tailscale.com/util/groupmember from tailscale.com/ipn/ipnserver
|
||||
tailscale.com/util/lineread from tailscale.com/hostinfo+
|
||||
tailscale.com/util/multierr from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/util/mak from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/multierr from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/netconv from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/util/osshare from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/util/pidowner from tailscale.com/ipn/ipnserver
|
||||
tailscale.com/util/racebuild from tailscale.com/logpolicy
|
||||
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/systemd from tailscale.com/control/controlclient+
|
||||
tailscale.com/util/uniq from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/util/winutil from tailscale.com/cmd/tailscaled+
|
||||
W 💣 tailscale.com/util/winutil/vss from tailscale.com/util/winutil
|
||||
tailscale.com/version from tailscale.com/client/tailscale+
|
||||
tailscale.com/version/distro from tailscale.com/cmd/tailscaled+
|
||||
💣 tailscale.com/util/winutil from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/version from tailscale.com/derp+
|
||||
tailscale.com/version/distro from tailscale.com/hostinfo+
|
||||
W tailscale.com/wf from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/wgengine from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/wgengine from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/filter from tailscale.com/control/controlclient+
|
||||
tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/monitor from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/wgengine/router from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/wgengine/monitor from tailscale.com/control/controlclient+
|
||||
tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/wgengine/router from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/wgcfg from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/wgengine/wglog from tailscale.com/wgengine
|
||||
@@ -298,7 +305,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
golang.org/x/crypto/nacl/secretbox from golang.org/x/crypto/nacl/box
|
||||
golang.org/x/crypto/poly1305 from golang.zx2c4.com/wireguard/device+
|
||||
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
|
||||
LD golang.org/x/crypto/ssh from tailscale.com/ssh/tailssh
|
||||
LD golang.org/x/crypto/ssh from tailscale.com/ssh/tailssh+
|
||||
golang.org/x/net/bpf from github.com/mdlayher/genetlink+
|
||||
golang.org/x/net/dns/dnsmessage from net+
|
||||
golang.org/x/net/http/httpguts from golang.org/x/net/http2+
|
||||
@@ -312,12 +319,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
golang.org/x/net/proxy from tailscale.com/net/netns
|
||||
D golang.org/x/net/route from net+
|
||||
golang.org/x/sync/errgroup from github.com/mdlayher/socket+
|
||||
golang.org/x/sync/singleflight from tailscale.com/control/controlclient+
|
||||
golang.org/x/sys/cpu from golang.org/x/crypto/blake2b+
|
||||
LD golang.org/x/sys/unix from github.com/insomniacslk/dhcp/interfaces+
|
||||
W golang.org/x/sys/windows from github.com/go-ole/go-ole+
|
||||
W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+
|
||||
W golang.org/x/sys/windows/registry from golang.org/x/sys/windows/svc/eventlog+
|
||||
W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+
|
||||
W golang.org/x/sys/windows/svc/eventlog from tailscale.com/cmd/tailscaled
|
||||
W golang.org/x/sys/windows/svc/mgr from tailscale.com/cmd/tailscaled
|
||||
golang.org/x/term from tailscale.com/logpolicy
|
||||
golang.org/x/text/secure/bidirule from golang.org/x/net/idna
|
||||
@@ -349,10 +356,10 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
crypto/sha256 from crypto/tls+
|
||||
crypto/sha512 from crypto/ecdsa+
|
||||
crypto/subtle from crypto/aes+
|
||||
crypto/tls from github.com/aws/aws-sdk-go-v2/aws/transport/http+
|
||||
crypto/tls from github.com/tcnksm/go-httpstat+
|
||||
crypto/x509 from crypto/tls+
|
||||
crypto/x509/pkix from crypto/x509+
|
||||
embed from crypto/elliptic+
|
||||
embed from tailscale.com+
|
||||
encoding from encoding/json+
|
||||
encoding/asn1 from crypto/x509+
|
||||
encoding/base64 from encoding/json+
|
||||
@@ -360,19 +367,19 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
encoding/hex from crypto/x509+
|
||||
encoding/json from expvar+
|
||||
encoding/pem from crypto/tls+
|
||||
encoding/xml from github.com/aws/aws-sdk-go-v2/aws/protocol/xml+
|
||||
encoding/xml from github.com/tailscale/goupnp+
|
||||
errors from bufio+
|
||||
expvar from tailscale.com/derp+
|
||||
flag from tailscale.com/cmd/tailscaled+
|
||||
flag from tailscale.com/control/controlclient+
|
||||
fmt from compress/flate+
|
||||
hash from crypto+
|
||||
hash/crc32 from compress/gzip+
|
||||
hash/fnv from gvisor.dev/gvisor/pkg/tcpip/network/ipv6+
|
||||
hash/fnv from tailscale.com/wgengine/magicsock+
|
||||
hash/maphash from go4.org/mem
|
||||
html from net/http/pprof+
|
||||
html from tailscale.com/ipn/ipnlocal+
|
||||
io from bufio+
|
||||
io/fs from crypto/rand+
|
||||
io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
|
||||
io/ioutil from github.com/godbus/dbus/v5+
|
||||
log from expvar+
|
||||
LD log/syslog from tailscale.com/ssh/tailssh
|
||||
math from compress/flate+
|
||||
@@ -389,19 +396,19 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
net/http/internal from net/http+
|
||||
net/http/pprof from tailscale.com/cmd/tailscaled+
|
||||
net/netip from golang.zx2c4.com/wireguard/conn+
|
||||
net/textproto from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
|
||||
net/textproto from golang.org/x/net/http/httpguts+
|
||||
net/url from crypto/x509+
|
||||
os from crypto/rand+
|
||||
os/exec from github.com/aws/aws-sdk-go-v2/credentials/processcreds+
|
||||
os/exec from github.com/coreos/go-iptables/iptables+
|
||||
os/signal from tailscale.com/cmd/tailscaled+
|
||||
os/user from github.com/godbus/dbus/v5+
|
||||
path from github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds+
|
||||
path from github.com/godbus/dbus/v5+
|
||||
path/filepath from crypto/x509+
|
||||
reflect from crypto/x509+
|
||||
regexp from github.com/aws/aws-sdk-go-v2/internal/endpoints/v2+
|
||||
regexp from github.com/coreos/go-iptables/iptables+
|
||||
regexp/syntax from regexp
|
||||
runtime/debug from github.com/klauspost/compress/zstd+
|
||||
runtime/pprof from net/http/pprof+
|
||||
runtime/pprof from tailscale.com/log/logheap+
|
||||
runtime/trace from net/http/pprof
|
||||
sort from compress/flate+
|
||||
strconv from compress/flate+
|
||||
|
||||
@@ -9,4 +9,3 @@ package main
|
||||
|
||||
// Force registration of tailssh with LocalBackend.
|
||||
import _ "tailscale.com/ssh/tailssh"
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ func main() {
|
||||
flag.StringVar(&args.httpProxyAddr, "outbound-http-proxy-listen", "", `optional [ip]:port to run an outbound HTTP proxy (e.g. "localhost:8080")`)
|
||||
flag.StringVar(&args.tunname, "tun", defaultTunName(), `tunnel interface name; use "userspace-networking" (beta) to not use TUN`)
|
||||
flag.Var(flagtype.PortValue(&args.port, 0), "port", "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select")
|
||||
flag.StringVar(&args.statepath, "state", paths.DefaultTailscaledStateFile(), "absolute path of state file; use 'kube:<secret-name>' to use Kubernetes secrets or 'arn:aws:ssm:...' to store in AWS SSM; use 'mem:' to not store state and register as an emphemeral node. If empty and --statedir is provided, the default is <statedir>/tailscaled.state")
|
||||
flag.StringVar(&args.statepath, "state", "", "absolute path of state file; use 'kube:<secret-name>' to use Kubernetes secrets or 'arn:aws:ssm:...' to store in AWS SSM; use 'mem:' to not store state and register as an emphemeral node. If empty and --statedir is provided, the default is <statedir>/tailscaled.state. Default: "+paths.DefaultTailscaledStateFile())
|
||||
flag.StringVar(&args.statedir, "statedir", "", "path to directory for storage of config state, TLS certs, temporary incoming Taildrop files, etc. If empty, it's derived from --state when possible.")
|
||||
flag.StringVar(&args.socketpath, "socket", paths.DefaultTailscaledSocket(), "path of the service unix socket")
|
||||
flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket")
|
||||
@@ -158,13 +158,12 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
if beWindowsSubprocess() {
|
||||
return
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
if flag.NArg() > 0 {
|
||||
log.Fatalf("tailscaled does not take non-flag arguments: %q", flag.Args())
|
||||
// Windows subprocess is spawned with /subprocess, so we need to avoid this check there.
|
||||
if runtime.GOOS != "windows" || (flag.Arg(0) != "/subproc" && flag.Arg(0) != "/firewall") {
|
||||
log.Fatalf("tailscaled does not take non-flag arguments: %q", flag.Args())
|
||||
}
|
||||
}
|
||||
|
||||
if printVersion {
|
||||
@@ -187,6 +186,16 @@ func main() {
|
||||
log.Fatalf("--bird-socket is not supported on %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// Only apply a default statepath when neither have been provided, so that a
|
||||
// user may specify only --statedir if they wish.
|
||||
if args.statepath == "" && args.statedir == "" {
|
||||
args.statepath = paths.DefaultTailscaledStateFile()
|
||||
}
|
||||
|
||||
if beWindowsSubprocess() {
|
||||
return
|
||||
}
|
||||
|
||||
err := run()
|
||||
|
||||
// Remove file sharing from Windows shell (noop in non-windows)
|
||||
@@ -332,6 +341,7 @@ func run() error {
|
||||
socksListener, httpProxyListener := mustStartProxyListeners(args.socksAddr, args.httpProxyAddr)
|
||||
|
||||
dialer := new(tsdial.Dialer) // mutated below (before used)
|
||||
dialer.Logf = logf
|
||||
e, useNetstack, err := createEngine(logf, linkMon, dialer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("createEngine: %w", err)
|
||||
@@ -341,7 +351,7 @@ func run() error {
|
||||
}
|
||||
if debugMux != nil {
|
||||
if ig, ok := e.(wgengine.InternalsGetter); ok {
|
||||
if _, mc, ok := ig.GetInternals(); ok {
|
||||
if _, mc, _, ok := ig.GetInternals(); ok {
|
||||
debugMux.HandleFunc("/debug/magicsock", mc.ServeHTTPDebug)
|
||||
}
|
||||
}
|
||||
@@ -426,6 +436,7 @@ func run() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("safesocket.Listen: %v", err)
|
||||
}
|
||||
defer dialer.Close()
|
||||
|
||||
err = srv.Run(ctx, ln)
|
||||
// Cancelation is not an error: it is the only way to stop ipnserver.
|
||||
@@ -504,7 +515,7 @@ func tryEngine(logf logger.Logf, linkMon *monitor.Mon, dialer *tsdial.Dialer, na
|
||||
} else {
|
||||
dev, devName, err := tstun.New(logf, name)
|
||||
if err != nil {
|
||||
tstun.Diagnose(logf, name)
|
||||
tstun.Diagnose(logf, name, err)
|
||||
return nil, false, fmt.Errorf("tstun.New(%q): %w", name, err)
|
||||
}
|
||||
conf.Tun = dev
|
||||
@@ -564,11 +575,11 @@ func runDebugServer(mux *http.ServeMux, addr string) {
|
||||
}
|
||||
|
||||
func newNetstack(logf logger.Logf, dialer *tsdial.Dialer, e wgengine.Engine) (*netstack.Impl, error) {
|
||||
tunDev, magicConn, ok := e.(wgengine.InternalsGetter).GetInternals()
|
||||
tunDev, magicConn, dns, ok := e.(wgengine.InternalsGetter).GetInternals()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%T is not a wgengine.InternalsGetter", e)
|
||||
}
|
||||
return netstack.Create(logf, tunDev, e, magicConn, dialer)
|
||||
return netstack.Create(logf, tunDev, e, magicConn, dialer, dns)
|
||||
}
|
||||
|
||||
// mustStartProxyListeners creates listeners for local SOCKS and HTTP
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/svc"
|
||||
"golang.org/x/sys/windows/svc/eventlog"
|
||||
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/envknob"
|
||||
@@ -60,12 +61,31 @@ func isWindowsService() bool {
|
||||
return v
|
||||
}
|
||||
|
||||
// syslogf is a logger function that writes to the Windows event log (ie, the
|
||||
// one that you see in the Windows Event Viewer). tailscaled may optionally
|
||||
// generate diagnostic messages in the same event timeline as the Windows
|
||||
// Service Control Manager to assist with diagnosing issues with tailscaled's
|
||||
// lifetime (such as slow shutdowns).
|
||||
var syslogf logger.Logf = logger.Discard
|
||||
|
||||
// runWindowsService starts running Tailscale under the Windows
|
||||
// Service environment.
|
||||
//
|
||||
// At this point we're still the parent process that
|
||||
// Windows started.
|
||||
func runWindowsService(pol *logpolicy.Policy) error {
|
||||
if winutil.GetPolicyInteger("LogSCMInteractions", 0) != 0 {
|
||||
syslog, err := eventlog.Open(serviceName)
|
||||
if err == nil {
|
||||
syslogf = func(format string, args ...any) {
|
||||
syslog.Info(0, fmt.Sprintf(format, args...))
|
||||
}
|
||||
defer syslog.Close()
|
||||
}
|
||||
}
|
||||
|
||||
syslogf("Service entering svc.Run")
|
||||
defer syslogf("Service exiting svc.Run")
|
||||
return svc.Run(serviceName, &ipnService{Policy: pol})
|
||||
}
|
||||
|
||||
@@ -75,7 +95,10 @@ type ipnService struct {
|
||||
|
||||
// Called by Windows to execute the windows service.
|
||||
func (service *ipnService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
|
||||
defer syslogf("SvcStopped notification imminent")
|
||||
|
||||
changes <- svc.Status{State: svc.StartPending}
|
||||
syslogf("Service start pending")
|
||||
|
||||
svcAccepts := svc.AcceptStop
|
||||
if winutil.GetPolicyInteger("FlushDNSOnSessionUnlock", 0) != 0 {
|
||||
@@ -98,26 +121,29 @@ func (service *ipnService) Execute(args []string, r <-chan svc.ChangeRequest, ch
|
||||
}()
|
||||
|
||||
changes <- svc.Status{State: svc.Running, Accepts: svcAccepts}
|
||||
syslogf("Service running")
|
||||
|
||||
for ctx.Err() == nil {
|
||||
for {
|
||||
select {
|
||||
case <-doneCh:
|
||||
return false, windows.NO_ERROR
|
||||
case cmd := <-r:
|
||||
log.Printf("Got Windows Service event: %v", cmdName(cmd.Cmd))
|
||||
switch cmd.Cmd {
|
||||
case svc.Stop:
|
||||
cancel()
|
||||
changes <- svc.Status{State: svc.StopPending}
|
||||
syslogf("Service stop pending")
|
||||
cancel() // so BabysitProc will kill the child process
|
||||
case svc.Interrogate:
|
||||
syslogf("Service interrogation")
|
||||
changes <- cmd.CurrentStatus
|
||||
case svc.SessionChange:
|
||||
syslogf("Service session change notification")
|
||||
handleSessionChange(cmd)
|
||||
changes <- cmd.CurrentStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changes <- svc.Status{State: svc.StopPending}
|
||||
return false, windows.NO_ERROR
|
||||
}
|
||||
|
||||
func cmdName(c svc.Cmd) string {
|
||||
@@ -234,19 +260,19 @@ func startIPNServer(ctx context.Context, logid string) error {
|
||||
|
||||
linkMon, err := monitor.New(logf)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("monitor: %w", err)
|
||||
}
|
||||
dialer := new(tsdial.Dialer)
|
||||
|
||||
getEngineRaw := func() (wgengine.Engine, error) {
|
||||
getEngineRaw := func() (wgengine.Engine, *netstack.Impl, error) {
|
||||
dev, devName, err := tstun.New(logf, "Tailscale")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TUN: %w", err)
|
||||
return nil, nil, fmt.Errorf("TUN: %w", err)
|
||||
}
|
||||
r, err := router.New(logf, dev, nil)
|
||||
if err != nil {
|
||||
dev.Close()
|
||||
return nil, fmt.Errorf("router: %w", err)
|
||||
return nil, nil, fmt.Errorf("router: %w", err)
|
||||
}
|
||||
if wrapNetstack {
|
||||
r = netstack.NewSubnetRouterWrapper(r)
|
||||
@@ -255,7 +281,7 @@ func startIPNServer(ctx context.Context, logid string) error {
|
||||
if err != nil {
|
||||
r.Close()
|
||||
dev.Close()
|
||||
return nil, fmt.Errorf("DNS: %w", err)
|
||||
return nil, nil, fmt.Errorf("DNS: %w", err)
|
||||
}
|
||||
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
|
||||
Tun: dev,
|
||||
@@ -268,23 +294,24 @@ func startIPNServer(ctx context.Context, logid string) error {
|
||||
if err != nil {
|
||||
r.Close()
|
||||
dev.Close()
|
||||
return nil, fmt.Errorf("engine: %w", err)
|
||||
return nil, nil, fmt.Errorf("engine: %w", err)
|
||||
}
|
||||
ns, err := newNetstack(logf, dialer, eng)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("newNetstack: %w", err)
|
||||
return nil, nil, fmt.Errorf("newNetstack: %w", err)
|
||||
}
|
||||
ns.ProcessLocalIPs = false
|
||||
ns.ProcessSubnets = wrapNetstack
|
||||
if err := ns.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start netstack: %w", err)
|
||||
return nil, nil, fmt.Errorf("failed to start netstack: %w", err)
|
||||
}
|
||||
return wgengine.NewWatchdog(eng), nil
|
||||
return wgengine.NewWatchdog(eng), ns, nil
|
||||
}
|
||||
|
||||
type engineOrError struct {
|
||||
Engine wgengine.Engine
|
||||
Err error
|
||||
Engine wgengine.Engine
|
||||
Netstack *netstack.Impl
|
||||
Err error
|
||||
}
|
||||
engErrc := make(chan engineOrError)
|
||||
t0 := time.Now()
|
||||
@@ -293,7 +320,7 @@ func startIPNServer(ctx context.Context, logid string) error {
|
||||
for try := 1; ; try++ {
|
||||
logf("tailscaled: getting engine... (try %v)", try)
|
||||
t1 := time.Now()
|
||||
eng, err := getEngineRaw()
|
||||
eng, ns, err := getEngineRaw()
|
||||
d, dt := time.Since(t1).Round(ms), time.Since(t1).Round(ms)
|
||||
if err != nil {
|
||||
logf("tailscaled: engine fetch error (try %v) in %v (total %v, sysUptime %v): %v",
|
||||
@@ -306,7 +333,7 @@ func startIPNServer(ctx context.Context, logid string) error {
|
||||
}
|
||||
}
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
engErrc <- engineOrError{eng, err}
|
||||
engErrc <- engineOrError{eng, ns, err}
|
||||
if err == nil {
|
||||
timer.Stop()
|
||||
return
|
||||
@@ -318,14 +345,14 @@ func startIPNServer(ctx context.Context, logid string) error {
|
||||
// getEngine is called by ipnserver to get the engine. It's
|
||||
// not called concurrently and is not called again once it
|
||||
// successfully returns an engine.
|
||||
getEngine := func() (wgengine.Engine, error) {
|
||||
getEngine := func() (wgengine.Engine, *netstack.Impl, error) {
|
||||
if msg := envknob.String("TS_DEBUG_WIN_FAIL"); msg != "" {
|
||||
return nil, fmt.Errorf("pretending to be a service failure: %v", msg)
|
||||
return nil, nil, fmt.Errorf("pretending to be a service failure: %v", msg)
|
||||
}
|
||||
for {
|
||||
res := <-engErrc
|
||||
if res.Engine != nil {
|
||||
return res.Engine, nil
|
||||
return res.Engine, res.Netstack, nil
|
||||
}
|
||||
if time.Since(t0) < time.Minute || windowsUptime() < 10*time.Minute {
|
||||
// Ignore errors during early boot. Windows 10 auto logs in the GUI
|
||||
@@ -336,12 +363,12 @@ func startIPNServer(ctx context.Context, logid string) error {
|
||||
}
|
||||
// Return nicer errors to users, annotated with logids, which helps
|
||||
// when they file bugs.
|
||||
return nil, fmt.Errorf("%w\n\nlogid: %v", res.Err, logid)
|
||||
return nil, nil, fmt.Errorf("%w\n\nlogid: %v", res.Err, logid)
|
||||
}
|
||||
}
|
||||
store, err := store.New(logf, statePathOrDefault())
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("store: %w", err)
|
||||
}
|
||||
|
||||
ln, _, err := safesocket.Listen(args.socketpath, safesocket.WindowsLocalPort)
|
||||
|
||||
4
cmd/tsconnect/.gitignore
vendored
Normal file
4
cmd/tsconnect/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
src/wasm_exec.js
|
||||
src/main.wasm
|
||||
node_modules/
|
||||
dist/
|
||||
190
cmd/tsconnect/build.go
Normal file
190
cmd/tsconnect/build.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func runBuild() {
|
||||
buildOptions, err := commonSetup(prodMode)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot setup: %v", err)
|
||||
}
|
||||
|
||||
if err := cleanDist(); err != nil {
|
||||
log.Fatalf("Cannot clean %s: %v", *distDir, err)
|
||||
}
|
||||
|
||||
buildOptions.Write = true
|
||||
buildOptions.MinifyWhitespace = true
|
||||
buildOptions.MinifyIdentifiers = true
|
||||
buildOptions.MinifySyntax = true
|
||||
|
||||
buildOptions.EntryNames = "[dir]/[name]-[hash]"
|
||||
buildOptions.AssetNames = "[name]-[hash]"
|
||||
buildOptions.Metafile = true
|
||||
|
||||
log.Printf("Running esbuild...\n")
|
||||
result := esbuild.Build(*buildOptions)
|
||||
if len(result.Errors) > 0 {
|
||||
log.Printf("ESBuild Error:\n")
|
||||
for _, e := range result.Errors {
|
||||
log.Printf("%v", e)
|
||||
}
|
||||
log.Fatal("Build failed")
|
||||
}
|
||||
if len(result.Warnings) > 0 {
|
||||
log.Printf("ESBuild Warnings:\n")
|
||||
for _, w := range result.Warnings {
|
||||
log.Printf("%v", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve build metadata so we can extract hashed file names for serving.
|
||||
metadataBytes, err := fixEsbuildMetadataPaths(result.Metafile)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot fix esbuild metadata paths: %v", err)
|
||||
}
|
||||
if err := ioutil.WriteFile(path.Join(*distDir, "/esbuild-metadata.json"), metadataBytes, 0666); err != nil {
|
||||
log.Fatalf("Cannot write metadata: %v", err)
|
||||
}
|
||||
|
||||
if er := precompressDist(); err != nil {
|
||||
log.Fatalf("Cannot precompress resources: %v", er)
|
||||
}
|
||||
}
|
||||
|
||||
// fixEsbuildMetadataPaths re-keys the esbuild metadata file to use paths
|
||||
// relative to the dist directory (it normally uses paths relative to the cwd,
|
||||
// which are akward if we're running with a different cwd at serving time).
|
||||
func fixEsbuildMetadataPaths(metadataStr string) ([]byte, error) {
|
||||
var metadata EsbuildMetadata
|
||||
if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil {
|
||||
return nil, fmt.Errorf("Cannot parse metadata: %w", err)
|
||||
}
|
||||
distAbsPath, err := filepath.Abs(*distDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot get absolute path from %s: %w", *distDir, err)
|
||||
}
|
||||
for outputPath, output := range metadata.Outputs {
|
||||
outputAbsPath, err := filepath.Abs(outputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot get absolute path from %s: %w", outputPath, err)
|
||||
}
|
||||
outputRelPath, err := filepath.Rel(distAbsPath, outputAbsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot get relative path from %s: %w", outputRelPath, err)
|
||||
}
|
||||
delete(metadata.Outputs, outputPath)
|
||||
metadata.Outputs[outputRelPath] = output
|
||||
}
|
||||
return json.Marshal(metadata)
|
||||
}
|
||||
|
||||
// cleanDist removes files from the dist build directory, except the placeholder
|
||||
// one that we keep to make sure Git still creates the directory.
|
||||
func cleanDist() error {
|
||||
log.Printf("Cleaning %s...\n", *distDir)
|
||||
files, err := os.ReadDir(*distDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return os.MkdirAll(*distDir, 0755)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.Name() != "placeholder" {
|
||||
if err := os.Remove(filepath.Join(*distDir, file.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func precompressDist() error {
|
||||
log.Printf("Pre-compressing files in %s/...\n", *distDir)
|
||||
var eg errgroup.Group
|
||||
err := fs.WalkDir(os.DirFS(*distDir), ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !compressibleExtensions[filepath.Ext(p)] {
|
||||
return nil
|
||||
}
|
||||
p = path.Join(*distDir, p)
|
||||
log.Printf("Pre-compressing %v\n", p)
|
||||
|
||||
eg.Go(func() error {
|
||||
return precompress(p)
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return eg.Wait()
|
||||
}
|
||||
|
||||
var compressibleExtensions = map[string]bool{
|
||||
".js": true,
|
||||
".css": true,
|
||||
".wasm": true,
|
||||
}
|
||||
|
||||
func precompress(path string) error {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = writeCompressed(contents, func(w io.Writer) (io.WriteCloser, error) {
|
||||
return gzip.NewWriterLevel(w, gzip.BestCompression)
|
||||
}, path+".gz", fi.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCompressed(contents, func(w io.Writer) (io.WriteCloser, error) {
|
||||
return brotli.NewWriterLevel(w, brotli.BestCompression), nil
|
||||
}, path+".br", fi.Mode())
|
||||
}
|
||||
|
||||
func writeCompressed(contents []byte, compressedWriterCreator func(io.Writer) (io.WriteCloser, error), outputPath string, outputMode fs.FileMode) error {
|
||||
var buf bytes.Buffer
|
||||
compressedWriter, err := compressedWriterCreator(&buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := compressedWriter.Write(contents); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := compressedWriter.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outputPath, buf.Bytes(), outputMode)
|
||||
}
|
||||
114
cmd/tsconnect/common.go
Normal file
114
cmd/tsconnect/common.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
const (
|
||||
devMode = true
|
||||
prodMode = false
|
||||
)
|
||||
|
||||
// commonSetup performs setup that is common to both dev and build modes.
|
||||
func commonSetup(dev bool) (*esbuild.BuildOptions, error) {
|
||||
// Change cwd to to where this file lives -- that's where all inputs for
|
||||
// esbuild and other build steps live.
|
||||
if _, filename, _, ok := runtime.Caller(0); ok {
|
||||
if err := os.Chdir(path.Dir(filename)); err != nil {
|
||||
return nil, fmt.Errorf("Cannot change cwd: %w", err)
|
||||
}
|
||||
}
|
||||
if err := buildDeps(dev); err != nil {
|
||||
return nil, fmt.Errorf("Cannot build deps: %w", err)
|
||||
}
|
||||
|
||||
return &esbuild.BuildOptions{
|
||||
EntryPoints: []string{"src/index.js", "src/index.css"},
|
||||
Loader: map[string]esbuild.Loader{".wasm": esbuild.LoaderFile},
|
||||
Outdir: *distDir,
|
||||
Bundle: true,
|
||||
Sourcemap: esbuild.SourceMapLinked,
|
||||
LogLevel: esbuild.LogLevelInfo,
|
||||
Define: map[string]string{"DEBUG": strconv.FormatBool(dev)},
|
||||
Target: esbuild.ES2017,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildDeps builds the static assets that are needed for the server (except for
|
||||
// JS/CSS bundling, which is handled by esbuild).
|
||||
func buildDeps(dev bool) error {
|
||||
if err := copyWasmExec(); err != nil {
|
||||
return fmt.Errorf("Cannot copy wasm_exec.js: %w", err)
|
||||
}
|
||||
if err := buildWasm(dev); err != nil {
|
||||
return fmt.Errorf("Cannot build main.wasm: %w", err)
|
||||
}
|
||||
if err := installJSDeps(); err != nil {
|
||||
return fmt.Errorf("Cannot install JS deps: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyWasmExec grabs the current wasm_exec.js runtime helper library from the
|
||||
// Go toolchain.
|
||||
func copyWasmExec() error {
|
||||
log.Printf("Copying wasm_exec.js...\n")
|
||||
wasmExecSrcPath := filepath.Join(runtime.GOROOT(), "misc", "wasm", "wasm_exec.js")
|
||||
wasmExecDstPath := filepath.Join("src", "wasm_exec.js")
|
||||
contents, err := os.ReadFile(wasmExecSrcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(wasmExecDstPath, contents, 0600)
|
||||
}
|
||||
|
||||
// buildWasm builds the Tailscale wasm binary and places it where the JS can
|
||||
// load it.
|
||||
func buildWasm(dev bool) error {
|
||||
log.Printf("Building wasm...\n")
|
||||
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,nethttpomithttp2,omitidna,omitpemdecrypt"}
|
||||
if !dev {
|
||||
// Omit long paths and debug symbols in release builds, to reduce the
|
||||
// generated WASM binary size.
|
||||
args = append(args, "-trimpath", "-ldflags", "-s -w")
|
||||
}
|
||||
args = append(args, "-o", "src/main.wasm", "./wasm")
|
||||
cmd := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...)
|
||||
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// installJSDeps installs the JavaScript dependencies specified by package.json
|
||||
func installJSDeps() error {
|
||||
log.Printf("Installing JS deps...\n")
|
||||
stdoutStderr, err := exec.Command("yarn").CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("yarn failed: %s", stdoutStderr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// EsbuildMetadata is the subset of metadata struct (described by
|
||||
// https://esbuild.github.io/api/#metafile) that we care about for mapping
|
||||
// from entry points to hashed file names.
|
||||
type EsbuildMetadata struct {
|
||||
Outputs map[string]struct {
|
||||
EntryPoint string `json:"entryPoint,omitempty"`
|
||||
} `json:"outputs,omitempty"`
|
||||
}
|
||||
38
cmd/tsconnect/dev.go
Normal file
38
cmd/tsconnect/dev.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
func runDev() {
|
||||
buildOptions, err := commonSetup(devMode)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot setup: %v", err)
|
||||
}
|
||||
host, portStr, err := net.SplitHostPort(*addr)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot parse addr: %v", err)
|
||||
}
|
||||
port, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot parse port: %v", err)
|
||||
}
|
||||
result, err := esbuild.Serve(esbuild.ServeOptions{
|
||||
Port: uint16(port),
|
||||
Host: host,
|
||||
Servedir: "./",
|
||||
}, *buildOptions)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot start esbuild server: %v", err)
|
||||
}
|
||||
log.Printf("Listening on http://%s:%d\n", result.Host, result.Port)
|
||||
result.Wait()
|
||||
}
|
||||
2
cmd/tsconnect/dist/placeholder
vendored
Normal file
2
cmd/tsconnect/dist/placeholder
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
This is here to make sure the dist/ directory exists for the go:embed command
|
||||
in serve.go.
|
||||
16
cmd/tsconnect/index.html
Normal file
16
cmd/tsconnect/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" type="text/css" href="dist/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<h1>Tailscale Connect</h1>
|
||||
<div id="state">Loading…</div>
|
||||
</div>
|
||||
<div id="peers"></div>
|
||||
<script src="dist/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
cmd/tsconnect/package.json
Normal file
12
cmd/tsconnect/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@tailscale/ssh",
|
||||
"version": "0.0.1",
|
||||
"devDependencies": {
|
||||
"qrcode": "^1.5.0",
|
||||
"xterm": "^4.18.0"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
148
cmd/tsconnect/serve.go
Normal file
148
cmd/tsconnect/serve.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tsweb"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var embeddedFS embed.FS
|
||||
|
||||
//go:embed dist/*
|
||||
var embeddedDistFS embed.FS
|
||||
|
||||
var serveStartTime = time.Now()
|
||||
|
||||
func runServe() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
var distFS fs.FS
|
||||
if *distDir == "./dist" {
|
||||
var err error
|
||||
distFS, err = fs.Sub(embeddedDistFS, "dist")
|
||||
if err != nil {
|
||||
log.Fatalf("Could not drop dist/ prefix from embedded FS: %v", err)
|
||||
}
|
||||
} else {
|
||||
distFS = os.DirFS(*distDir)
|
||||
}
|
||||
|
||||
indexBytes, err := generateServeIndex(distFS)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not generate index.html: %v", err)
|
||||
}
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, "index.html", serveStartTime, bytes.NewReader(indexBytes))
|
||||
}))
|
||||
mux.Handle("/dist/", http.StripPrefix("/dist/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleServeDist(w, r, distFS)
|
||||
})))
|
||||
tsweb.Debugger(mux)
|
||||
|
||||
log.Printf("Listening on %s", *addr)
|
||||
err = http.ListenAndServe(*addr, mux)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func generateServeIndex(distFS fs.FS) ([]byte, error) {
|
||||
log.Printf("Generating index.html...\n")
|
||||
rawIndexBytes, err := embeddedFS.ReadFile("index.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not read index.html: %w", err)
|
||||
}
|
||||
|
||||
esbuildMetadataFile, err := distFS.Open("esbuild-metadata.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not open esbuild-metadata.json: %w", err)
|
||||
}
|
||||
defer esbuildMetadataFile.Close()
|
||||
esbuildMetadataBytes, err := ioutil.ReadAll(esbuildMetadataFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not read esbuild-metadata.json: %w", err)
|
||||
}
|
||||
var esbuildMetadata EsbuildMetadata
|
||||
if err := json.Unmarshal(esbuildMetadataBytes, &esbuildMetadata); err != nil {
|
||||
return nil, fmt.Errorf("Could not parse esbuild-metadata.json: %w", err)
|
||||
}
|
||||
entryPointsToHashedDistPaths := make(map[string]string)
|
||||
for outputPath, output := range esbuildMetadata.Outputs {
|
||||
if output.EntryPoint != "" {
|
||||
entryPointsToHashedDistPaths[output.EntryPoint] = path.Join("dist", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
indexBytes := rawIndexBytes
|
||||
for entryPointPath, defaultDistPath := range entryPointsToDefaultDistPaths {
|
||||
hashedDistPath := entryPointsToHashedDistPaths[entryPointPath]
|
||||
if hashedDistPath != "" {
|
||||
indexBytes = bytes.ReplaceAll(indexBytes, []byte(defaultDistPath), []byte(hashedDistPath))
|
||||
}
|
||||
}
|
||||
|
||||
return indexBytes, nil
|
||||
}
|
||||
|
||||
var entryPointsToDefaultDistPaths = map[string]string{
|
||||
"src/index.css": "dist/index.css",
|
||||
"src/index.js": "dist/index.js",
|
||||
}
|
||||
|
||||
func handleServeDist(w http.ResponseWriter, r *http.Request, distFS fs.FS) {
|
||||
path := r.URL.Path
|
||||
var f fs.File
|
||||
// Prefer pre-compressed versions generated during the build step.
|
||||
if tsweb.AcceptsEncoding(r, "br") {
|
||||
if brotliFile, err := distFS.Open(path + ".br"); err == nil {
|
||||
f = brotliFile
|
||||
w.Header().Set("Content-Encoding", "br")
|
||||
}
|
||||
}
|
||||
if f == nil && tsweb.AcceptsEncoding(r, "gzip") {
|
||||
if gzipFile, err := distFS.Open(path + ".gz"); err == nil {
|
||||
f = gzipFile
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
}
|
||||
}
|
||||
|
||||
if f == nil {
|
||||
if rawFile, err := distFS.Open(path); err == nil {
|
||||
f = rawFile
|
||||
} else {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// fs.File does not claim to implement Seeker, but in practice it does.
|
||||
fSeeker, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
http.Error(w, "Not seekable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Aggressively cache static assets, since we cache-bust our assets with
|
||||
// hashed filenames.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31535996")
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
|
||||
http.ServeContent(w, r, path, serveStartTime, fSeeker)
|
||||
}
|
||||
91
cmd/tsconnect/src/index.css
Normal file
91
cmd/tsconnect/src/index.css
Normal file
@@ -0,0 +1,91 @@
|
||||
/* Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. */
|
||||
/* Use of this source code is governed by a BSD-style */
|
||||
/* license that can be found in the LICENSE file. */
|
||||
|
||||
@import "xterm/css/xterm.css";
|
||||
|
||||
html {
|
||||
background: #fff;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
border: solid 1px #ccc;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#header {
|
||||
background: #f7f5f4;
|
||||
border-bottom: 1px solid #eeebea;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#header h1 {
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#header #state {
|
||||
padding: 0 8px;
|
||||
color: #444342;
|
||||
}
|
||||
|
||||
#peers {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.login {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logout {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.peer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.peer:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.peer .name {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.peer .ssh {
|
||||
background-color: #cbf4c9;
|
||||
}
|
||||
|
||||
.term-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.xterm-viewport.xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
.xterm-viewport::-webkit-scrollbar-track {
|
||||
opacity: 0;
|
||||
}
|
||||
.xterm-viewport::-webkit-scrollbar-thumb {
|
||||
min-height: 20px;
|
||||
background-color: #ffffff20;
|
||||
}
|
||||
26
cmd/tsconnect/src/index.js
Normal file
26
cmd/tsconnect/src/index.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import "./wasm_exec"
|
||||
import wasmUrl from "./main.wasm"
|
||||
import { notifyState, notifyNetMap, notifyBrowseToURL } from "./notifier"
|
||||
import { sessionStateStorage } from "./js-state-store"
|
||||
|
||||
const go = new window.Go()
|
||||
WebAssembly.instantiateStreaming(
|
||||
fetch(`./dist/${wasmUrl}`),
|
||||
go.importObject
|
||||
).then((result) => {
|
||||
go.run(result.instance)
|
||||
const ipn = newIPN({
|
||||
// Persist IPN state in sessionStorage in development, so that we don't need
|
||||
// to re-authorize every time we reload the page.
|
||||
stateStorage: DEBUG ? sessionStateStorage : undefined,
|
||||
})
|
||||
ipn.run({
|
||||
notifyState: notifyState.bind(null, ipn),
|
||||
notifyNetMap: notifyNetMap.bind(null, ipn),
|
||||
notifyBrowseToURL: notifyBrowseToURL.bind(null, ipn),
|
||||
})
|
||||
})
|
||||
16
cmd/tsconnect/src/js-state-store.js
Normal file
16
cmd/tsconnect/src/js-state-store.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/**
|
||||
* @fileoverview Callbacks used by jsStateStore to persist IPN state.
|
||||
*/
|
||||
|
||||
export const sessionStateStorage = {
|
||||
setState(id, value) {
|
||||
window.sessionStorage[`ipn-state-${id}`] = value
|
||||
},
|
||||
getState(id) {
|
||||
return window.sessionStorage[`ipn-state-${id}`] || ""
|
||||
},
|
||||
}
|
||||
71
cmd/tsconnect/src/login.js
Normal file
71
cmd/tsconnect/src/login.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import QRCode from "qrcode"
|
||||
|
||||
export async function showLoginURL(url) {
|
||||
if (loginNode) {
|
||||
loginNode.remove()
|
||||
}
|
||||
loginNode = document.createElement("div")
|
||||
loginNode.className = "login"
|
||||
const linkNode = document.createElement("a")
|
||||
linkNode.href = url
|
||||
linkNode.target = "_blank"
|
||||
loginNode.appendChild(linkNode)
|
||||
|
||||
try {
|
||||
const dataURL = await QRCode.toDataURL(url, { width: 512 })
|
||||
const imageNode = document.createElement("img")
|
||||
imageNode.src = dataURL
|
||||
imageNode.width = 256
|
||||
imageNode.height = 256
|
||||
imageNode.border = "0"
|
||||
linkNode.appendChild(imageNode)
|
||||
} catch (err) {
|
||||
console.error("Could not generate QR code:", err)
|
||||
}
|
||||
|
||||
linkNode.appendChild(document.createElement("br"))
|
||||
linkNode.appendChild(document.createTextNode(url))
|
||||
|
||||
document.body.appendChild(loginNode)
|
||||
}
|
||||
|
||||
export function hideLoginURL() {
|
||||
if (!loginNode) {
|
||||
return
|
||||
}
|
||||
loginNode.remove()
|
||||
loginNode = undefined
|
||||
}
|
||||
|
||||
let loginNode
|
||||
|
||||
export function showLogoutButton(ipn) {
|
||||
if (logoutButtonNode) {
|
||||
logoutButtonNode.remove()
|
||||
}
|
||||
logoutButtonNode = document.createElement("button")
|
||||
logoutButtonNode.className = "logout"
|
||||
logoutButtonNode.textContent = "Logout"
|
||||
logoutButtonNode.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
ipn.logout()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
document.getElementById("header").appendChild(logoutButtonNode)
|
||||
}
|
||||
|
||||
export function hideLogoutButton() {
|
||||
if (!logoutButtonNode) {
|
||||
return
|
||||
}
|
||||
logoutButtonNode.remove()
|
||||
logoutButtonNode = undefined
|
||||
}
|
||||
|
||||
let logoutButtonNode
|
||||
75
cmd/tsconnect/src/notifier.js
Normal file
75
cmd/tsconnect/src/notifier.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import {
|
||||
showLoginURL,
|
||||
hideLoginURL,
|
||||
showLogoutButton,
|
||||
hideLogoutButton,
|
||||
} from "./login"
|
||||
import { showSSHPeers, hideSSHPeers } from "./ssh"
|
||||
|
||||
/**
|
||||
* @fileoverview Notification callback functions (bridged from ipn.Notify)
|
||||
*/
|
||||
|
||||
/** Mirrors values from ipn/backend.go */
|
||||
const State = {
|
||||
NoState: 0,
|
||||
InUseOtherUser: 1,
|
||||
NeedsLogin: 2,
|
||||
NeedsMachineAuth: 3,
|
||||
Stopped: 4,
|
||||
Starting: 5,
|
||||
Running: 6,
|
||||
}
|
||||
|
||||
export function notifyState(ipn, state) {
|
||||
let stateLabel
|
||||
switch (state) {
|
||||
case State.NoState:
|
||||
stateLabel = "Initializing…"
|
||||
break
|
||||
case State.InUseOtherUser:
|
||||
stateLabel = "In-use by another user"
|
||||
break
|
||||
case State.NeedsLogin:
|
||||
stateLabel = "Needs Login"
|
||||
hideLogoutButton()
|
||||
hideSSHPeers()
|
||||
ipn.login()
|
||||
break
|
||||
case State.NeedsMachineAuth:
|
||||
stateLabel = "Needs authorization"
|
||||
break
|
||||
case State.Stopped:
|
||||
stateLabel = "Stopped"
|
||||
hideLogoutButton()
|
||||
hideSSHPeers()
|
||||
break
|
||||
case State.Starting:
|
||||
stateLabel = "Starting…"
|
||||
break
|
||||
case State.Running:
|
||||
stateLabel = "Running"
|
||||
hideLoginURL()
|
||||
showLogoutButton(ipn)
|
||||
break
|
||||
}
|
||||
const stateNode = document.getElementById("state")
|
||||
stateNode.textContent = stateLabel ?? ""
|
||||
}
|
||||
|
||||
export function notifyNetMap(ipn, netMapStr) {
|
||||
const netMap = JSON.parse(netMapStr)
|
||||
if (DEBUG) {
|
||||
console.log("Received net map: " + JSON.stringify(netMap, null, 2))
|
||||
}
|
||||
|
||||
showSSHPeers(netMap.peers, ipn)
|
||||
}
|
||||
|
||||
export function notifyBrowseToURL(ipn, url) {
|
||||
showLoginURL(url)
|
||||
}
|
||||
77
cmd/tsconnect/src/ssh.js
Normal file
77
cmd/tsconnect/src/ssh.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
import { Terminal } from "xterm"
|
||||
|
||||
export function showSSHPeers(peers, ipn) {
|
||||
const peersNode = document.getElementById("peers")
|
||||
peersNode.innerHTML = ""
|
||||
|
||||
const sshPeers = peers.filter((p) => p.tailscaleSSHEnabled)
|
||||
if (!sshPeers.length) {
|
||||
peersNode.textContent = "No machines have Tailscale SSH installed."
|
||||
return
|
||||
}
|
||||
|
||||
for (const peer of sshPeers) {
|
||||
const peerNode = document.createElement("div")
|
||||
peerNode.className = "peer"
|
||||
const nameNode = document.createElement("div")
|
||||
nameNode.className = "name"
|
||||
nameNode.textContent = peer.name
|
||||
peerNode.appendChild(nameNode)
|
||||
|
||||
const sshButtonNode = document.createElement("button")
|
||||
sshButtonNode.className = "ssh"
|
||||
sshButtonNode.addEventListener("click", function () {
|
||||
ssh(peer.name, ipn)
|
||||
})
|
||||
sshButtonNode.textContent = "SSH"
|
||||
peerNode.appendChild(sshButtonNode)
|
||||
|
||||
peersNode.appendChild(peerNode)
|
||||
}
|
||||
}
|
||||
|
||||
export function hideSSHPeers() {
|
||||
const peersNode = document.getElementById("peers")
|
||||
peersNode.innerHTML = ""
|
||||
}
|
||||
|
||||
function ssh(hostname, ipn) {
|
||||
const termContainerNode = document.createElement("div")
|
||||
termContainerNode.className = "term-container"
|
||||
document.body.appendChild(termContainerNode)
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
})
|
||||
term.open(termContainerNode)
|
||||
|
||||
// Cancel wheel events from scrolling the page if the terminal has scrollback
|
||||
termContainerNode.addEventListener("wheel", (e) => {
|
||||
if (term.buffer.active.baseY > 0) {
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
let onDataHook
|
||||
term.onData((e) => {
|
||||
onDataHook?.(e)
|
||||
})
|
||||
|
||||
term.focus()
|
||||
|
||||
ipn.ssh(
|
||||
hostname,
|
||||
(input) => term.write(input),
|
||||
(hook) => (onDataHook = hook),
|
||||
term.rows,
|
||||
term.cols,
|
||||
() => {
|
||||
term.dispose()
|
||||
termContainerNode.remove()
|
||||
}
|
||||
)
|
||||
}
|
||||
61
cmd/tsconnect/tsconnect.go
Normal file
61
cmd/tsconnect/tsconnect.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// The tsconnect command builds and serves the static site that is generated for
|
||||
// the Tailscale Connect JS/WASM client. Can be run in 3 modes:
|
||||
// - dev: builds the site and serves it. JS and CSS changes can be picked up
|
||||
// with a reload.
|
||||
// - build: builds the site and writes it to dist/
|
||||
// - serve: serves the site from dist/ (embedded in the binary)
|
||||
package main // import "tailscale.com/cmd/tsconnect"
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
addr = flag.String("addr", ":9090", "address to listen on")
|
||||
distDir = flag.String("distdir", "./dist", "path of directory to place build output in")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Usage = usage
|
||||
flag.Parse()
|
||||
if len(flag.Args()) != 1 {
|
||||
flag.Usage()
|
||||
}
|
||||
|
||||
switch flag.Arg(0) {
|
||||
case "dev":
|
||||
runDev()
|
||||
case "build":
|
||||
runBuild()
|
||||
case "serve":
|
||||
runServe()
|
||||
default:
|
||||
log.Printf("Unknown command: %s", flag.Arg(0))
|
||||
flag.Usage()
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
usage: tsconnect {dev|build|serve}
|
||||
`[1:])
|
||||
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
|
||||
tsconnect implements development/build/serving workflows for Tailscale Connect.
|
||||
It can be invoked with one of three subcommands:
|
||||
|
||||
- dev: Run in development mode, allowing JS and CSS changes to be picked up without a rebuilt or restart.
|
||||
- build: Run in production build mode (generating static assets)
|
||||
- serve: Run in production serve mode (serving static assets)
|
||||
`[1:])
|
||||
os.Exit(2)
|
||||
}
|
||||
411
cmd/tsconnect/wasm/wasm_js.go
Normal file
411
cmd/tsconnect/wasm/wasm_js.go
Normal file
@@ -0,0 +1,411 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// The wasm package builds a WebAssembly module that provides a subset of
|
||||
// Tailscale APIs to JavaScript.
|
||||
//
|
||||
// When run in the browser, a newIPN(config) function is added to the global JS
|
||||
// namespace. When called it returns an ipn object with the methods
|
||||
// run(callbacks), login(), logout(), and ssh(...).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/ipn/ipnserver"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/wgengine"
|
||||
"tailscale.com/wgengine/netstack"
|
||||
"tailscale.com/words"
|
||||
)
|
||||
|
||||
func main() {
|
||||
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
if len(args) != 1 {
|
||||
log.Fatal("Usage: newIPN(config)")
|
||||
return nil
|
||||
}
|
||||
return newIPN(args[0])
|
||||
}))
|
||||
// Keep Go runtime alive, otherwise it will be shut down before newIPN gets
|
||||
// called.
|
||||
<-make(chan bool)
|
||||
}
|
||||
|
||||
func newIPN(jsConfig js.Value) map[string]any {
|
||||
netns.SetEnabled(false)
|
||||
var logf logger.Logf = log.Printf
|
||||
|
||||
dialer := new(tsdial.Dialer)
|
||||
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
|
||||
Dialer: dialer,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
tunDev, magicConn, dnsManager, ok := eng.(wgengine.InternalsGetter).GetInternals()
|
||||
if !ok {
|
||||
log.Fatalf("%T is not a wgengine.InternalsGetter", eng)
|
||||
}
|
||||
ns, err := netstack.Create(logf, tunDev, eng, magicConn, dialer, dnsManager)
|
||||
if err != nil {
|
||||
log.Fatalf("netstack.Create: %v", err)
|
||||
}
|
||||
ns.ProcessLocalIPs = true
|
||||
ns.ProcessSubnets = true
|
||||
if err := ns.Start(); err != nil {
|
||||
log.Fatalf("failed to start netstack: %v", err)
|
||||
}
|
||||
dialer.UseNetstackForIP = func(ip netaddr.IP) bool {
|
||||
return true
|
||||
}
|
||||
dialer.NetstackDialTCP = func(ctx context.Context, dst netaddr.IPPort) (net.Conn, error) {
|
||||
return ns.DialContextTCP(ctx, dst)
|
||||
}
|
||||
|
||||
jsStateStorage := jsConfig.Get("stateStorage")
|
||||
var store ipn.StateStore
|
||||
if jsStateStorage.IsUndefined() {
|
||||
store = new(mem.Store)
|
||||
} else {
|
||||
store = &jsStateStore{jsStateStorage}
|
||||
}
|
||||
srv, err := ipnserver.New(log.Printf, "some-logid", store, eng, dialer, nil, ipnserver.Options{
|
||||
SurviveDisconnects: true,
|
||||
LoginFlags: controlclient.LoginEphemeral,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("ipnserver.New: %v", err)
|
||||
}
|
||||
lb := srv.LocalBackend()
|
||||
|
||||
jsIPN := &jsIPN{
|
||||
dialer: dialer,
|
||||
srv: srv,
|
||||
lb: lb,
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"run": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
if len(args) != 1 {
|
||||
log.Fatal(`Usage: run({
|
||||
notifyState(state: int): void,
|
||||
notifyNetMap(netMap: object): void,
|
||||
notifyBrowseToURL(url: string): void,
|
||||
})`)
|
||||
return nil
|
||||
}
|
||||
jsIPN.run(args[0])
|
||||
return nil
|
||||
}),
|
||||
"login": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
if len(args) != 0 {
|
||||
log.Printf("Usage: login()")
|
||||
return nil
|
||||
}
|
||||
jsIPN.login()
|
||||
return nil
|
||||
}),
|
||||
"logout": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
if len(args) != 0 {
|
||||
log.Printf("Usage: logout()")
|
||||
return nil
|
||||
}
|
||||
jsIPN.logout()
|
||||
return nil
|
||||
}),
|
||||
"ssh": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
if len(args) != 6 {
|
||||
log.Printf("Usage: ssh(hostname, writeFn, readFn, rows, cols, onDone)")
|
||||
return nil
|
||||
}
|
||||
go jsIPN.ssh(
|
||||
args[0].String(),
|
||||
args[1],
|
||||
args[2],
|
||||
args[3].Int(),
|
||||
args[4].Int(),
|
||||
args[5])
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
type jsIPN struct {
|
||||
dialer *tsdial.Dialer
|
||||
srv *ipnserver.Server
|
||||
lb *ipnlocal.LocalBackend
|
||||
}
|
||||
|
||||
func (i *jsIPN) run(jsCallbacks js.Value) {
|
||||
notifyState := func(state ipn.State) {
|
||||
jsCallbacks.Call("notifyState", int(state))
|
||||
}
|
||||
notifyState(ipn.NoState)
|
||||
|
||||
i.lb.SetNotifyCallback(func(n ipn.Notify) {
|
||||
log.Printf("NOTIFY: %+v", n)
|
||||
if n.State != nil {
|
||||
notifyState(*n.State)
|
||||
}
|
||||
if nm := n.NetMap; nm != nil {
|
||||
jsNetMap := jsNetMap{
|
||||
Self: jsNetMapSelfNode{
|
||||
jsNetMapNode: jsNetMapNode{
|
||||
Name: nm.Name,
|
||||
Addresses: mapSlice(nm.Addresses, func(a netaddr.IPPrefix) string { return a.IP().String() }),
|
||||
NodeKey: nm.NodeKey.String(),
|
||||
MachineKey: nm.MachineKey.String(),
|
||||
},
|
||||
MachineStatus: int(nm.MachineStatus),
|
||||
},
|
||||
Peers: mapSlice(nm.Peers, func(p *tailcfg.Node) jsNetMapPeerNode {
|
||||
return jsNetMapPeerNode{
|
||||
jsNetMapNode: jsNetMapNode{
|
||||
Name: p.Name,
|
||||
Addresses: mapSlice(p.Addresses, func(a netaddr.IPPrefix) string { return a.IP().String() }),
|
||||
MachineKey: p.Machine.String(),
|
||||
NodeKey: p.Key.String(),
|
||||
},
|
||||
Online: *p.Online,
|
||||
TailscaleSSHEnabled: p.Hostinfo.TailscaleSSHEnabled(),
|
||||
}
|
||||
}),
|
||||
}
|
||||
if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
|
||||
jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
|
||||
} else {
|
||||
log.Printf("Could not generate JSON netmap: %v", err)
|
||||
}
|
||||
}
|
||||
if n.BrowseToURL != nil {
|
||||
jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
|
||||
}
|
||||
})
|
||||
|
||||
go func() {
|
||||
err := i.lb.Start(ipn.Options{
|
||||
StateKey: "wasm",
|
||||
UpdatePrefs: &ipn.Prefs{
|
||||
ControlURL: ipn.DefaultControlURL,
|
||||
RouteAll: false,
|
||||
AllowSingleHosts: true,
|
||||
WantRunning: true,
|
||||
Hostname: generateHostname(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Start error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
ln, _, err := safesocket.Listen("", 0)
|
||||
if err != nil {
|
||||
log.Fatalf("safesocket.Listen: %v", err)
|
||||
}
|
||||
|
||||
err = i.srv.Run(context.Background(), ln)
|
||||
log.Fatalf("ipnserver.Run exited: %v", err)
|
||||
}()
|
||||
}
|
||||
|
||||
func (i *jsIPN) login() {
|
||||
go i.lb.StartLoginInteractive()
|
||||
}
|
||||
|
||||
func (i *jsIPN) logout() {
|
||||
if i.lb.State() == ipn.NoState {
|
||||
log.Printf("Backend not running")
|
||||
}
|
||||
go i.lb.Logout()
|
||||
}
|
||||
|
||||
func (i *jsIPN) ssh(host string, writeFn js.Value, setReadFn js.Value, rows, cols int, onDone js.Value) {
|
||||
defer onDone.Invoke()
|
||||
|
||||
write := func(s string) {
|
||||
writeFn.Invoke(s)
|
||||
}
|
||||
writeError := func(label string, err error) {
|
||||
write(fmt.Sprintf("%s Error: %v\r\n", label, err))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
c, err := i.dialer.UserDial(ctx, "tcp", net.JoinHostPort(host, "22"))
|
||||
if err != nil {
|
||||
writeError("Dial", err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
sshConn, _, _, err := ssh.NewClientConn(c, host, config)
|
||||
if err != nil {
|
||||
writeError("SSH Connection", err)
|
||||
return
|
||||
}
|
||||
defer sshConn.Close()
|
||||
write("SSH Connected\r\n")
|
||||
|
||||
sshClient := ssh.NewClient(sshConn, nil, nil)
|
||||
defer sshClient.Close()
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
writeError("SSH Session", err)
|
||||
return
|
||||
}
|
||||
write("Session Established\r\n")
|
||||
defer session.Close()
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
writeError("SSH Stdin", err)
|
||||
return
|
||||
}
|
||||
|
||||
session.Stdout = termWriter{writeFn}
|
||||
session.Stderr = termWriter{writeFn}
|
||||
|
||||
setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
input := args[0].String()
|
||||
_, err := stdin.Write([]byte(input))
|
||||
if err != nil {
|
||||
writeError("Write Input", err)
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
|
||||
|
||||
if err != nil {
|
||||
writeError("Pseudo Terminal", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = session.Shell()
|
||||
if err != nil {
|
||||
writeError("Shell", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = session.Wait()
|
||||
if err != nil {
|
||||
writeError("Exit", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type termWriter struct {
|
||||
f js.Value
|
||||
}
|
||||
|
||||
func (w termWriter) Write(p []byte) (n int, err error) {
|
||||
r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1)
|
||||
w.f.Invoke(string(r))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
type jsNetMap struct {
|
||||
Self jsNetMapSelfNode `json:"self"`
|
||||
Peers []jsNetMapPeerNode `json:"peers"`
|
||||
}
|
||||
|
||||
type jsNetMapNode struct {
|
||||
Name string `json:"name"`
|
||||
Addresses []string `json:"addresses"`
|
||||
MachineStatus int `json:"machineStatus"`
|
||||
MachineKey string `json:"machineKey"`
|
||||
NodeKey string `json:"nodeKey"`
|
||||
}
|
||||
|
||||
type jsNetMapSelfNode struct {
|
||||
jsNetMapNode
|
||||
MachineStatus int `json:"machineStatus"`
|
||||
}
|
||||
|
||||
type jsNetMapPeerNode struct {
|
||||
jsNetMapNode
|
||||
Online bool `json:"online"`
|
||||
TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"`
|
||||
}
|
||||
|
||||
type jsStateStore struct {
|
||||
jsStateStorage js.Value
|
||||
}
|
||||
|
||||
func (s *jsStateStore) ReadState(id ipn.StateKey) ([]byte, error) {
|
||||
jsValue := s.jsStateStorage.Call("getState", string(id))
|
||||
if jsValue.String() == "" {
|
||||
return nil, ipn.ErrStateNotExist
|
||||
}
|
||||
return hex.DecodeString(jsValue.String())
|
||||
}
|
||||
|
||||
func (s *jsStateStore) WriteState(id ipn.StateKey, bs []byte) error {
|
||||
s.jsStateStorage.Call("setState", string(id), hex.EncodeToString(bs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapSlice[T any, M any](a []T, f func(T) M) []M {
|
||||
n := make([]M, len(a))
|
||||
for i, e := range a {
|
||||
n[i] = f(e)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func filterSlice[T any](a []T, f func(T) bool) []T {
|
||||
n := make([]T, 0, len(a))
|
||||
for _, e := range a {
|
||||
if f(e) {
|
||||
n = append(n, e)
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func generateHostname() string {
|
||||
tails := words.Tails()
|
||||
scales := words.Scales()
|
||||
if rand.Int()%2 == 0 {
|
||||
// JavaScript
|
||||
tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "j") })
|
||||
scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "s") })
|
||||
} else {
|
||||
// WebAssembly
|
||||
tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "w") })
|
||||
scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "a") })
|
||||
}
|
||||
|
||||
tail := tails[rand.Intn(len(tails))]
|
||||
scale := scales[rand.Intn(len(scales))]
|
||||
return fmt.Sprintf("%s-%s", tail, scale)
|
||||
}
|
||||
205
cmd/tsconnect/yarn.lock
Normal file
205
cmd/tsconnect/yarn.lock
Normal file
@@ -0,0 +1,205 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
ansi-regex@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
|
||||
|
||||
ansi-styles@^4.0.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
|
||||
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
|
||||
dependencies:
|
||||
color-convert "^2.0.1"
|
||||
|
||||
camelcase@^5.0.0:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
|
||||
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
|
||||
|
||||
cliui@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
|
||||
integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==
|
||||
dependencies:
|
||||
string-width "^4.2.0"
|
||||
strip-ansi "^6.0.0"
|
||||
wrap-ansi "^6.2.0"
|
||||
|
||||
color-convert@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
|
||||
integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
|
||||
dependencies:
|
||||
color-name "~1.1.4"
|
||||
|
||||
color-name@~1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
||||
decamelize@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
|
||||
integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
|
||||
|
||||
dijkstrajs@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257"
|
||||
integrity sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg==
|
||||
|
||||
emoji-regex@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
|
||||
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
|
||||
|
||||
encode-utf8@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda"
|
||||
integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==
|
||||
|
||||
find-up@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
|
||||
integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
|
||||
dependencies:
|
||||
locate-path "^5.0.0"
|
||||
path-exists "^4.0.0"
|
||||
|
||||
get-caller-file@^2.0.1:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
|
||||
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
|
||||
|
||||
is-fullwidth-code-point@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
|
||||
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
|
||||
|
||||
locate-path@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
|
||||
integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
|
||||
dependencies:
|
||||
p-locate "^4.1.0"
|
||||
|
||||
p-limit@^2.2.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
|
||||
integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
|
||||
dependencies:
|
||||
p-try "^2.0.0"
|
||||
|
||||
p-locate@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
|
||||
integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
|
||||
dependencies:
|
||||
p-limit "^2.2.0"
|
||||
|
||||
p-try@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
|
||||
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
|
||||
|
||||
path-exists@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||
integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
|
||||
|
||||
pngjs@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
|
||||
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
|
||||
|
||||
qrcode@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.0.tgz#95abb8a91fdafd86f8190f2836abbfc500c72d1b"
|
||||
integrity sha512-9MgRpgVc+/+47dFvQeD6U2s0Z92EsKzcHogtum4QB+UNd025WOJSHvn/hjk9xmzj7Stj95CyUAs31mrjxliEsQ==
|
||||
dependencies:
|
||||
dijkstrajs "^1.0.1"
|
||||
encode-utf8 "^1.0.3"
|
||||
pngjs "^5.0.0"
|
||||
yargs "^15.3.1"
|
||||
|
||||
require-directory@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
|
||||
integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
|
||||
|
||||
require-main-filename@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
|
||||
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
|
||||
|
||||
set-blocking@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
|
||||
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
which-module@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
|
||||
integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
|
||||
|
||||
wrap-ansi@^6.2.0:
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
|
||||
integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
xterm@^4.18.0:
|
||||
version "4.18.0"
|
||||
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1"
|
||||
integrity sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ==
|
||||
|
||||
y18n@^4.0.0:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
|
||||
integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
|
||||
|
||||
yargs-parser@^18.1.2:
|
||||
version "18.1.3"
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
|
||||
integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
|
||||
dependencies:
|
||||
camelcase "^5.0.0"
|
||||
decamelize "^1.2.0"
|
||||
|
||||
yargs@^15.3.1:
|
||||
version "15.4.1"
|
||||
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
|
||||
integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
|
||||
dependencies:
|
||||
cliui "^6.0.0"
|
||||
decamelize "^1.2.0"
|
||||
find-up "^4.1.0"
|
||||
get-caller-file "^2.0.1"
|
||||
require-directory "^2.1.1"
|
||||
require-main-filename "^2.0.0"
|
||||
set-blocking "^2.0.0"
|
||||
string-width "^4.2.0"
|
||||
which-module "^2.0.0"
|
||||
y18n "^4.0.0"
|
||||
yargs-parser "^18.1.2"
|
||||
@@ -2,177 +2,12 @@
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
// The tsshd binary is an SSH server that accepts connections
|
||||
// The tsshd binary was an experimental SSH server that accepts connections
|
||||
// from anybody on the same Tailscale network.
|
||||
//
|
||||
// It does not use passwords or SSH public key.
|
||||
// Its functionality moved into tailscaled.
|
||||
//
|
||||
// Any user name is accepted; users are logged in as whoever is
|
||||
// running this daemon.
|
||||
//
|
||||
// Warning: use at your own risk. This code has had very few eyeballs
|
||||
// on it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/creack/pty"
|
||||
gossh "github.com/tailscale/golang-x-crypto/ssh"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tempfork/gliderlabs/ssh"
|
||||
)
|
||||
|
||||
var (
|
||||
port = flag.Int("port", 2200, "port to listen on")
|
||||
hostKey = flag.String("hostkey", "", "SSH host key")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *hostKey == "" {
|
||||
log.Fatalf("missing required --hostkey")
|
||||
}
|
||||
hostKey, err := ioutil.ReadFile(*hostKey)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
signer, err := gossh.ParsePrivateKey(hostKey)
|
||||
if err != nil {
|
||||
log.Printf("failed to parse SSH host key: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
warned := false
|
||||
for {
|
||||
addrs, iface, err := interfaces.Tailscale()
|
||||
if err != nil {
|
||||
log.Fatalf("listing interfaces: %v", err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
if !warned {
|
||||
log.Printf("no tailscale interface found; polling until one is available")
|
||||
warned = true
|
||||
}
|
||||
// TODO: use netlink or other OS-specific mechanism to efficiently
|
||||
// wait for change in interfaces. Polling every N seconds is good enough
|
||||
// for now.
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
warned = false
|
||||
var addr netaddr.IP
|
||||
for _, a := range addrs {
|
||||
if a.Is4() {
|
||||
addr = a
|
||||
break
|
||||
}
|
||||
}
|
||||
listen := net.JoinHostPort(addr.String(), fmt.Sprint(*port))
|
||||
log.Printf("tailscale ssh server listening on %v, %v", iface.Name, listen)
|
||||
s := &ssh.Server{
|
||||
Addr: listen,
|
||||
Handler: handleSSH,
|
||||
}
|
||||
s.AddHostKey(signer)
|
||||
|
||||
err = s.ListenAndServe()
|
||||
log.Fatalf("tailscale sshd failed: %v", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func handleSSH(s ssh.Session) {
|
||||
user := s.User()
|
||||
addr := s.RemoteAddr()
|
||||
ta, ok := addr.(*net.TCPAddr)
|
||||
if !ok {
|
||||
log.Printf("tsshd: rejecting non-TCP addr %T %v", addr, addr)
|
||||
s.Exit(1)
|
||||
return
|
||||
}
|
||||
tanetaddr, ok := netaddr.FromStdIP(ta.IP)
|
||||
if !ok {
|
||||
log.Printf("tsshd: rejecting unparseable addr %v", ta.IP)
|
||||
s.Exit(1)
|
||||
return
|
||||
}
|
||||
if !tsaddr.IsTailscaleIP(tanetaddr) {
|
||||
log.Printf("tsshd: rejecting non-Tailscale addr %v", ta.IP)
|
||||
s.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("new session for %q from %v", user, ta)
|
||||
defer log.Printf("closing session for %q from %v", user, ta)
|
||||
ptyReq, winCh, isPty := s.Pty()
|
||||
if !isPty {
|
||||
fmt.Fprintf(s, "TODO scp etc")
|
||||
s.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
userWantsShell := len(s.Command()) == 0
|
||||
|
||||
if userWantsShell {
|
||||
shell, err := shellOfUser(s.User())
|
||||
if err != nil {
|
||||
fmt.Fprintf(s, "failed to find shell: %v\n", err)
|
||||
s.Exit(1)
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(shell)
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term))
|
||||
f, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
log.Printf("running shell: %v", err)
|
||||
s.Exit(1)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
go func() {
|
||||
for win := range winCh {
|
||||
setWinsize(f, win.Width, win.Height)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
io.Copy(f, s) // stdin
|
||||
}()
|
||||
io.Copy(s, f) // stdout
|
||||
cmd.Process.Kill()
|
||||
if err := cmd.Wait(); err != nil {
|
||||
s.Exit(1)
|
||||
} else {
|
||||
s.Exit(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(s, "TODO: args\n")
|
||||
s.Exit(1)
|
||||
}
|
||||
|
||||
func shellOfUser(user string) (string, error) {
|
||||
// TODO
|
||||
return "/bin/bash", nil
|
||||
}
|
||||
|
||||
func setWinsize(f *os.File, w, h int) {
|
||||
syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ),
|
||||
uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0})))
|
||||
}
|
||||
// See https://github.com/tailscale/tailscale/issues/3802
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package main
|
||||
|
||||
func main() {
|
||||
panic("tsshd does not work on windows yet")
|
||||
}
|
||||
59
cmd/viewer/tests/tests.go
Normal file
59
cmd/viewer/tests/tests.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package tests serves a list of tests for tailscale.com/cmd/viewer.
|
||||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"inet.af/netaddr"
|
||||
)
|
||||
|
||||
//go:generate go run tailscale.com/cmd/viewer --type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices
|
||||
|
||||
type StructWithoutPtrs struct {
|
||||
Int int
|
||||
Pfx netaddr.IPPrefix
|
||||
}
|
||||
|
||||
type Map struct {
|
||||
Int map[string]int
|
||||
SliceInt map[string][]int
|
||||
StructWithPtr map[string]*StructWithPtrs
|
||||
StructWithoutPtr map[string]*StructWithoutPtrs
|
||||
SlicesWithPtrs map[string][]*StructWithPtrs
|
||||
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
|
||||
StructWithoutPtrKey map[StructWithoutPtrs]int `json:"-"`
|
||||
|
||||
// Unsupported.
|
||||
SliceIntPtr map[string][]*int
|
||||
PointerKey map[*string]int `json:"-"`
|
||||
StructWithPtrKey map[StructWithPtrs]int `json:"-"`
|
||||
}
|
||||
|
||||
type StructWithPtrs struct {
|
||||
Value *StructWithoutPtrs
|
||||
Int *int
|
||||
|
||||
NoCloneValue *StructWithoutPtrs `codegen:"noclone"`
|
||||
}
|
||||
|
||||
func (v *StructWithPtrs) String() string { return fmt.Sprintf("%v", v.Int) }
|
||||
|
||||
func (v *StructWithPtrs) Equal(v2 *StructWithPtrs) bool {
|
||||
return v.Value == v2.Value
|
||||
}
|
||||
|
||||
type StructWithSlices struct {
|
||||
Values []StructWithoutPtrs
|
||||
ValuePointers []*StructWithoutPtrs
|
||||
StructPointers []*StructWithPtrs
|
||||
Structs []StructWithPtrs
|
||||
Ints []*int
|
||||
|
||||
Slice []string
|
||||
Prefixes []netaddr.IPPrefix
|
||||
Data []byte
|
||||
}
|
||||
183
cmd/viewer/tests/tests_clone.go
Normal file
183
cmd/viewer/tests/tests_clone.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Code generated by tailscale.com/cmd/cloner; DO NOT EDIT.
|
||||
|
||||
package tests
|
||||
|
||||
import (
|
||||
"inet.af/netaddr"
|
||||
)
|
||||
|
||||
// Clone makes a deep copy of StructWithPtrs.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *StructWithPtrs) Clone() *StructWithPtrs {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(StructWithPtrs)
|
||||
*dst = *src
|
||||
if dst.Value != nil {
|
||||
dst.Value = new(StructWithoutPtrs)
|
||||
*dst.Value = *src.Value
|
||||
}
|
||||
if dst.Int != nil {
|
||||
dst.Int = new(int)
|
||||
*dst.Int = *src.Int
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _StructWithPtrsCloneNeedsRegeneration = StructWithPtrs(struct {
|
||||
Value *StructWithoutPtrs
|
||||
Int *int
|
||||
NoCloneValue *StructWithoutPtrs
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of StructWithoutPtrs.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *StructWithoutPtrs) Clone() *StructWithoutPtrs {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(StructWithoutPtrs)
|
||||
*dst = *src
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _StructWithoutPtrsCloneNeedsRegeneration = StructWithoutPtrs(struct {
|
||||
Int int
|
||||
Pfx netaddr.IPPrefix
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of Map.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *Map) Clone() *Map {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(Map)
|
||||
*dst = *src
|
||||
if dst.Int != nil {
|
||||
dst.Int = map[string]int{}
|
||||
for k, v := range src.Int {
|
||||
dst.Int[k] = v
|
||||
}
|
||||
}
|
||||
if dst.SliceInt != nil {
|
||||
dst.SliceInt = map[string][]int{}
|
||||
for k := range src.SliceInt {
|
||||
dst.SliceInt[k] = append([]int{}, src.SliceInt[k]...)
|
||||
}
|
||||
}
|
||||
if dst.StructWithPtr != nil {
|
||||
dst.StructWithPtr = map[string]*StructWithPtrs{}
|
||||
for k, v := range src.StructWithPtr {
|
||||
dst.StructWithPtr[k] = v.Clone()
|
||||
}
|
||||
}
|
||||
if dst.StructWithoutPtr != nil {
|
||||
dst.StructWithoutPtr = map[string]*StructWithoutPtrs{}
|
||||
for k, v := range src.StructWithoutPtr {
|
||||
dst.StructWithoutPtr[k] = v.Clone()
|
||||
}
|
||||
}
|
||||
if dst.SlicesWithPtrs != nil {
|
||||
dst.SlicesWithPtrs = map[string][]*StructWithPtrs{}
|
||||
for k := range src.SlicesWithPtrs {
|
||||
dst.SlicesWithPtrs[k] = append([]*StructWithPtrs{}, src.SlicesWithPtrs[k]...)
|
||||
}
|
||||
}
|
||||
if dst.SlicesWithoutPtrs != nil {
|
||||
dst.SlicesWithoutPtrs = map[string][]*StructWithoutPtrs{}
|
||||
for k := range src.SlicesWithoutPtrs {
|
||||
dst.SlicesWithoutPtrs[k] = append([]*StructWithoutPtrs{}, src.SlicesWithoutPtrs[k]...)
|
||||
}
|
||||
}
|
||||
if dst.StructWithoutPtrKey != nil {
|
||||
dst.StructWithoutPtrKey = map[StructWithoutPtrs]int{}
|
||||
for k, v := range src.StructWithoutPtrKey {
|
||||
dst.StructWithoutPtrKey[k] = v
|
||||
}
|
||||
}
|
||||
if dst.SliceIntPtr != nil {
|
||||
dst.SliceIntPtr = map[string][]*int{}
|
||||
for k := range src.SliceIntPtr {
|
||||
dst.SliceIntPtr[k] = append([]*int{}, src.SliceIntPtr[k]...)
|
||||
}
|
||||
}
|
||||
if dst.PointerKey != nil {
|
||||
dst.PointerKey = map[*string]int{}
|
||||
for k, v := range src.PointerKey {
|
||||
dst.PointerKey[k] = v
|
||||
}
|
||||
}
|
||||
if dst.StructWithPtrKey != nil {
|
||||
dst.StructWithPtrKey = map[StructWithPtrs]int{}
|
||||
for k, v := range src.StructWithPtrKey {
|
||||
dst.StructWithPtrKey[k] = v
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _MapCloneNeedsRegeneration = Map(struct {
|
||||
Int map[string]int
|
||||
SliceInt map[string][]int
|
||||
StructWithPtr map[string]*StructWithPtrs
|
||||
StructWithoutPtr map[string]*StructWithoutPtrs
|
||||
SlicesWithPtrs map[string][]*StructWithPtrs
|
||||
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
|
||||
StructWithoutPtrKey map[StructWithoutPtrs]int
|
||||
SliceIntPtr map[string][]*int
|
||||
PointerKey map[*string]int
|
||||
StructWithPtrKey map[StructWithPtrs]int
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of StructWithSlices.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *StructWithSlices) Clone() *StructWithSlices {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(StructWithSlices)
|
||||
*dst = *src
|
||||
dst.Values = append(src.Values[:0:0], src.Values...)
|
||||
dst.ValuePointers = make([]*StructWithoutPtrs, len(src.ValuePointers))
|
||||
for i := range dst.ValuePointers {
|
||||
dst.ValuePointers[i] = src.ValuePointers[i].Clone()
|
||||
}
|
||||
dst.StructPointers = make([]*StructWithPtrs, len(src.StructPointers))
|
||||
for i := range dst.StructPointers {
|
||||
dst.StructPointers[i] = src.StructPointers[i].Clone()
|
||||
}
|
||||
dst.Structs = make([]StructWithPtrs, len(src.Structs))
|
||||
for i := range dst.Structs {
|
||||
dst.Structs[i] = *src.Structs[i].Clone()
|
||||
}
|
||||
dst.Ints = make([]*int, len(src.Ints))
|
||||
for i := range dst.Ints {
|
||||
x := *src.Ints[i]
|
||||
dst.Ints[i] = &x
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _StructWithSlicesCloneNeedsRegeneration = StructWithSlices(struct {
|
||||
Values []StructWithoutPtrs
|
||||
ValuePointers []*StructWithoutPtrs
|
||||
StructPointers []*StructWithPtrs
|
||||
Structs []StructWithPtrs
|
||||
Ints []*int
|
||||
Slice []string
|
||||
Prefixes []netaddr.IPPrefix
|
||||
Data []byte
|
||||
}{})
|
||||
316
cmd/viewer/tests/tests_view.go
Normal file
316
cmd/viewer/tests/tests_view.go
Normal file
@@ -0,0 +1,316 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Code generated by tailscale/cmd/viewer; DO NOT EDIT.
|
||||
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"go4.org/mem"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices
|
||||
|
||||
// View returns a readonly view of StructWithPtrs.
|
||||
func (p *StructWithPtrs) View() StructWithPtrsView {
|
||||
return StructWithPtrsView{ж: p}
|
||||
}
|
||||
|
||||
// StructWithPtrsView provides a read-only view over StructWithPtrs.
|
||||
//
|
||||
// Its methods should only be called if `Valid()` returns true.
|
||||
type StructWithPtrsView 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.
|
||||
ж *StructWithPtrs
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v StructWithPtrsView) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v StructWithPtrsView) AsStruct() *StructWithPtrs {
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v StructWithPtrsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *StructWithPtrsView) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x StructWithPtrs
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж = &x
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v StructWithPtrsView) Value() *StructWithoutPtrs {
|
||||
if v.ж.Value == nil {
|
||||
return nil
|
||||
}
|
||||
x := *v.ж.Value
|
||||
return &x
|
||||
}
|
||||
|
||||
func (v StructWithPtrsView) Int() *int {
|
||||
if v.ж.Int == nil {
|
||||
return nil
|
||||
}
|
||||
x := *v.ж.Int
|
||||
return &x
|
||||
}
|
||||
|
||||
func (v StructWithPtrsView) NoCloneValue() *StructWithoutPtrs { return v.ж.NoCloneValue }
|
||||
func (v StructWithPtrsView) String() string { return v.ж.String() }
|
||||
func (v StructWithPtrsView) Equal(v2 StructWithPtrsView) bool { return v.ж.Equal(v2.ж) }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _StructWithPtrsViewNeedsRegeneration = StructWithPtrs(struct {
|
||||
Value *StructWithoutPtrs
|
||||
Int *int
|
||||
NoCloneValue *StructWithoutPtrs
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of StructWithoutPtrs.
|
||||
func (p *StructWithoutPtrs) View() StructWithoutPtrsView {
|
||||
return StructWithoutPtrsView{ж: p}
|
||||
}
|
||||
|
||||
// StructWithoutPtrsView provides a read-only view over StructWithoutPtrs.
|
||||
//
|
||||
// Its methods should only be called if `Valid()` returns true.
|
||||
type StructWithoutPtrsView 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.
|
||||
ж *StructWithoutPtrs
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v StructWithoutPtrsView) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v StructWithoutPtrsView) AsStruct() *StructWithoutPtrs {
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v StructWithoutPtrsView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *StructWithoutPtrsView) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x StructWithoutPtrs
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж = &x
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v StructWithoutPtrsView) Int() int { return v.ж.Int }
|
||||
func (v StructWithoutPtrsView) Pfx() netaddr.IPPrefix { return v.ж.Pfx }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _StructWithoutPtrsViewNeedsRegeneration = StructWithoutPtrs(struct {
|
||||
Int int
|
||||
Pfx netaddr.IPPrefix
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of Map.
|
||||
func (p *Map) View() MapView {
|
||||
return MapView{ж: p}
|
||||
}
|
||||
|
||||
// MapView provides a read-only view over Map.
|
||||
//
|
||||
// Its methods should only be called if `Valid()` returns true.
|
||||
type MapView 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.
|
||||
ж *Map
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v MapView) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v MapView) AsStruct() *Map {
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v MapView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *MapView) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x Map
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж = &x
|
||||
return nil
|
||||
}
|
||||
|
||||
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) StructWithPtr() views.MapFn[string, *StructWithPtrs, StructWithPtrsView] {
|
||||
return views.MapFnOf(v.ж.StructWithPtr, func(t *StructWithPtrs) StructWithPtrsView {
|
||||
return t.View()
|
||||
})
|
||||
}
|
||||
|
||||
func (v MapView) StructWithoutPtr() views.MapFn[string, *StructWithoutPtrs, StructWithoutPtrsView] {
|
||||
return views.MapFnOf(v.ж.StructWithoutPtr, func(t *StructWithoutPtrs) StructWithoutPtrsView {
|
||||
return t.View()
|
||||
})
|
||||
}
|
||||
|
||||
func (v MapView) SlicesWithPtrs() views.MapFn[string, []*StructWithPtrs, views.SliceView[*StructWithPtrs, StructWithPtrsView]] {
|
||||
return views.MapFnOf(v.ж.SlicesWithPtrs, func(t []*StructWithPtrs) views.SliceView[*StructWithPtrs, StructWithPtrsView] {
|
||||
return views.SliceOfViews[*StructWithPtrs, StructWithPtrsView](t)
|
||||
})
|
||||
}
|
||||
|
||||
func (v MapView) SlicesWithoutPtrs() views.MapFn[string, []*StructWithoutPtrs, views.SliceView[*StructWithoutPtrs, StructWithoutPtrsView]] {
|
||||
return views.MapFnOf(v.ж.SlicesWithoutPtrs, func(t []*StructWithoutPtrs) views.SliceView[*StructWithoutPtrs, StructWithoutPtrsView] {
|
||||
return views.SliceOfViews[*StructWithoutPtrs, StructWithoutPtrsView](t)
|
||||
})
|
||||
}
|
||||
|
||||
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") }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _MapViewNeedsRegeneration = Map(struct {
|
||||
Int map[string]int
|
||||
SliceInt map[string][]int
|
||||
StructWithPtr map[string]*StructWithPtrs
|
||||
StructWithoutPtr map[string]*StructWithoutPtrs
|
||||
SlicesWithPtrs map[string][]*StructWithPtrs
|
||||
SlicesWithoutPtrs map[string][]*StructWithoutPtrs
|
||||
StructWithoutPtrKey map[StructWithoutPtrs]int
|
||||
SliceIntPtr map[string][]*int
|
||||
PointerKey map[*string]int
|
||||
StructWithPtrKey map[StructWithPtrs]int
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of StructWithSlices.
|
||||
func (p *StructWithSlices) View() StructWithSlicesView {
|
||||
return StructWithSlicesView{ж: p}
|
||||
}
|
||||
|
||||
// StructWithSlicesView provides a read-only view over StructWithSlices.
|
||||
//
|
||||
// Its methods should only be called if `Valid()` returns true.
|
||||
type StructWithSlicesView 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.
|
||||
ж *StructWithSlices
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v StructWithSlicesView) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v StructWithSlicesView) AsStruct() *StructWithSlices {
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v StructWithSlicesView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *StructWithSlicesView) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x StructWithSlices
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж = &x
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v StructWithSlicesView) Values() views.Slice[StructWithoutPtrs] {
|
||||
return views.SliceOf(v.ж.Values)
|
||||
}
|
||||
func (v StructWithSlicesView) ValuePointers() views.SliceView[*StructWithoutPtrs, StructWithoutPtrsView] {
|
||||
return views.SliceOfViews[*StructWithoutPtrs, StructWithoutPtrsView](v.ж.ValuePointers)
|
||||
}
|
||||
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.IPPrefixSlice {
|
||||
return views.IPPrefixSliceOf(v.ж.Prefixes)
|
||||
}
|
||||
func (v StructWithSlicesView) Data() mem.RO { return mem.B(v.ж.Data) }
|
||||
|
||||
// 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 []netaddr.IPPrefix
|
||||
Data []byte
|
||||
}{})
|
||||
378
cmd/viewer/viewer.go
Normal file
378
cmd/viewer/viewer.go
Normal file
@@ -0,0 +1,378 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Viewer is a tool to automate the creation of "view" wrapper types that
|
||||
// provide read-only accessor methods to underlying fields.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/types"
|
||||
"html/template"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/codegen"
|
||||
)
|
||||
|
||||
const viewTemplateStr = `{{define "common"}}
|
||||
// View returns a readonly view of {{.StructName}}.
|
||||
func (p *{{.StructName}}) View() {{.ViewName}} {
|
||||
return {{.ViewName}}{ж: p}
|
||||
}
|
||||
|
||||
// {{.ViewName}} provides a read-only view over {{.StructName}}.
|
||||
//
|
||||
// Its methods should only be called if ` + "`Valid()`" + ` returns true.
|
||||
type {{.ViewName}} 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}}
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v {{.ViewName}}) 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}}{
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v {{.ViewName}}) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *{{.ViewName}}) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x {{.StructName}}
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж=&x
|
||||
return nil
|
||||
}
|
||||
|
||||
{{end}}
|
||||
{{define "valueField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} { return v.ж.{{.FieldName}} }
|
||||
{{end}}
|
||||
{{define "byteSliceField"}}func (v {{.ViewName}}) {{.FieldName}}() mem.RO { return mem.B(v.ж.{{.FieldName}}) }
|
||||
{{end}}
|
||||
{{define "ipPrefixSliceField"}}func (v {{.ViewName}}) {{.FieldName}}() views.IPPrefixSlice { return views.IPPrefixSliceOf(v.ж.{{.FieldName}}) }
|
||||
{{end}}
|
||||
{{define "sliceField"}}func (v {{.ViewName}}) {{.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}}) }
|
||||
{{end}}
|
||||
{{define "viewField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}}View { return v.ж.{{.FieldName}}.View() }
|
||||
{{end}}
|
||||
{{define "valuePointerField"}}func (v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} {
|
||||
if v.ж.{{.FieldName}} == nil {
|
||||
return nil
|
||||
}
|
||||
x := *v.ж.{{.FieldName}}
|
||||
return &x
|
||||
}
|
||||
|
||||
{{end}}
|
||||
{{define "mapField"}}
|
||||
func(v {{.ViewName}}) {{.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}} {
|
||||
return {{.MapFn}}
|
||||
})}
|
||||
{{end}}
|
||||
{{define "unsupportedField"}}func(v {{.ViewName}}) {{.FieldName}}() {{.FieldType}} {panic("unsupported")}
|
||||
{{end}}
|
||||
{{define "stringFunc"}}func(v {{.ViewName}}) String() string { return v.ж.String() }
|
||||
{{end}}
|
||||
{{define "equalFunc"}}func(v {{.ViewName}}) Equal(v2 {{.ViewName}}) bool { return v.ж.Equal(v2.ж) }
|
||||
{{end}}
|
||||
`
|
||||
|
||||
var viewTemplate *template.Template
|
||||
|
||||
func init() {
|
||||
viewTemplate = template.Must(template.New("view").Parse(viewTemplateStr))
|
||||
}
|
||||
|
||||
func requiresCloning(t types.Type) (shallow, deep bool, base types.Type) {
|
||||
switch v := t.(type) {
|
||||
case *types.Pointer:
|
||||
_, deep, base = requiresCloning(v.Elem())
|
||||
return true, deep, base
|
||||
case *types.Slice:
|
||||
_, deep, base = requiresCloning(v.Elem())
|
||||
return true, deep, base
|
||||
}
|
||||
p := codegen.ContainsPointers(t)
|
||||
return p, p, t
|
||||
}
|
||||
|
||||
func genView(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named, thisPkg *types.Package) {
|
||||
t, ok := typ.Underlying().(*types.Struct)
|
||||
if !ok || codegen.IsViewType(t) {
|
||||
return
|
||||
}
|
||||
it.Import("encoding/json")
|
||||
it.Import("errors")
|
||||
|
||||
args := struct {
|
||||
StructName string
|
||||
ViewName string
|
||||
FieldName string
|
||||
FieldType string
|
||||
FieldViewName string
|
||||
|
||||
MapKeyType string
|
||||
MapValueType string
|
||||
MapValueView string
|
||||
MapFn string
|
||||
}{
|
||||
StructName: typ.Obj().Name(),
|
||||
ViewName: typ.Obj().Name() + "View",
|
||||
}
|
||||
|
||||
writeTemplate := func(name string) {
|
||||
if err := viewTemplate.ExecuteTemplate(buf, name, args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeTemplate("common")
|
||||
for i := 0; i < t.NumFields(); i++ {
|
||||
f := t.Field(i)
|
||||
fname := f.Name()
|
||||
if !f.Exported() {
|
||||
continue
|
||||
}
|
||||
args.FieldName = fname
|
||||
fieldType := f.Type()
|
||||
if codegen.IsInvalid(fieldType) {
|
||||
continue
|
||||
}
|
||||
if !codegen.ContainsPointers(fieldType) || codegen.IsViewType(fieldType) || codegen.HasNoClone(t.Tag(i)) {
|
||||
args.FieldType = it.QualifiedName(fieldType)
|
||||
writeTemplate("valueField")
|
||||
continue
|
||||
}
|
||||
switch underlying := fieldType.Underlying().(type) {
|
||||
case *types.Slice:
|
||||
slice := underlying
|
||||
elem := slice.Elem()
|
||||
args.FieldType = it.QualifiedName(elem)
|
||||
switch elem.String() {
|
||||
case "byte":
|
||||
it.Import("go4.org/mem")
|
||||
writeTemplate("byteSliceField")
|
||||
case "inet.af/netaddr.IPPrefix":
|
||||
it.Import("tailscale.com/types/views")
|
||||
writeTemplate("ipPrefixSliceField")
|
||||
default:
|
||||
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")
|
||||
}
|
||||
continue
|
||||
} else if shallow {
|
||||
if _, isBasic := base.(*types.Basic); isBasic {
|
||||
writeTemplate("unsupportedField")
|
||||
} else {
|
||||
args.FieldViewName = it.QualifiedName(base) + "View"
|
||||
writeTemplate("viewSliceField")
|
||||
}
|
||||
continue
|
||||
}
|
||||
writeTemplate("sliceField")
|
||||
}
|
||||
continue
|
||||
case *types.Struct, *types.Named:
|
||||
strucT := underlying
|
||||
args.FieldType = it.QualifiedName(fieldType)
|
||||
if codegen.ContainsPointers(strucT) {
|
||||
writeTemplate("viewField")
|
||||
continue
|
||||
}
|
||||
writeTemplate("valueField")
|
||||
continue
|
||||
case *types.Map:
|
||||
m := underlying
|
||||
args.FieldType = it.QualifiedName(fieldType)
|
||||
shallow, deep, key := requiresCloning(m.Key())
|
||||
if shallow || deep {
|
||||
writeTemplate("unsupportedField")
|
||||
continue
|
||||
}
|
||||
args.MapKeyType = it.QualifiedName(key)
|
||||
mElem := m.Elem()
|
||||
var template string
|
||||
switch u := mElem.(type) {
|
||||
case *types.Basic:
|
||||
template = "mapField"
|
||||
args.MapValueType = it.QualifiedName(mElem)
|
||||
case *types.Slice:
|
||||
slice := u
|
||||
sElem := slice.Elem()
|
||||
switch x := sElem.(type) {
|
||||
case *types.Basic:
|
||||
args.MapValueView = fmt.Sprintf("views.Slice[%v]", sElem)
|
||||
args.MapValueType = "[]" + sElem.String()
|
||||
args.MapFn = "views.SliceOf(t)"
|
||||
template = "mapFnField"
|
||||
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"
|
||||
}
|
||||
default:
|
||||
template = "unsupportedField"
|
||||
}
|
||||
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"
|
||||
args.MapFn = "t.View()"
|
||||
template = "mapFnField"
|
||||
default:
|
||||
template = "unsupportedField"
|
||||
}
|
||||
default:
|
||||
template = "unsupportedField"
|
||||
}
|
||||
writeTemplate(template)
|
||||
continue
|
||||
case *types.Pointer:
|
||||
ptr := underlying
|
||||
_, deep, base := requiresCloning(ptr)
|
||||
if deep {
|
||||
args.FieldType = it.QualifiedName(base)
|
||||
writeTemplate("viewField")
|
||||
} else {
|
||||
args.FieldType = it.QualifiedName(ptr)
|
||||
writeTemplate("valuePointerField")
|
||||
}
|
||||
continue
|
||||
}
|
||||
writeTemplate("unsupportedField")
|
||||
}
|
||||
for i := 0; i < typ.NumMethods(); i++ {
|
||||
f := typ.Method(i)
|
||||
if !f.Exported() {
|
||||
continue
|
||||
}
|
||||
sig, ok := f.Type().(*types.Signature)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch f.Name() {
|
||||
case "Clone", "View":
|
||||
continue // "AsStruct"
|
||||
case "String":
|
||||
writeTemplate("stringFunc")
|
||||
continue
|
||||
case "Equal":
|
||||
if sig.Results().Len() == 1 && sig.Results().At(0).Type().String() == "bool" {
|
||||
writeTemplate("equalFunc")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(buf, "\n")
|
||||
buf.Write(codegen.AssertStructUnchanged(t, args.StructName, "View", it))
|
||||
}
|
||||
|
||||
var (
|
||||
flagTypes = flag.String("type", "", "comma-separated list of types; required")
|
||||
flagBuildTags = flag.String("tags", "", "compiler build tags to apply")
|
||||
flagCloneFunc = flag.Bool("clonefunc", false, "add a top-level Clone func")
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("viewer: ")
|
||||
flag.Parse()
|
||||
if len(*flagTypes) == 0 {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
typeNames := strings.Split(*flagTypes, ",")
|
||||
|
||||
var flagArgs []string
|
||||
flagArgs = append(flagArgs, fmt.Sprintf("-clonefunc=%v", *flagCloneFunc))
|
||||
if *flagTypes != "" {
|
||||
flagArgs = append(flagArgs, "-type="+*flagTypes)
|
||||
}
|
||||
if *flagBuildTags != "" {
|
||||
flagArgs = append(flagArgs, "-tags="+*flagBuildTags)
|
||||
}
|
||||
pkg, namedTypes, err := codegen.LoadTypes(*flagBuildTags, ".")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
it := codegen.NewImportTracker(pkg.Types)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
fmt.Fprintf(buf, `//go:generate go run tailscale.com/cmd/cloner %s`, strings.Join(flagArgs, " "))
|
||||
fmt.Fprintln(buf)
|
||||
runCloner := false
|
||||
for _, typeName := range typeNames {
|
||||
typ, ok := namedTypes[typeName]
|
||||
if !ok {
|
||||
log.Fatalf("could not find type %s", typeName)
|
||||
}
|
||||
var hasClone bool
|
||||
for i, n := 0, typ.NumMethods(); i < n; i++ {
|
||||
if typ.Method(i).Name() == "Clone" {
|
||||
hasClone = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasClone {
|
||||
runCloner = true
|
||||
}
|
||||
genView(buf, it, typ, pkg.Types)
|
||||
}
|
||||
out := pkg.Name + "_view.go"
|
||||
if err := codegen.WritePackageFile("tailscale/cmd/viewer", pkg, out, it, buf); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if runCloner {
|
||||
// When a new pacakge is added or when existing generated files have
|
||||
// been deleted, we might run into a case where tailscale.com/cmd/cloner
|
||||
// has not run yet. We detect this by verifying that all the structs we
|
||||
// interacted with have had Clone method already generated. If they
|
||||
// haven't we ask the caller to rerun generation again so that those get
|
||||
// generated.
|
||||
log.Printf("%v requires regeneration. Please run go generate again", pkg.Name+"_clone.go")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package controlclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
@@ -41,30 +42,31 @@ func (g *LoginGoal) sendLogoutError(err error) {
|
||||
}
|
||||
}
|
||||
|
||||
var _ Client = (*Auto)(nil)
|
||||
|
||||
// Auto connects to a tailcontrol server for a node.
|
||||
// It's a concrete implementation of the Client interface.
|
||||
type Auto struct {
|
||||
direct *Direct // our interface to the server APIs
|
||||
timeNow func() time.Time
|
||||
logf logger.Logf
|
||||
expiry *time.Time
|
||||
closed bool
|
||||
newMapCh chan struct{} // readable when we must restart a map request
|
||||
direct *Direct // our interface to the server APIs
|
||||
timeNow func() time.Time
|
||||
logf logger.Logf
|
||||
expiry *time.Time
|
||||
closed bool
|
||||
newMapCh chan struct{} // readable when we must restart a map request
|
||||
statusFunc func(Status) // called to update Client status; always non-nil
|
||||
|
||||
unregisterHealthWatch func()
|
||||
|
||||
mu sync.Mutex // mutex guards the following fields
|
||||
statusFunc func(Status) // called to update Client status
|
||||
mu sync.Mutex // mutex guards the following fields
|
||||
|
||||
paused bool // whether we should stop making HTTP requests
|
||||
unpauseWaiters []chan struct{}
|
||||
loggedIn bool // true if currently logged in
|
||||
loginGoal *LoginGoal // non-nil if some login activity is desired
|
||||
synced bool // true if our netmap is up-to-date
|
||||
hostinfo *tailcfg.Hostinfo
|
||||
inPollNetMap bool // true if currently running a PollNetMap
|
||||
inLiteMapUpdate bool // true if a lite (non-streaming) map request is outstanding
|
||||
inSendStatus int // number of sendStatus calls currently in progress
|
||||
inPollNetMap bool // true if currently running a PollNetMap
|
||||
inLiteMapUpdate bool // true if a lite (non-streaming) map request is outstanding
|
||||
inSendStatus int // number of sendStatus calls currently in progress
|
||||
state State
|
||||
|
||||
authCtx context.Context // context used for auth requests
|
||||
@@ -91,6 +93,9 @@ func NewNoStart(opts Options) (*Auto, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.Status == nil {
|
||||
return nil, errors.New("missing required Options.Status")
|
||||
}
|
||||
if opts.Logf == nil {
|
||||
opts.Logf = func(fmt string, args ...any) {}
|
||||
}
|
||||
@@ -98,13 +103,14 @@ func NewNoStart(opts Options) (*Auto, error) {
|
||||
opts.TimeNow = time.Now
|
||||
}
|
||||
c := &Auto{
|
||||
direct: direct,
|
||||
timeNow: opts.TimeNow,
|
||||
logf: opts.Logf,
|
||||
newMapCh: make(chan struct{}, 1),
|
||||
quit: make(chan struct{}),
|
||||
authDone: make(chan struct{}),
|
||||
mapDone: make(chan struct{}),
|
||||
direct: direct,
|
||||
timeNow: opts.TimeNow,
|
||||
logf: opts.Logf,
|
||||
newMapCh: make(chan struct{}, 1),
|
||||
quit: make(chan struct{}),
|
||||
authDone: make(chan struct{}),
|
||||
mapDone: make(chan struct{}),
|
||||
statusFunc: opts.Status,
|
||||
}
|
||||
c.authCtx, c.authCancel = context.WithCancel(context.Background())
|
||||
c.mapCtx, c.mapCancel = context.WithCancel(context.Background())
|
||||
@@ -290,6 +296,7 @@ func (c *Auto) authRoutine() {
|
||||
}
|
||||
|
||||
if goal == nil {
|
||||
health.SetAuthRoutineInError(nil)
|
||||
// Wait for user to Login or Logout.
|
||||
<-ctx.Done()
|
||||
c.logf("[v1] authRoutine: context done.")
|
||||
@@ -297,6 +304,7 @@ func (c *Auto) authRoutine() {
|
||||
}
|
||||
|
||||
if !goal.wantLoggedIn {
|
||||
health.SetAuthRoutineInError(nil)
|
||||
err := c.direct.TryLogout(ctx)
|
||||
goal.sendLogoutError(err)
|
||||
if err != nil {
|
||||
@@ -335,6 +343,7 @@ func (c *Auto) authRoutine() {
|
||||
f = "TryLogin"
|
||||
}
|
||||
if err != nil {
|
||||
health.SetAuthRoutineInError(err)
|
||||
report(err, f)
|
||||
bo.BackOff(ctx, err)
|
||||
continue
|
||||
@@ -359,6 +368,7 @@ func (c *Auto) authRoutine() {
|
||||
}
|
||||
|
||||
// success
|
||||
health.SetAuthRoutineInError(nil)
|
||||
c.mu.Lock()
|
||||
c.loggedIn = true
|
||||
c.loginGoal = nil
|
||||
@@ -459,7 +469,7 @@ func (c *Auto) mapRoutine() {
|
||||
c.mu.Unlock()
|
||||
health.SetInPollNetMap(false)
|
||||
|
||||
err := c.direct.PollNetMap(ctx, -1, func(nm *netmap.NetworkMap) {
|
||||
err := c.direct.PollNetMap(ctx, func(nm *netmap.NetworkMap) {
|
||||
health.SetInPollNetMap(true)
|
||||
c.mu.Lock()
|
||||
|
||||
@@ -528,13 +538,6 @@ func (c *Auto) AuthCantContinue() bool {
|
||||
return !c.loggedIn && (c.loginGoal == nil || c.loginGoal.url != "")
|
||||
}
|
||||
|
||||
// SetStatusFunc sets fn as the callback to run on any status change.
|
||||
func (c *Auto) SetStatusFunc(fn func(Status)) {
|
||||
c.mu.Lock()
|
||||
c.statusFunc = fn
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Auto) SetHostinfo(hi *tailcfg.Hostinfo) {
|
||||
if hi == nil {
|
||||
panic("nil Hostinfo")
|
||||
@@ -555,19 +558,20 @@ func (c *Auto) SetNetInfo(ni *tailcfg.NetInfo) {
|
||||
if !c.direct.SetNetInfo(ni) {
|
||||
return
|
||||
}
|
||||
c.logf("NetInfo: %v", ni)
|
||||
|
||||
// Send new Hostinfo (which includes NetInfo) to server
|
||||
// Send new NetInfo to server
|
||||
c.sendNewMapRequest()
|
||||
}
|
||||
|
||||
func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkMap) {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
state := c.state
|
||||
loggedIn := c.loggedIn
|
||||
synced := c.synced
|
||||
statusFunc := c.statusFunc
|
||||
hi := c.hostinfo
|
||||
c.inSendStatus++
|
||||
c.mu.Unlock()
|
||||
|
||||
@@ -595,13 +599,10 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
|
||||
URL: url,
|
||||
Persist: p,
|
||||
NetMap: nm,
|
||||
Hostinfo: hi,
|
||||
State: state,
|
||||
Err: err,
|
||||
}
|
||||
if statusFunc != nil {
|
||||
statusFunc(new)
|
||||
}
|
||||
c.statusFunc(new)
|
||||
|
||||
c.mu.Lock()
|
||||
c.inSendStatus--
|
||||
@@ -666,11 +667,8 @@ func (c *Auto) SetExpirySooner(ctx context.Context, expiry time.Time) error {
|
||||
// them to the control server if they've changed.
|
||||
//
|
||||
// It does not retain the provided slice.
|
||||
//
|
||||
// The localPort field is unused except for integration tests in
|
||||
// another repo.
|
||||
func (c *Auto) UpdateEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) {
|
||||
changed := c.direct.SetEndpoints(localPort, endpoints)
|
||||
func (c *Auto) UpdateEndpoints(endpoints []tailcfg.Endpoint) {
|
||||
changed := c.direct.SetEndpoints(endpoints)
|
||||
if changed {
|
||||
c.sendNewMapRequest()
|
||||
}
|
||||
@@ -685,7 +683,6 @@ func (c *Auto) Shutdown() {
|
||||
direct := c.direct
|
||||
if !closed {
|
||||
c.closed = true
|
||||
c.statusFunc = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ package controlclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
@@ -29,9 +27,6 @@ const (
|
||||
// Currently this is done through a pair of polling https requests in
|
||||
// the Auto client, but that might change eventually.
|
||||
type Client interface {
|
||||
// SetStatusFunc provides a callback to call when control sends us
|
||||
// a message.
|
||||
SetStatusFunc(func(Status))
|
||||
// Shutdown closes this session, which should not be used any further
|
||||
// afterwards.
|
||||
Shutdown()
|
||||
@@ -47,9 +42,6 @@ type Client interface {
|
||||
// Logout starts a synchronous logout process. It doesn't return
|
||||
// until the logout operation has been completed.
|
||||
Logout(context.Context) error
|
||||
// SetExpirySooner sets the node's expiry time via the controlclient,
|
||||
// as long as it's shorter than the current expiry time.
|
||||
SetExpirySooner(context.Context, time.Time) error
|
||||
// SetPaused pauses or unpauses the controlclient activity as much
|
||||
// as possible, without losing its internal state, to minimize
|
||||
// unnecessary network activity.
|
||||
@@ -75,17 +67,10 @@ type Client interface {
|
||||
SetNetInfo(*tailcfg.NetInfo)
|
||||
// UpdateEndpoints changes the Endpoint structure that will be sent
|
||||
// in subsequent node registration requests.
|
||||
// The localPort field is unused except for integration tests in another repo.
|
||||
// TODO: a server-side change would let us simply upload this
|
||||
// in a separate http request. It has nothing to do with the rest of
|
||||
// the state machine.
|
||||
UpdateEndpoints(localPort uint16, endpoints []tailcfg.Endpoint)
|
||||
// SetDNS sends the SetDNSRequest request to the control plane server,
|
||||
// requesting a DNS record be created or updated.
|
||||
SetDNS(context.Context, *tailcfg.SetDNSRequest) error
|
||||
// DoNoiseRequest sends an HTTP request to the control plane
|
||||
// over the Noise transport.
|
||||
DoNoiseRequest(*http.Request) (*http.Response, error)
|
||||
UpdateEndpoints(endpoints []tailcfg.Endpoint)
|
||||
}
|
||||
|
||||
// UserVisibleError is an error that should be shown to users.
|
||||
|
||||
@@ -22,7 +22,7 @@ func fieldsOf(t reflect.Type) (fields []string) {
|
||||
|
||||
func TestStatusEqual(t *testing.T) {
|
||||
// Verify that the Equal method stays in sync with reality
|
||||
equalHandles := []string{"LoginFinished", "LogoutFinished", "Err", "URL", "NetMap", "State", "Persist", "Hostinfo"}
|
||||
equalHandles := []string{"LoginFinished", "LogoutFinished", "Err", "URL", "NetMap", "State", "Persist"}
|
||||
if have := fieldsOf(reflect.TypeOf(Status{})); !reflect.DeepEqual(have, equalHandles) {
|
||||
t.Errorf("Status.Equal check might be out of sync\nfields: %q\nhandled: %q\n",
|
||||
have, equalHandles)
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"time"
|
||||
|
||||
"go4.org/mem"
|
||||
"golang.org/x/sync/singleflight"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/control/controlknobs"
|
||||
"tailscale.com/envknob"
|
||||
@@ -38,9 +37,9 @@ import (
|
||||
"tailscale.com/net/dnscache"
|
||||
"tailscale.com/net/dnsfallback"
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/tlsdial"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/net/tshttpproxy"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
@@ -50,6 +49,7 @@ import (
|
||||
"tailscale.com/types/persist"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/multierr"
|
||||
"tailscale.com/util/singleflight"
|
||||
"tailscale.com/util/systemd"
|
||||
"tailscale.com/wgengine/monitor"
|
||||
)
|
||||
@@ -57,7 +57,8 @@ 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
|
||||
serverURL string // URL of the tailcontrol server
|
||||
dialer *tsdial.Dialer
|
||||
serverURL string // URL of the tailcontrol server
|
||||
timeNow func() time.Time
|
||||
lastPrintMap time.Time
|
||||
newDecompressor func() (Decompressor, error)
|
||||
@@ -76,18 +77,17 @@ type Direct struct {
|
||||
serverKey key.MachinePublic // original ("legacy") nacl crypto_box-based public key
|
||||
serverNoiseKey key.MachinePublic
|
||||
|
||||
sfGroup singleflight.Group // protects noiseClient creation.
|
||||
sfGroup singleflight.Group[struct{}, *noiseClient] // protects noiseClient creation.
|
||||
noiseClient *noiseClient
|
||||
|
||||
persist persist.Persist
|
||||
authKey string
|
||||
tryingNewKey key.NodePrivate
|
||||
expiry *time.Time
|
||||
// hostinfo is mutated in-place while mu is held.
|
||||
persist persist.Persist
|
||||
authKey string
|
||||
tryingNewKey key.NodePrivate
|
||||
expiry *time.Time
|
||||
hostinfo *tailcfg.Hostinfo // always non-nil
|
||||
netinfo *tailcfg.NetInfo
|
||||
endpoints []tailcfg.Endpoint
|
||||
everEndpoints bool // whether we've ever had non-empty endpoints
|
||||
localPort uint16 // or zero to mean auto
|
||||
lastPingURL string // last PingRequest.URL received, for dup suppression
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ type Options struct {
|
||||
DebugFlags []string // debug settings to send to control
|
||||
LinkMonitor *monitor.Mon // optional link monitor
|
||||
PopBrowserURL func(url string) // optional func to open browser
|
||||
Dialer *tsdial.Dialer // non-nil
|
||||
|
||||
// Status is called when there's a change in status.
|
||||
Status func(Status)
|
||||
|
||||
// KeepSharerAndUserSplit controls whether the client
|
||||
// understands Node.Sharer. If false, the Sharer is mapped to the User.
|
||||
@@ -122,11 +126,10 @@ type Options struct {
|
||||
Pinger Pinger
|
||||
}
|
||||
|
||||
// Pinger is a subset of the wgengine.Engine interface, containing just the Ping method.
|
||||
// Pinger is the LocalBackend.Ping method.
|
||||
type Pinger interface {
|
||||
// Ping is a request to start a discovery or TSMP ping with the peer handling
|
||||
// the given IP and then call cb with its ping latency & method.
|
||||
Ping(ip netaddr.IP, useTSMP bool, cb func(*ipnstate.PingResult))
|
||||
// Ping is a request to do a ping with the peer handling the given IP.
|
||||
Ping(ctx context.Context, ip netaddr.IP, pingType tailcfg.PingType) (*ipnstate.PingResult, error)
|
||||
}
|
||||
|
||||
type Decompressor interface {
|
||||
@@ -170,13 +173,12 @@ func NewDirect(opts Options) (*Direct, error) {
|
||||
UseLastGood: true,
|
||||
LookupIPFallback: dnsfallback.Lookup,
|
||||
}
|
||||
dialer := netns.NewDialer(opts.Logf)
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.Proxy = tshttpproxy.ProxyFromEnvironment
|
||||
tshttpproxy.SetTransportGetProxyConnectHeader(tr)
|
||||
tr.TLSClientConfig = tlsdial.Config(serverURL.Hostname(), tr.TLSClientConfig)
|
||||
tr.DialContext = dnscache.Dialer(dialer.DialContext, dnsCache)
|
||||
tr.DialTLSContext = dnscache.TLSDialer(dialer.DialContext, dnsCache, tr.TLSClientConfig)
|
||||
tr.DialContext = dnscache.Dialer(opts.Dialer.SystemDial, dnsCache)
|
||||
tr.DialTLSContext = dnscache.TLSDialer(opts.Dialer.SystemDial, dnsCache, tr.TLSClientConfig)
|
||||
tr.ForceAttemptHTTP2 = true
|
||||
// Disable implicit gzip compression; the various
|
||||
// handlers (register, map, set-dns, etc) do their own
|
||||
@@ -202,11 +204,17 @@ func NewDirect(opts Options) (*Direct, error) {
|
||||
skipIPForwardingCheck: opts.SkipIPForwardingCheck,
|
||||
pinger: opts.Pinger,
|
||||
popBrowser: opts.PopBrowserURL,
|
||||
dialer: opts.Dialer,
|
||||
}
|
||||
if opts.Hostinfo == nil {
|
||||
c.SetHostinfo(hostinfo.New())
|
||||
} else {
|
||||
ni := opts.Hostinfo.NetInfo
|
||||
opts.Hostinfo.NetInfo = nil
|
||||
c.SetHostinfo(opts.Hostinfo)
|
||||
if ni != nil {
|
||||
c.SetNetInfo(ni)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -251,14 +259,11 @@ func (c *Direct) SetNetInfo(ni *tailcfg.NetInfo) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.hostinfo == nil {
|
||||
c.logf("[unexpected] SetNetInfo called with no HostInfo; ignoring NetInfo update: %+v", ni)
|
||||
if reflect.DeepEqual(ni, c.netinfo) {
|
||||
return false
|
||||
}
|
||||
if reflect.DeepEqual(ni, c.hostinfo.NetInfo) {
|
||||
return false
|
||||
}
|
||||
c.hostinfo.NetInfo = ni.Clone()
|
||||
c.netinfo = ni.Clone()
|
||||
c.logf("NetInfo: %v", ni)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -335,6 +340,14 @@ type httpClient interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// hostInfoLocked returns a Clone of c.hostinfo and c.netinfo.
|
||||
// It must only be called with c.mu held.
|
||||
func (c *Direct) hostInfoLocked() *tailcfg.Hostinfo {
|
||||
hi := c.hostinfo.Clone()
|
||||
hi.NetInfo = c.netinfo.Clone()
|
||||
return hi
|
||||
}
|
||||
|
||||
func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, newURL string, err error) {
|
||||
c.mu.Lock()
|
||||
persist := c.persist
|
||||
@@ -342,7 +355,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
serverKey := c.serverKey
|
||||
serverNoiseKey := c.serverNoiseKey
|
||||
authKey := c.authKey
|
||||
hi := c.hostinfo.Clone()
|
||||
hi := c.hostInfoLocked()
|
||||
backendLogID := hi.BackendLogID
|
||||
expired := c.expiry != nil && !c.expiry.IsZero() && c.expiry.Before(c.timeNow())
|
||||
c.mu.Unlock()
|
||||
@@ -376,7 +389,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
if err != nil {
|
||||
return regen, opt.URL, err
|
||||
}
|
||||
c.logf("control server key %s from %s", serverKey.ShortString(), c.serverURL)
|
||||
c.logf("control server key from %s: ts2021=%s, legacy=%v", c.serverURL, keys.PublicKey.ShortString(), keys.LegacyPublicKey.ShortString())
|
||||
|
||||
c.mu.Lock()
|
||||
c.serverKey = keys.LegacyPublicKey
|
||||
@@ -575,20 +588,19 @@ func sameEndpoints(a, b []tailcfg.Endpoint) bool {
|
||||
// whether they've changed.
|
||||
//
|
||||
// It does not retain the provided slice.
|
||||
func (c *Direct) newEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) (changed bool) {
|
||||
func (c *Direct) newEndpoints(endpoints []tailcfg.Endpoint) (changed bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Nothing new?
|
||||
if c.localPort == localPort && sameEndpoints(c.endpoints, endpoints) {
|
||||
if sameEndpoints(c.endpoints, endpoints) {
|
||||
return false // unchanged
|
||||
}
|
||||
var epStrs []string
|
||||
for _, ep := range endpoints {
|
||||
epStrs = append(epStrs, ep.Addr.String())
|
||||
}
|
||||
c.logf("[v2] client.newEndpoints(%v, %v)", localPort, epStrs)
|
||||
c.localPort = localPort
|
||||
c.logf("[v2] client.newEndpoints(%v)", epStrs)
|
||||
c.endpoints = append(c.endpoints[:0], endpoints...)
|
||||
if len(endpoints) > 0 {
|
||||
c.everEndpoints = true
|
||||
@@ -599,28 +611,37 @@ func (c *Direct) newEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) (c
|
||||
// SetEndpoints updates the list of locally advertised endpoints.
|
||||
// It won't be replicated to the server until a *fresh* call to PollNetMap().
|
||||
// You don't need to restart PollNetMap if we return changed==false.
|
||||
func (c *Direct) SetEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) (changed bool) {
|
||||
func (c *Direct) SetEndpoints(endpoints []tailcfg.Endpoint) (changed bool) {
|
||||
// (no log message on function entry, because it clutters the logs
|
||||
// if endpoints haven't changed. newEndpoints() will log it.)
|
||||
return c.newEndpoints(localPort, endpoints)
|
||||
return c.newEndpoints(endpoints)
|
||||
}
|
||||
|
||||
func inTest() bool { return flag.Lookup("test.v") != nil }
|
||||
|
||||
// PollNetMap makes a /map request to download the network map, calling cb with
|
||||
// each new netmap.
|
||||
//
|
||||
// maxPolls is how many network maps to download; common values are 1
|
||||
// or -1 (to keep a long-poll query open to the server).
|
||||
func (c *Direct) PollNetMap(ctx context.Context, maxPolls int, cb func(*netmap.NetworkMap)) error {
|
||||
return c.sendMapRequest(ctx, maxPolls, cb)
|
||||
func (c *Direct) PollNetMap(ctx context.Context, cb func(*netmap.NetworkMap)) error {
|
||||
return c.sendMapRequest(ctx, -1, false, cb)
|
||||
}
|
||||
|
||||
// FetchNetMap fetches the netmap once.
|
||||
func (c *Direct) FetchNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
|
||||
var ret *netmap.NetworkMap
|
||||
err := c.sendMapRequest(ctx, 1, false, func(nm *netmap.NetworkMap) {
|
||||
ret = nm
|
||||
})
|
||||
if err == nil && ret == nil {
|
||||
return nil, errors.New("[unexpected] sendMapRequest success without callback")
|
||||
}
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// SendLiteMapUpdate makes a /map request to update the server of our latest state,
|
||||
// but does not fetch anything. It returns an error if the server did not return a
|
||||
// successful 200 OK response.
|
||||
func (c *Direct) SendLiteMapUpdate(ctx context.Context) error {
|
||||
return c.sendMapRequest(ctx, 1, nil)
|
||||
return c.sendMapRequest(ctx, 1, false, nil)
|
||||
}
|
||||
|
||||
// If we go more than pollTimeout without hearing from the server,
|
||||
@@ -629,7 +650,7 @@ func (c *Direct) SendLiteMapUpdate(ctx context.Context) error {
|
||||
const pollTimeout = 120 * time.Second
|
||||
|
||||
// cb nil means to omit peers.
|
||||
func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netmap.NetworkMap)) error {
|
||||
func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool, cb func(*netmap.NetworkMap)) error {
|
||||
metricMapRequests.Add(1)
|
||||
metricMapRequestsActive.Add(1)
|
||||
defer metricMapRequestsActive.Add(-1)
|
||||
@@ -644,9 +665,8 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
|
||||
serverURL := c.serverURL
|
||||
serverKey := c.serverKey
|
||||
serverNoiseKey := c.serverNoiseKey
|
||||
hi := c.hostinfo.Clone()
|
||||
hi := c.hostInfoLocked()
|
||||
backendLogID := hi.BackendLogID
|
||||
localPort := c.localPort
|
||||
var epStrs []string
|
||||
var epTypes []tailcfg.EndpointType
|
||||
for _, ep := range c.endpoints {
|
||||
@@ -672,7 +692,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
|
||||
}
|
||||
|
||||
allowStream := maxPolls != 1
|
||||
c.logf("[v1] PollNetMap: stream=%v :%v ep=%v", allowStream, localPort, epStrs)
|
||||
c.logf("[v1] PollNetMap: stream=%v ep=%v", allowStream, epStrs)
|
||||
|
||||
vlogf := logger.Discard
|
||||
if Debug.NetMap {
|
||||
@@ -692,6 +712,16 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
|
||||
Hostinfo: hi,
|
||||
DebugFlags: c.debugFlags,
|
||||
OmitPeers: cb == nil,
|
||||
|
||||
// On initial startup before we know our endpoints, set the ReadOnly flag
|
||||
// to tell the control server not to distribute out our (empty) endpoints to peers.
|
||||
// Presumably we'll learn our endpoints in a half second and do another post
|
||||
// with useful results. The first POST just gets us the DERP map which we
|
||||
// need to do the STUN queries to discover our endpoints.
|
||||
// TODO(bradfitz): we skip this optimization in tests, though,
|
||||
// because the e2e tests are currently hyperspecific about the
|
||||
// ordering of things. The e2e tests need love.
|
||||
ReadOnly: readOnly || (len(epStrs) == 0 && !everEndpoints && !inTest()),
|
||||
}
|
||||
var extraDebugFlags []string
|
||||
if hi != nil && c.linkMon != nil && !c.skipIPForwardingCheck &&
|
||||
@@ -714,17 +744,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
|
||||
if c.newDecompressor != nil {
|
||||
request.Compress = "zstd"
|
||||
}
|
||||
// On initial startup before we know our endpoints, set the ReadOnly flag
|
||||
// to tell the control server not to distribute out our (empty) endpoints to peers.
|
||||
// Presumably we'll learn our endpoints in a half second and do another post
|
||||
// with useful results. The first POST just gets us the DERP map which we
|
||||
// need to do the STUN queries to discover our endpoints.
|
||||
// TODO(bradfitz): we skip this optimization in tests, though,
|
||||
// because the e2e tests are currently hyperspecific about the
|
||||
// ordering of things. The e2e tests need love.
|
||||
if len(epStrs) == 0 && !everEndpoints && !inTest() {
|
||||
request.ReadOnly = true
|
||||
}
|
||||
|
||||
bodyData, err := encode(request, serverKey, serverNoiseKey, machinePrivKey)
|
||||
if err != nil {
|
||||
@@ -929,14 +948,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
|
||||
nm.SelfNode.Capabilities = nil
|
||||
}
|
||||
|
||||
// Get latest localPort. This might've changed if
|
||||
// a lite map update occurred meanwhile. This only affects
|
||||
// the end-to-end test.
|
||||
// TODO(bradfitz): remove the NetworkMap.LocalPort field entirely.
|
||||
c.mu.Lock()
|
||||
nm.LocalPort = c.localPort
|
||||
c.mu.Unlock()
|
||||
|
||||
// Occasionally print the netmap header.
|
||||
// This is handy for debugging, and our logs processing
|
||||
// pipeline depends on it. (TODO: Remove this dependency.)
|
||||
@@ -1195,12 +1206,9 @@ func answerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pinge
|
||||
return
|
||||
}
|
||||
for _, t := range strings.Split(pr.Types, ",") {
|
||||
switch t {
|
||||
case "TSMP", "disco":
|
||||
go doPingerPing(logf, c, pr, pinger, t)
|
||||
// TODO(tailscale/corp#754)
|
||||
// case "host":
|
||||
// case "peerapi":
|
||||
switch pt := tailcfg.PingType(t); pt {
|
||||
case tailcfg.PingTSMP, tailcfg.PingDisco, tailcfg.PingICMP, tailcfg.PingPeerAPI:
|
||||
go doPingerPing(logf, c, pr, pinger, pt)
|
||||
default:
|
||||
logf("unsupported ping request type: %q", t)
|
||||
}
|
||||
@@ -1272,13 +1280,12 @@ func (c *Direct) getNoiseClient() (*noiseClient, error) {
|
||||
if nc != nil {
|
||||
return nc, nil
|
||||
}
|
||||
np, err, _ := c.sfGroup.Do("noise", func() (any, error) {
|
||||
nc, err, _ := c.sfGroup.Do(struct{}{}, func() (*noiseClient, error) {
|
||||
k, err := c.getMachinePrivKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nc, err = newNoiseClient(k, serverNoiseKey, c.serverURL)
|
||||
nc, err := newNoiseClient(k, serverNoiseKey, c.serverURL, c.dialer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1290,7 +1297,7 @@ func (c *Direct) getNoiseClient() (*noiseClient, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return np.(*noiseClient), nil
|
||||
return nc, nil
|
||||
}
|
||||
|
||||
// setDNSNoise sends the SetDNSRequest request to the control plane server over Noise,
|
||||
@@ -1400,16 +1407,23 @@ func (c *Direct) DoNoiseRequest(req *http.Request) (*http.Response, error) {
|
||||
|
||||
// doPingerPing sends a Ping to pr.IP using pinger, and sends an http request back to
|
||||
// pr.URL with ping response data.
|
||||
func doPingerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pinger Pinger, pingType string) {
|
||||
func doPingerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pinger Pinger, pingType tailcfg.PingType) {
|
||||
if pr.URL == "" || pr.IP.IsZero() || pinger == nil {
|
||||
logf("invalid ping request: missing url, ip or pinger")
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
pinger.Ping(pr.IP, pingType == "TSMP", func(res *ipnstate.PingResult) {
|
||||
// Currently does not check for error since we just return if it fails.
|
||||
postPingResult(start, logf, c, pr, res.ToPingResponse(pingType))
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := pinger.Ping(ctx, pr.IP, pingType)
|
||||
if err != nil {
|
||||
d := time.Since(start).Round(time.Millisecond)
|
||||
logf("doPingerPing: ping error of type %q to %v after %v: %v", pingType, pr.IP, d, err)
|
||||
return
|
||||
}
|
||||
postPingResult(start, logf, c, pr, res.ToPingResponse(pingType))
|
||||
}
|
||||
|
||||
func postPingResult(start time.Time, logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, res *tailcfg.PingResponse) error {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
@@ -30,6 +31,7 @@ func TestNewDirect(t *testing.T) {
|
||||
GetMachinePrivateKey: func() (key.MachinePrivate, error) {
|
||||
return k, nil
|
||||
},
|
||||
Dialer: new(tsdial.Dialer),
|
||||
}
|
||||
c, err := NewDirect(opts)
|
||||
if err != nil {
|
||||
@@ -66,22 +68,18 @@ func TestNewDirect(t *testing.T) {
|
||||
}
|
||||
|
||||
endpoints := fakeEndpoints(1, 2, 3)
|
||||
changed = c.newEndpoints(12, endpoints)
|
||||
changed = c.newEndpoints(endpoints)
|
||||
if !changed {
|
||||
t.Errorf("c.newEndpoints(12) want true got %v", changed)
|
||||
t.Errorf("c.newEndpoints want true got %v", changed)
|
||||
}
|
||||
changed = c.newEndpoints(12, endpoints)
|
||||
changed = c.newEndpoints(endpoints)
|
||||
if changed {
|
||||
t.Errorf("c.newEndpoints(12) want false got %v", changed)
|
||||
}
|
||||
changed = c.newEndpoints(13, endpoints)
|
||||
if !changed {
|
||||
t.Errorf("c.newEndpoints(13) want true got %v", changed)
|
||||
t.Errorf("c.newEndpoints want false got %v", changed)
|
||||
}
|
||||
endpoints = fakeEndpoints(4, 5, 6)
|
||||
changed = c.newEndpoints(13, endpoints)
|
||||
changed = c.newEndpoints(endpoints)
|
||||
if !changed {
|
||||
t.Errorf("c.newEndpoints(13) want true got %v", changed)
|
||||
t.Errorf("c.newEndpoints want true got %v", changed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +104,7 @@ func TestTsmpPing(t *testing.T) {
|
||||
GetMachinePrivateKey: func() (key.MachinePrivate, error) {
|
||||
return k, nil
|
||||
},
|
||||
Dialer: new(tsdial.Dialer),
|
||||
}
|
||||
|
||||
c, err := NewDirect(opts)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package controlclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
@@ -165,12 +166,6 @@ func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.Netwo
|
||||
}
|
||||
ms.addUserProfile(peer.User)
|
||||
}
|
||||
if len(resp.DNS) > 0 {
|
||||
nm.DNS.Nameservers = resp.DNS
|
||||
}
|
||||
if len(resp.SearchPaths) > 0 {
|
||||
nm.DNS.Domains = resp.SearchPaths
|
||||
}
|
||||
if Debug.ProxyDNS {
|
||||
nm.DNS.Proxied = true
|
||||
}
|
||||
@@ -244,7 +239,7 @@ func undeltaPeers(mapRes *tailcfg.MapResponse, prev []*tailcfg.Node) {
|
||||
sortNodes(newFull)
|
||||
}
|
||||
|
||||
if len(mapRes.PeerSeenChange) != 0 || len(mapRes.OnlineChange) != 0 {
|
||||
if len(mapRes.PeerSeenChange) != 0 || len(mapRes.OnlineChange) != 0 || len(mapRes.PeersChangedPatch) != 0 {
|
||||
peerByID := make(map[tailcfg.NodeID]*tailcfg.Node, len(newFull))
|
||||
for _, n := range newFull {
|
||||
peerByID[n.ID] = n
|
||||
@@ -265,6 +260,16 @@ func undeltaPeers(mapRes *tailcfg.MapResponse, prev []*tailcfg.Node) {
|
||||
n.Online = &online
|
||||
}
|
||||
}
|
||||
for _, ec := range mapRes.PeersChangedPatch {
|
||||
if n, ok := peerByID[ec.NodeID]; ok {
|
||||
if ec.DERPRegion != 0 {
|
||||
n.DERP = fmt.Sprintf("%s:%v", tailcfg.DerpMagicIP, ec.DERPRegion)
|
||||
}
|
||||
if ec.Endpoints != nil {
|
||||
n.Endpoints = ec.Endpoints
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mapRes.Peers = newFull
|
||||
|
||||
@@ -34,6 +34,16 @@ func TestUndeltaPeers(t *testing.T) {
|
||||
n.LastSeen = &t
|
||||
}
|
||||
}
|
||||
withDERP := func(d string) func(*tailcfg.Node) {
|
||||
return func(n *tailcfg.Node) {
|
||||
n.DERP = d
|
||||
}
|
||||
}
|
||||
withEP := func(ep string) func(*tailcfg.Node) {
|
||||
return func(n *tailcfg.Node) {
|
||||
n.Endpoints = []string{ep}
|
||||
}
|
||||
}
|
||||
n := func(id tailcfg.NodeID, name string, mod ...func(*tailcfg.Node)) *tailcfg.Node {
|
||||
n := &tailcfg.Node{ID: id, Name: name}
|
||||
for _, f := range mod {
|
||||
@@ -137,7 +147,53 @@ func TestUndeltaPeers(t *testing.T) {
|
||||
n(2, "bar", seenAt(time.Unix(123, 0))),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "ep_change_derp",
|
||||
prev: peers(n(1, "foo", withDERP("127.3.3.40:3"))),
|
||||
mapRes: &tailcfg.MapResponse{
|
||||
PeersChangedPatch: []*tailcfg.PeerChange{{
|
||||
NodeID: 1,
|
||||
DERPRegion: 4,
|
||||
}},
|
||||
},
|
||||
want: peers(n(1, "foo", withDERP("127.3.3.40:4"))),
|
||||
},
|
||||
{
|
||||
name: "ep_change_udp",
|
||||
prev: peers(n(1, "foo", withEP("1.2.3.4:111"))),
|
||||
mapRes: &tailcfg.MapResponse{
|
||||
PeersChangedPatch: []*tailcfg.PeerChange{{
|
||||
NodeID: 1,
|
||||
Endpoints: []string{"1.2.3.4:56"},
|
||||
}},
|
||||
},
|
||||
want: peers(n(1, "foo", withEP("1.2.3.4:56"))),
|
||||
},
|
||||
{
|
||||
name: "ep_change_udp",
|
||||
prev: peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:111"))),
|
||||
mapRes: &tailcfg.MapResponse{
|
||||
PeersChangedPatch: []*tailcfg.PeerChange{{
|
||||
NodeID: 1,
|
||||
Endpoints: []string{"1.2.3.4:56"},
|
||||
}},
|
||||
},
|
||||
want: peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:56"))),
|
||||
},
|
||||
{
|
||||
name: "ep_change_both",
|
||||
prev: peers(n(1, "foo", withDERP("127.3.3.40:3"), withEP("1.2.3.4:111"))),
|
||||
mapRes: &tailcfg.MapResponse{
|
||||
PeersChangedPatch: []*tailcfg.PeerChange{{
|
||||
NodeID: 1,
|
||||
DERPRegion: 2,
|
||||
Endpoints: []string{"1.2.3.4:56"},
|
||||
}},
|
||||
},
|
||||
want: peers(n(1, "foo", withDERP("127.3.3.40:2"), withEP("1.2.3.4:56"))),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if !tt.curTime.IsZero() {
|
||||
|
||||
@@ -18,8 +18,10 @@ import (
|
||||
"golang.org/x/net/http2"
|
||||
"tailscale.com/control/controlbase"
|
||||
"tailscale.com/control/controlhttp"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/util/multierr"
|
||||
)
|
||||
|
||||
@@ -45,6 +47,7 @@ func (c *noiseConn) Close() error {
|
||||
// the ts2021 protocol.
|
||||
type noiseClient struct {
|
||||
*http.Client // HTTP client used to talk to tailcontrol
|
||||
dialer *tsdial.Dialer
|
||||
privKey key.MachinePrivate
|
||||
serverPubKey key.MachinePublic
|
||||
serverHost string // the host:port part of serverURL
|
||||
@@ -57,7 +60,7 @@ type noiseClient struct {
|
||||
|
||||
// newNoiseClient returns a new noiseClient for the provided server and machine key.
|
||||
// serverURL is of the form https://<host>:<port> (no trailing slash).
|
||||
func newNoiseClient(priKey key.MachinePrivate, serverPubKey key.MachinePublic, serverURL string) (*noiseClient, error) {
|
||||
func newNoiseClient(priKey key.MachinePrivate, serverPubKey key.MachinePublic, serverURL string, dialer *tsdial.Dialer) (*noiseClient, error) {
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -74,6 +77,7 @@ func newNoiseClient(priKey key.MachinePrivate, serverPubKey key.MachinePublic, s
|
||||
serverPubKey: serverPubKey,
|
||||
privKey: priKey,
|
||||
serverHost: host,
|
||||
dialer: dialer,
|
||||
}
|
||||
|
||||
// Create the HTTP/2 Transport using a net/http.Transport
|
||||
@@ -137,9 +141,6 @@ func (nc *noiseClient) Close() error {
|
||||
func (nc *noiseClient) dial(_, _ string, _ *tls.Config) (net.Conn, error) {
|
||||
nc.mu.Lock()
|
||||
connID := nc.nextID
|
||||
if nc.connPool == nil {
|
||||
nc.connPool = make(map[int]*noiseConn)
|
||||
}
|
||||
nc.nextID++
|
||||
nc.mu.Unlock()
|
||||
|
||||
@@ -153,7 +154,7 @@ func (nc *noiseClient) dial(_, _ string, _ *tls.Config) (net.Conn, error) {
|
||||
// thousand version numbers before getting to this point.
|
||||
panic("capability version is too high to fit in the wire protocol")
|
||||
}
|
||||
conn, err := controlhttp.Dial(ctx, nc.serverHost, nc.privKey, nc.serverPubKey, uint16(tailcfg.CurrentCapabilityVersion))
|
||||
conn, err := controlhttp.Dial(ctx, nc.serverHost, nc.privKey, nc.serverPubKey, uint16(tailcfg.CurrentCapabilityVersion), nc.dialer.SystemDial)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -161,6 +162,6 @@ func (nc *noiseClient) dial(_, _ string, _ *tls.Config) (net.Conn, error) {
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
ncc := &noiseConn{Conn: conn, id: connID, pool: nc}
|
||||
nc.connPool[ncc.id] = ncc
|
||||
mak.Set(&nc.connPool, ncc.id, ncc)
|
||||
return ncc, nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/empty"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/types/persist"
|
||||
@@ -75,9 +74,8 @@ type Status struct {
|
||||
// package, but we have some automated tests elsewhere that need to
|
||||
// use them. Please don't use these fields.
|
||||
// TODO(apenwarr): Unexport or remove these.
|
||||
State State
|
||||
Persist *persist.Persist // locally persisted configuration
|
||||
Hostinfo *tailcfg.Hostinfo // current Hostinfo data
|
||||
State State
|
||||
Persist *persist.Persist // locally persisted configuration
|
||||
}
|
||||
|
||||
// Equal reports whether s and s2 are equal.
|
||||
@@ -92,7 +90,6 @@ func (s *Status) Equal(s2 *Status) bool {
|
||||
s.URL == s2.URL &&
|
||||
reflect.DeepEqual(s.Persist, s2.Persist) &&
|
||||
reflect.DeepEqual(s.NetMap, s2.NetMap) &&
|
||||
reflect.DeepEqual(s.Hostinfo, s2.Hostinfo) &&
|
||||
s.State == s2.State
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build !js
|
||||
// +build !js
|
||||
|
||||
// Package controlhttp implements the Tailscale 2021 control protocol
|
||||
// base transport over HTTP.
|
||||
//
|
||||
@@ -25,37 +28,21 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"tailscale.com/control/controlbase"
|
||||
"tailscale.com/net/dnscache"
|
||||
"tailscale.com/net/dnsfallback"
|
||||
"tailscale.com/net/netns"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/net/tlsdial"
|
||||
"tailscale.com/net/tshttpproxy"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
const (
|
||||
// upgradeHeader is the value of the Upgrade HTTP header used to
|
||||
// indicate the Tailscale control protocol.
|
||||
upgradeHeaderValue = "tailscale-control-protocol"
|
||||
|
||||
// handshakeHeaderName is the HTTP request header that can
|
||||
// optionally contain base64-encoded initial handshake
|
||||
// payload, to save an RTT.
|
||||
handshakeHeaderName = "X-Tailscale-Handshake"
|
||||
|
||||
// serverUpgradePath is where the server-side HTTP handler to
|
||||
// to do the protocol switch is located.
|
||||
serverUpgradePath = "/ts2021"
|
||||
)
|
||||
|
||||
// Dial connects to the HTTP server at addr, requests to switch to the
|
||||
// Tailscale control protocol, and returns an established control
|
||||
// protocol connection.
|
||||
@@ -65,13 +52,12 @@ const (
|
||||
//
|
||||
// The provided ctx is only used for the initial connection, until
|
||||
// Dial returns. It does not affect the connection once established.
|
||||
func Dial(ctx context.Context, addr string, machineKey key.MachinePrivate, controlKey key.MachinePublic, protocolVersion uint16) (*controlbase.Conn, error) {
|
||||
func Dial(ctx context.Context, addr string, machineKey key.MachinePrivate, controlKey key.MachinePublic, protocolVersion uint16, dialer dnscache.DialContextFunc) (*controlbase.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &dialParams{
|
||||
ctx: ctx,
|
||||
host: host,
|
||||
httpPort: port,
|
||||
httpsPort: "443",
|
||||
@@ -79,12 +65,12 @@ func Dial(ctx context.Context, addr string, machineKey key.MachinePrivate, contr
|
||||
controlKey: controlKey,
|
||||
version: protocolVersion,
|
||||
proxyFunc: tshttpproxy.ProxyFromEnvironment,
|
||||
dialer: dialer,
|
||||
}
|
||||
return a.dial()
|
||||
return a.dial(ctx)
|
||||
}
|
||||
|
||||
type dialParams struct {
|
||||
ctx context.Context
|
||||
host string
|
||||
httpPort string
|
||||
httpsPort string
|
||||
@@ -92,65 +78,132 @@ type dialParams struct {
|
||||
controlKey key.MachinePublic
|
||||
version uint16
|
||||
proxyFunc func(*http.Request) (*url.URL, error) // or nil
|
||||
dialer dnscache.DialContextFunc
|
||||
|
||||
// For tests only
|
||||
insecureTLS bool
|
||||
insecureTLS bool
|
||||
testFallbackDelay time.Duration
|
||||
}
|
||||
|
||||
func (a *dialParams) dial() (*controlbase.Conn, error) {
|
||||
init, cont, err := controlbase.ClientDeferred(a.machineKey, a.controlKey, a.version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// httpsFallbackDelay is how long we'll wait for a.httpPort to work before
|
||||
// starting to try a.httpsPort.
|
||||
func (a *dialParams) httpsFallbackDelay() time.Duration {
|
||||
if v := a.testFallbackDelay; v != 0 {
|
||||
return v
|
||||
}
|
||||
return 500 * time.Millisecond
|
||||
}
|
||||
|
||||
u := &url.URL{
|
||||
func (a *dialParams) dial(ctx context.Context) (*controlbase.Conn, error) {
|
||||
// Create one shared context used by both port 80 and port 443 dials.
|
||||
// If port 80 is still in flight when 443 returns, this deferred cancel
|
||||
// will stop the port 80 dial.
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// u80 and u443 are the URLs we'll try to hit over HTTP or HTTPS,
|
||||
// respectively, in order to do the HTTP upgrade to a net.Conn over which
|
||||
// we'll speak Noise.
|
||||
u80 := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: net.JoinHostPort(a.host, a.httpPort),
|
||||
Path: serverUpgradePath,
|
||||
}
|
||||
conn, httpErr := a.tryURL(u, init)
|
||||
if httpErr == nil {
|
||||
ret, err := cont(a.ctx, conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
u443 := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: net.JoinHostPort(a.host, a.httpsPort),
|
||||
Path: serverUpgradePath,
|
||||
}
|
||||
|
||||
// Connecting over plain HTTP failed, assume it's an HTTP proxy
|
||||
// being difficult and see if we can get through over HTTPS.
|
||||
u.Scheme = "https"
|
||||
u.Host = net.JoinHostPort(a.host, a.httpsPort)
|
||||
init, cont, err = controlbase.ClientDeferred(a.machineKey, a.controlKey, a.version)
|
||||
type tryURLRes struct {
|
||||
u *url.URL // input (the URL conn+err are for/from)
|
||||
conn *controlbase.Conn // result (mutually exclusive with err)
|
||||
err error
|
||||
}
|
||||
ch := make(chan tryURLRes) // must be unbuffered
|
||||
try := func(u *url.URL) {
|
||||
cbConn, err := a.dialURL(ctx, u)
|
||||
select {
|
||||
case ch <- tryURLRes{u, cbConn, err}:
|
||||
case <-ctx.Done():
|
||||
if cbConn != nil {
|
||||
cbConn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the plaintext HTTP attempt first.
|
||||
go try(u80)
|
||||
|
||||
// In case outbound port 80 blocked or MITM'ed poorly, start a backup timer
|
||||
// to dial port 443 if port 80 doesn't either succeed or fail quickly.
|
||||
try443Timer := time.AfterFunc(a.httpsFallbackDelay(), func() { try(u443) })
|
||||
defer try443Timer.Stop()
|
||||
|
||||
var err80, err443 error
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("connection attempts aborted by context: %w", ctx.Err())
|
||||
case res := <-ch:
|
||||
if res.err == nil {
|
||||
return res.conn, nil
|
||||
}
|
||||
switch res.u {
|
||||
case u80:
|
||||
// Connecting over plain HTTP failed; assume it's an HTTP proxy
|
||||
// being difficult and see if we can get through over HTTPS.
|
||||
err80 = res.err
|
||||
// Stop the fallback timer and run it immediately. We don't use
|
||||
// Timer.Reset(0) here because on AfterFuncs, that can run it
|
||||
// again.
|
||||
if try443Timer.Stop() {
|
||||
go try(u443)
|
||||
} // else we lost the race and it started already which is what we want
|
||||
case u443:
|
||||
err443 = res.err
|
||||
default:
|
||||
panic("invalid")
|
||||
}
|
||||
if err80 != nil && err443 != nil {
|
||||
return nil, fmt.Errorf("all connection attempts failed (HTTP: %v, HTTPS: %v)", err80, err443)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dialURL attempts to connect to the given URL.
|
||||
func (a *dialParams) dialURL(ctx context.Context, u *url.URL) (*controlbase.Conn, error) {
|
||||
init, cont, err := controlbase.ClientDeferred(a.machineKey, a.controlKey, a.version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, tlsErr := a.tryURL(u, init)
|
||||
if tlsErr == nil {
|
||||
ret, err := cont(a.ctx, conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
netConn, err := a.tryURLUpgrade(ctx, u, init)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all connection attempts failed (HTTP: %v, HTTPS: %v)", httpErr, tlsErr)
|
||||
cbConn, err := cont(ctx, netConn)
|
||||
if err != nil {
|
||||
netConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return cbConn, nil
|
||||
}
|
||||
|
||||
func (a *dialParams) tryURL(u *url.URL, init []byte) (net.Conn, error) {
|
||||
// tryURLUpgrade connects to u, and tries to upgrade it to a net.Conn.
|
||||
//
|
||||
// Only the provided ctx is used, not a.ctx.
|
||||
func (a *dialParams) tryURLUpgrade(ctx context.Context, u *url.URL, init []byte) (net.Conn, error) {
|
||||
dns := &dnscache.Resolver{
|
||||
Forward: dnscache.Get().Forward,
|
||||
LookupIPFallback: dnsfallback.Lookup,
|
||||
UseLastGood: true,
|
||||
}
|
||||
dialer := netns.NewDialer(log.Printf)
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
defer tr.CloseIdleConnections()
|
||||
tr.Proxy = a.proxyFunc
|
||||
tshttpproxy.SetTransportGetProxyConnectHeader(tr)
|
||||
tr.DialContext = dnscache.Dialer(dialer.DialContext, dns)
|
||||
tr.DialContext = dnscache.Dialer(a.dialer, dns)
|
||||
// Disable HTTP2, since h2 can't do protocol switching.
|
||||
tr.TLSClientConfig.NextProtos = []string{}
|
||||
tr.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
|
||||
@@ -159,7 +212,7 @@ func (a *dialParams) tryURL(u *url.URL, init []byte) (net.Conn, error) {
|
||||
tr.TLSClientConfig.InsecureSkipVerify = true
|
||||
tr.TLSClientConfig.VerifyConnection = nil
|
||||
}
|
||||
tr.DialTLSContext = dnscache.TLSDialer(dialer.DialContext, dns, tr.TLSClientConfig)
|
||||
tr.DialTLSContext = dnscache.TLSDialer(a.dialer, dns, tr.TLSClientConfig)
|
||||
tr.DisableCompression = true
|
||||
|
||||
// (mis)use httptrace to extract the underlying net.Conn from the
|
||||
@@ -189,7 +242,7 @@ func (a *dialParams) tryURL(u *url.URL, init []byte) (net.Conn, error) {
|
||||
connCh <- info.Conn
|
||||
},
|
||||
}
|
||||
ctx := httptrace.WithClientTrace(a.ctx, &trace)
|
||||
ctx = httptrace.WithClientTrace(ctx, &trace)
|
||||
req := &http.Request{
|
||||
Method: "POST",
|
||||
URL: u,
|
||||
|
||||
61
control/controlhttp/client_js.go
Normal file
61
control/controlhttp/client_js.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controlhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"net/url"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/control/controlbase"
|
||||
"tailscale.com/net/dnscache"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// Variant of Dial that tunnels the request over WebSockets, since we cannot do
|
||||
// bi-directional communication over an HTTP connection when in JS.
|
||||
func Dial(ctx context.Context, addr string, machineKey key.MachinePrivate, controlKey key.MachinePublic, protocolVersion uint16, dialer dnscache.DialContextFunc) (*controlbase.Conn, error) {
|
||||
init, cont, err := controlbase.ClientDeferred(machineKey, controlKey, protocolVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wsScheme := "wss"
|
||||
wsHost := host
|
||||
if host == "localhost" {
|
||||
wsScheme = "ws"
|
||||
wsHost = addr
|
||||
}
|
||||
wsURL := &url.URL{
|
||||
Scheme: wsScheme,
|
||||
Host: wsHost,
|
||||
Path: serverUpgradePath,
|
||||
// Can't set HTTP headers on the websocket request, so we have to to send
|
||||
// the handshake via an HTTP header.
|
||||
RawQuery: url.Values{
|
||||
handshakeHeaderName: []string{base64.StdEncoding.EncodeToString(init)},
|
||||
}.Encode(),
|
||||
}
|
||||
wsConn, _, err := websocket.Dial(ctx, wsURL.String(), &websocket.DialOptions{
|
||||
Subprotocols: []string{upgradeHeaderValue},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
netConn := websocket.NetConn(context.Background(), wsConn, websocket.MessageBinary)
|
||||
cbConn, err := cont(ctx, netConn)
|
||||
if err != nil {
|
||||
netConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return cbConn, nil
|
||||
|
||||
}
|
||||
20
control/controlhttp/constants.go
Normal file
20
control/controlhttp/constants.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package controlhttp
|
||||
|
||||
const (
|
||||
// upgradeHeader is the value of the Upgrade HTTP header used to
|
||||
// indicate the Tailscale control protocol.
|
||||
upgradeHeaderValue = "tailscale-control-protocol"
|
||||
|
||||
// handshakeHeaderName is the HTTP request header that can
|
||||
// optionally contain base64-encoded initial handshake
|
||||
// payload, to save an RTT.
|
||||
handshakeHeaderName = "X-Tailscale-Handshake"
|
||||
|
||||
// serverUpgradePath is where the server-side HTTP handler to
|
||||
// to do the protocol switch is located.
|
||||
serverUpgradePath = "/ts2021"
|
||||
)
|
||||
@@ -17,22 +17,36 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tailscale.com/control/controlbase"
|
||||
"tailscale.com/net/socks5"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
type httpTestParam struct {
|
||||
name string
|
||||
proxy proxy
|
||||
|
||||
// makeHTTPHangAfterUpgrade makes the HTTP response hang after sending a
|
||||
// 101 switching protocols.
|
||||
makeHTTPHangAfterUpgrade bool
|
||||
}
|
||||
|
||||
func TestControlHTTP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
proxy proxy
|
||||
}{
|
||||
tests := []httpTestParam{
|
||||
// direct connection
|
||||
{
|
||||
name: "no_proxy",
|
||||
proxy: nil,
|
||||
},
|
||||
// direct connection but port 80 is MITM'ed and broken
|
||||
{
|
||||
name: "port80_broken_mitm",
|
||||
proxy: nil,
|
||||
makeHTTPHangAfterUpgrade: true,
|
||||
},
|
||||
// SOCKS5
|
||||
{
|
||||
name: "socks5",
|
||||
@@ -96,12 +110,13 @@ func TestControlHTTP(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
testControlHTTP(t, test.proxy)
|
||||
testControlHTTP(t, test)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testControlHTTP(t *testing.T, proxy proxy) {
|
||||
func testControlHTTP(t *testing.T, param httpTestParam) {
|
||||
proxy := param.proxy
|
||||
client, server := key.NewMachine(), key.NewMachine()
|
||||
|
||||
const testProtocolVersion = 1
|
||||
@@ -132,7 +147,11 @@ func testControlHTTP(t *testing.T, proxy proxy) {
|
||||
t.Fatalf("HTTPS listen: %v", err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{Handler: handler}
|
||||
var httpHandler http.Handler = handler
|
||||
if param.makeHTTPHangAfterUpgrade {
|
||||
httpHandler = http.HandlerFunc(brokenMITMHandler)
|
||||
}
|
||||
httpServer := &http.Server{Handler: httpHandler}
|
||||
go httpServer.Serve(httpLn)
|
||||
defer httpServer.Close()
|
||||
|
||||
@@ -143,18 +162,24 @@ func testControlHTTP(t *testing.T, proxy proxy) {
|
||||
go httpsServer.ServeTLS(httpsLn, "", "")
|
||||
defer httpsServer.Close()
|
||||
|
||||
//ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
//defer cancel()
|
||||
ctx := context.Background()
|
||||
const debugTimeout = false
|
||||
if debugTimeout {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
a := dialParams{
|
||||
ctx: context.Background(), //ctx,
|
||||
host: "localhost",
|
||||
httpPort: strconv.Itoa(httpLn.Addr().(*net.TCPAddr).Port),
|
||||
httpsPort: strconv.Itoa(httpsLn.Addr().(*net.TCPAddr).Port),
|
||||
machineKey: client,
|
||||
controlKey: server.Public(),
|
||||
version: testProtocolVersion,
|
||||
insecureTLS: true,
|
||||
host: "localhost",
|
||||
httpPort: strconv.Itoa(httpLn.Addr().(*net.TCPAddr).Port),
|
||||
httpsPort: strconv.Itoa(httpsLn.Addr().(*net.TCPAddr).Port),
|
||||
machineKey: client,
|
||||
controlKey: server.Public(),
|
||||
version: testProtocolVersion,
|
||||
insecureTLS: true,
|
||||
dialer: new(tsdial.Dialer).SystemDial,
|
||||
testFallbackDelay: 50 * time.Millisecond,
|
||||
}
|
||||
|
||||
if proxy != nil {
|
||||
@@ -173,7 +198,7 @@ func testControlHTTP(t *testing.T, proxy proxy) {
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := a.dial()
|
||||
conn, err := a.dial(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("dialing controlhttp: %v", err)
|
||||
}
|
||||
@@ -215,6 +240,7 @@ type proxy interface {
|
||||
|
||||
type socksProxy struct {
|
||||
sync.Mutex
|
||||
closed bool
|
||||
proxy socks5.Server
|
||||
ln net.Listener
|
||||
clientConnAddrs map[string]bool // addrs of the local end of outgoing conns from proxy
|
||||
@@ -230,7 +256,14 @@ func (s *socksProxy) Start(t *testing.T) (url string) {
|
||||
}
|
||||
s.ln = ln
|
||||
s.clientConnAddrs = map[string]bool{}
|
||||
s.proxy.Logf = t.Logf
|
||||
s.proxy.Logf = func(format string, a ...any) {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
t.Logf(format, a...)
|
||||
}
|
||||
s.proxy.Dialer = s.dialAndRecord
|
||||
go s.proxy.Serve(ln)
|
||||
return fmt.Sprintf("socks5://%s", ln.Addr().String())
|
||||
@@ -239,6 +272,10 @@ func (s *socksProxy) Start(t *testing.T) (url string) {
|
||||
func (s *socksProxy) Close() {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
s.ln.Close()
|
||||
}
|
||||
|
||||
@@ -398,3 +435,11 @@ EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
}
|
||||
|
||||
func brokenMITMHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Upgrade", upgradeHeaderValue)
|
||||
w.Header().Set("Connection", "upgrade")
|
||||
w.WriteHeader(http.StatusSwitchingProtocols)
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/control/controlbase"
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/types/key"
|
||||
@@ -27,6 +28,9 @@ func AcceptHTTP(ctx context.Context, w http.ResponseWriter, r *http.Request, pri
|
||||
http.Error(w, "missing next protocol", http.StatusBadRequest)
|
||||
return nil, errors.New("no next protocol in HTTP request")
|
||||
}
|
||||
if next == "websocket" {
|
||||
return acceptWebsocket(ctx, w, r, private)
|
||||
}
|
||||
if next != upgradeHeaderValue {
|
||||
http.Error(w, "unknown next protocol", http.StatusBadRequest)
|
||||
return nil, fmt.Errorf("client requested unhandled next protocol %q", next)
|
||||
@@ -71,3 +75,42 @@ func AcceptHTTP(ctx context.Context, w http.ResponseWriter, r *http.Request, pri
|
||||
|
||||
return nc, nil
|
||||
}
|
||||
|
||||
// acceptWebsocket upgrades a WebSocket connection (from a client that cannot
|
||||
// speak HTTP) to a Tailscale control protocol base transport connection.
|
||||
func acceptWebsocket(ctx context.Context, w http.ResponseWriter, r *http.Request, private key.MachinePrivate) (*controlbase.Conn, error) {
|
||||
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
Subprotocols: []string{upgradeHeaderValue},
|
||||
OriginPatterns: []string{"*"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not accept WebSocket connection %v", err)
|
||||
}
|
||||
if c.Subprotocol() != upgradeHeaderValue {
|
||||
c.Close(websocket.StatusPolicyViolation, "client must speak the control subprotocol")
|
||||
return nil, fmt.Errorf("Unexpected subprotocol %q", c.Subprotocol())
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
c.Close(websocket.StatusPolicyViolation, "Could not parse parameters")
|
||||
return nil, fmt.Errorf("parse query parameters: %v", err)
|
||||
}
|
||||
initB64 := r.Form.Get(handshakeHeaderName)
|
||||
if initB64 == "" {
|
||||
c.Close(websocket.StatusPolicyViolation, "missing Tailscale handshake parameter")
|
||||
return nil, errors.New("no tailscale handshake parameter in HTTP request")
|
||||
}
|
||||
init, err := base64.StdEncoding.DecodeString(initB64)
|
||||
if err != nil {
|
||||
c.Close(websocket.StatusPolicyViolation, "invalid tailscale handshake parameter")
|
||||
return nil, fmt.Errorf("decoding base64 handshake parameter: %v", err)
|
||||
}
|
||||
|
||||
conn := websocket.NetConn(ctx, c, websocket.MessageBinary)
|
||||
nc, err := controlbase.Server(ctx, conn, private, init)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("noise handshake failed: %w", err)
|
||||
}
|
||||
|
||||
return nc, nil
|
||||
}
|
||||
|
||||
@@ -156,6 +156,8 @@ func (c *Client) parseServerInfo(b []byte) (*serverInfo, error) {
|
||||
}
|
||||
|
||||
type clientInfo struct {
|
||||
// Version is the DERP protocol version that the client was built with.
|
||||
// See the ProtocolVersion const.
|
||||
Version int `json:"version,omitempty"`
|
||||
|
||||
// MeshKey optionally specifies a pre-shared key used by
|
||||
@@ -485,7 +487,7 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro
|
||||
c.peeked = int(n)
|
||||
} else {
|
||||
// But if for some reason we read a large DERP message (which isn't necessarily
|
||||
// a Wireguard packet), then just allocate memory for it.
|
||||
// a WireGuard packet), then just allocate memory for it.
|
||||
// TODO(bradfitz): use a pool if large frames ever happen in practice.
|
||||
b = make([]byte, n)
|
||||
_, err = io.ReadFull(c.br, b)
|
||||
|
||||
@@ -410,7 +410,7 @@ func (s *Server) IsClientConnectedForTest(k key.NodePublic) bool {
|
||||
// on its own.
|
||||
//
|
||||
// Accept closes nc.
|
||||
func (s *Server) Accept(nc Conn, brw *bufio.ReadWriter, remoteAddr string) {
|
||||
func (s *Server) Accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, remoteAddr string) {
|
||||
closed := make(chan struct{})
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -428,7 +428,7 @@ func (s *Server) Accept(nc Conn, brw *bufio.ReadWriter, remoteAddr string) {
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
if err := s.accept(nc, brw, remoteAddr, connNum); err != nil && !s.isClosed() {
|
||||
if err := s.accept(ctx, nc, brw, remoteAddr, connNum); err != nil && !s.isClosed() {
|
||||
s.logf("derp: %s: %v", remoteAddr, err)
|
||||
}
|
||||
}
|
||||
@@ -641,7 +641,7 @@ func (s *Server) addWatcher(c *sclient) {
|
||||
go c.requestMeshUpdate()
|
||||
}
|
||||
|
||||
func (s *Server) accept(nc Conn, brw *bufio.ReadWriter, remoteAddr string, connNum int64) error {
|
||||
func (s *Server) accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, remoteAddr string, connNum int64) error {
|
||||
br := brw.Reader
|
||||
nc.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
bw := &lazyBufioWriter{w: nc, lbw: brw.Writer}
|
||||
@@ -660,7 +660,7 @@ func (s *Server) accept(nc Conn, brw *bufio.ReadWriter, remoteAddr string, connN
|
||||
// At this point we trust the client so we don't time out.
|
||||
nc.SetDeadline(time.Time{})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
remoteIPPort, _ := netaddr.ParseIPPort(remoteAddr)
|
||||
|
||||
@@ -85,8 +85,12 @@ func TestSendRecv(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cin.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
brwServer := bufio.NewReadWriter(bufio.NewReader(cin), bufio.NewWriter(cin))
|
||||
go s.Accept(cin, brwServer, fmt.Sprintf("test-client-%d", i))
|
||||
go s.Accept(ctx, cin, brwServer, fmt.Sprintf("test-client-%d", i))
|
||||
|
||||
key := clientPrivateKeys[i]
|
||||
brw := bufio.NewReadWriter(bufio.NewReader(cout), bufio.NewWriter(cout))
|
||||
@@ -231,10 +235,10 @@ func TestSendFreeze(t *testing.T) {
|
||||
// Then cathy stops processing messsages.
|
||||
// That should not interfere with alice talking to bob.
|
||||
|
||||
newClient := func(name string, k key.NodePrivate) (c *Client, clientConn nettest.Conn) {
|
||||
newClient := func(ctx context.Context, name string, k key.NodePrivate) (c *Client, clientConn nettest.Conn) {
|
||||
t.Helper()
|
||||
c1, c2 := nettest.NewConn(name, 1024)
|
||||
go s.Accept(c1, bufio.NewReadWriter(bufio.NewReader(c1), bufio.NewWriter(c1)), name)
|
||||
go s.Accept(ctx, c1, bufio.NewReadWriter(bufio.NewReader(c1), bufio.NewWriter(c1)), name)
|
||||
|
||||
brw := bufio.NewReadWriter(bufio.NewReader(c2), bufio.NewWriter(c2))
|
||||
c, err := NewClient(k, c2, brw, t.Logf)
|
||||
@@ -245,14 +249,17 @@ func TestSendFreeze(t *testing.T) {
|
||||
return c, c2
|
||||
}
|
||||
|
||||
ctx, clientCtxCancel := context.WithCancel(context.Background())
|
||||
defer clientCtxCancel()
|
||||
|
||||
aliceKey := key.NewNode()
|
||||
aliceClient, aliceConn := newClient("alice", aliceKey)
|
||||
aliceClient, aliceConn := newClient(ctx, "alice", aliceKey)
|
||||
|
||||
bobKey := key.NewNode()
|
||||
bobClient, bobConn := newClient("bob", bobKey)
|
||||
bobClient, bobConn := newClient(ctx, "bob", bobKey)
|
||||
|
||||
cathyKey := key.NewNode()
|
||||
cathyClient, cathyConn := newClient("cathy", cathyKey)
|
||||
cathyClient, cathyConn := newClient(ctx, "cathy", cathyKey)
|
||||
|
||||
var (
|
||||
aliceCh = make(chan struct{}, 32)
|
||||
@@ -455,7 +462,7 @@ func (ts *testServer) close(t *testing.T) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) *testServer {
|
||||
func newTestServer(t *testing.T, ctx context.Context) *testServer {
|
||||
t.Helper()
|
||||
logf := logger.WithPrefix(t.Logf, "derp-server: ")
|
||||
s := NewServer(key.NewNode(), logf)
|
||||
@@ -475,7 +482,7 @@ func newTestServer(t *testing.T) *testServer {
|
||||
// TODO: register c in ts so Close also closes it?
|
||||
go func(i int) {
|
||||
brwServer := bufio.NewReadWriter(bufio.NewReader(c), bufio.NewWriter(c))
|
||||
go s.Accept(c, brwServer, fmt.Sprintf("test-client-%d", i))
|
||||
go s.Accept(ctx, c, brwServer, fmt.Sprintf("test-client-%d", i))
|
||||
}(i)
|
||||
}
|
||||
}()
|
||||
@@ -610,7 +617,10 @@ func (c *testClient) close(t *testing.T) {
|
||||
// TestWatch tests the connection watcher mechanism used by regional
|
||||
// DERP nodes to mesh up with each other.
|
||||
func TestWatch(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ts := newTestServer(t, ctx)
|
||||
defer ts.close(t)
|
||||
|
||||
w1 := newTestWatcher(t, ts, "w1")
|
||||
@@ -1198,7 +1208,10 @@ func benchmarkSendRecvSize(b *testing.B, packetSize int) {
|
||||
defer connIn.Close()
|
||||
|
||||
brwServer := bufio.NewReadWriter(bufio.NewReader(connIn), bufio.NewWriter(connIn))
|
||||
go s.Accept(connIn, brwServer, "test-client")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go s.Accept(ctx, connIn, brwServer, "test-client")
|
||||
|
||||
brw := bufio.NewReadWriter(bufio.NewReader(connOut), bufio.NewWriter(connOut))
|
||||
client, err := NewClient(k, connOut, brw, logger.Discard)
|
||||
@@ -1354,7 +1367,10 @@ func TestClientSendRateLimiting(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServerRepliesToPing(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ts := newTestServer(t, ctx)
|
||||
defer ts.close(t)
|
||||
|
||||
tc := newRegularClient(t, ts, "alice")
|
||||
|
||||
@@ -56,6 +56,6 @@ func Handler(s *derp.Server) http.Handler {
|
||||
pubKey.UntypedHexString())
|
||||
}
|
||||
|
||||
s.Accept(netConn, conn, netConn.RemoteAddr().String())
|
||||
s.Accept(r.Context(), netConn, conn, netConn.RemoteAddr().String())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"net"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/derp/wsconn"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -29,5 +28,6 @@ func dialWebsocket(ctx context.Context, urlStr string) (net.Conn, error) {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("websocket: connected to %v", urlStr)
|
||||
return wsconn.New(c), nil
|
||||
netConn := websocket.NetConn(context.Background(), c, websocket.MessageBinary)
|
||||
return netConn, nil
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package wsconn contains an adapter type that turns
|
||||
// a websocket connection into a net.Conn.
|
||||
package wsconn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
)
|
||||
|
||||
// New returns a net.Conn wrapper around c,
|
||||
// using c to send and receive binary messages with
|
||||
// chunks of bytes with no defined framing, effectively
|
||||
// discarding all WebSocket-level message framing.
|
||||
func New(c *websocket.Conn) net.Conn {
|
||||
return &websocketConn{c: c}
|
||||
}
|
||||
|
||||
// websocketConn implements derp.Conn around a *websocket.Conn,
|
||||
// treating a websocket.Conn as a byte stream, ignoring the WebSocket
|
||||
// frame/message boundaries.
|
||||
type websocketConn struct {
|
||||
c *websocket.Conn
|
||||
|
||||
// rextra are extra bytes owned by the reader.
|
||||
rextra []byte
|
||||
|
||||
mu sync.Mutex
|
||||
rdeadline time.Time
|
||||
cancelRead context.CancelFunc
|
||||
}
|
||||
|
||||
func (wc *websocketConn) LocalAddr() net.Addr { return addr{} }
|
||||
func (wc *websocketConn) RemoteAddr() net.Addr { return addr{} }
|
||||
|
||||
type addr struct{}
|
||||
|
||||
func (addr) Network() string { return "websocket" }
|
||||
func (addr) String() string { return "websocket" }
|
||||
|
||||
func (wc *websocketConn) Read(p []byte) (n int, err error) {
|
||||
// Drain any leftover from previously.
|
||||
n = copy(p, wc.rextra)
|
||||
if n > 0 {
|
||||
wc.rextra = wc.rextra[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
var ctx context.Context
|
||||
var cancel context.CancelFunc
|
||||
|
||||
wc.mu.Lock()
|
||||
if dl := wc.rdeadline; !dl.IsZero() {
|
||||
ctx, cancel = context.WithDeadline(context.Background(), wc.rdeadline)
|
||||
} else {
|
||||
ctx, cancel = context.WithDeadline(context.Background(), time.Now().Add(30*24*time.Hour))
|
||||
wc.rdeadline = time.Time{}
|
||||
}
|
||||
wc.cancelRead = cancel
|
||||
wc.mu.Unlock()
|
||||
defer cancel()
|
||||
|
||||
_, buf, err := wc.c.Read(ctx)
|
||||
n = copy(p, buf)
|
||||
wc.rextra = buf[n:]
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (wc *websocketConn) Write(p []byte) (n int, err error) {
|
||||
err = wc.c.Write(context.Background(), websocket.MessageBinary, p)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (wc *websocketConn) Close() error { return wc.c.Close(websocket.StatusNormalClosure, "close") }
|
||||
|
||||
func (wc *websocketConn) SetDeadline(t time.Time) error {
|
||||
wc.SetReadDeadline(t)
|
||||
wc.SetWriteDeadline(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wc *websocketConn) SetReadDeadline(t time.Time) error {
|
||||
wc.mu.Lock()
|
||||
defer wc.mu.Unlock()
|
||||
if !t.IsZero() && (wc.rdeadline.IsZero() || t.Before(wc.rdeadline)) && wc.cancelRead != nil {
|
||||
wc.cancelRead()
|
||||
}
|
||||
wc.rdeadline = t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wc *websocketConn) SetWriteDeadline(t time.Time) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
# Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
FROM ghcr.io/tailscale/tailscale:latest
|
||||
COPY run.sh /run.sh
|
||||
CMD "/run.sh"
|
||||
@@ -1,38 +1,28 @@
|
||||
# Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
# Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
ifndef IMAGE_TAG
|
||||
$(error "IMAGE_TAG is not set")
|
||||
endif
|
||||
|
||||
ROUTES ?= ""
|
||||
TS_ROUTES ?= ""
|
||||
SA_NAME ?= tailscale
|
||||
KUBE_SECRET ?= tailscale
|
||||
|
||||
build:
|
||||
@docker build . -t $(IMAGE_TAG)
|
||||
|
||||
push: build
|
||||
@docker push $(IMAGE_TAG)
|
||||
TS_KUBE_SECRET ?= tailscale
|
||||
|
||||
rbac:
|
||||
@sed -e "s;{{KUBE_SECRET}};$(KUBE_SECRET);g" role.yaml | kubectl apply -f -
|
||||
@sed -e "s;{{TS_KUBE_SECRET}};$(TS_KUBE_SECRET);g" role.yaml | kubectl apply -f -
|
||||
@sed -e "s;{{SA_NAME}};$(SA_NAME);g" rolebinding.yaml | kubectl apply -f -
|
||||
@sed -e "s;{{SA_NAME}};$(SA_NAME);g" sa.yaml | kubectl apply -f -
|
||||
|
||||
sidecar:
|
||||
@kubectl delete -f sidecar.yaml --ignore-not-found --grace-period=0
|
||||
@sed -e "s;{{KUBE_SECRET}};$(KUBE_SECRET);g" sidecar.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{IMAGE_TAG}};$(IMAGE_TAG);g" | kubectl create -f-
|
||||
@sed -e "s;{{TS_KUBE_SECRET}};$(TS_KUBE_SECRET);g" sidecar.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | kubectl create -f-
|
||||
|
||||
userspace-sidecar:
|
||||
@kubectl delete -f userspace-sidecar.yaml --ignore-not-found --grace-period=0
|
||||
@sed -e "s;{{KUBE_SECRET}};$(KUBE_SECRET);g" userspace-sidecar.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{IMAGE_TAG}};$(IMAGE_TAG);g" | kubectl create -f-
|
||||
@sed -e "s;{{TS_KUBE_SECRET}};$(TS_KUBE_SECRET);g" userspace-sidecar.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | kubectl create -f-
|
||||
|
||||
proxy:
|
||||
@kubectl delete -f proxy.yaml --ignore-not-found --grace-period=0
|
||||
@sed -e "s;{{KUBE_SECRET}};$(KUBE_SECRET);g" proxy.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{IMAGE_TAG}};$(IMAGE_TAG);g" | sed -e "s;{{DEST_IP}};$(DEST_IP);g" | kubectl create -f-
|
||||
kubectl delete -f proxy.yaml --ignore-not-found --grace-period=0
|
||||
sed -e "s;{{TS_KUBE_SECRET}};$(TS_KUBE_SECRET);g" proxy.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{TS_DEST_IP}};$(TS_DEST_IP);g" | kubectl create -f-
|
||||
|
||||
subnet-router:
|
||||
@kubectl delete -f subnet.yaml --ignore-not-found --grace-period=0
|
||||
@sed -e "s;{{KUBE_SECRET}};$(KUBE_SECRET);g" subnet.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{IMAGE_TAG}};$(IMAGE_TAG);g" | sed -e "s;{{ROUTES}};$(ROUTES);g" | kubectl create -f-
|
||||
@sed -e "s;{{TS_KUBE_SECRET}};$(TS_KUBE_SECRET);g" subnet.yaml | sed -e "s;{{SA_NAME}};$(SA_NAME);g" | sed -e "s;{{TS_ROUTES}};$(TS_ROUTES);g" | kubectl create -f-
|
||||
|
||||
@@ -15,19 +15,12 @@ There are quite a few ways of running Tailscale inside a Kubernetes Cluster, som
|
||||
AUTH_KEY: tskey-...
|
||||
```
|
||||
|
||||
1. Build and push the container
|
||||
|
||||
```bash
|
||||
export IMAGE_TAG=tailscale-k8s:latest
|
||||
make push
|
||||
```
|
||||
|
||||
1. Tailscale (v1.16+) supports storing state inside a Kubernetes Secret.
|
||||
|
||||
Configure RBAC to allow the Tailscale pod to read/write the `tailscale` secret.
|
||||
```bash
|
||||
export SA_NAME=tailscale
|
||||
export KUBE_SECRET=tailscale-auth
|
||||
export TS_KUBE_SECRET=tailscale-auth
|
||||
make rbac
|
||||
```
|
||||
|
||||
@@ -82,11 +75,11 @@ Running a Tailscale proxy allows you to provide inbound connectivity to a Kubern
|
||||
```bash
|
||||
kubectl create deployment nginx --image nginx
|
||||
kubectl expose deployment nginx --port 80
|
||||
export DEST_IP="$(kubectl get svc nginx -o=jsonpath='{.spec.clusterIP}')"
|
||||
export TS_DEST_IP="$(kubectl get svc nginx -o=jsonpath='{.spec.clusterIP}')"
|
||||
```
|
||||
**Using an existing service**
|
||||
```bash
|
||||
export DEST_IP="$(kubectl get svc <SVC_NAME> -o=jsonpath='{.spec.clusterIP}')"
|
||||
export TS_DEST_IP="$(kubectl get svc <SVC_NAME> -o=jsonpath='{.spec.clusterIP}')"
|
||||
```
|
||||
|
||||
1. Deploy the proxy pod
|
||||
@@ -114,12 +107,12 @@ Running a Tailscale proxy allows you to provide inbound connectivity to a Kubern
|
||||
Running a Tailscale [subnet router](https://tailscale.com/kb/1019/subnets/) allows you to access
|
||||
the entire Kubernetes cluster network (assuming NetworkPolicies allow) over Tailscale.
|
||||
|
||||
1. Identify the Pod/Service CIDRs that cover your Kubernetes cluster. These will vary depending on [which CNI](https://kubernetes.io/docs/concepts/cluster-administration/networking/) you are using and on the Cloud Provider you are using. Add these to the `ROUTES` variable as comma-separated values.
|
||||
1. Identify the Pod/Service CIDRs that cover your Kubernetes cluster. These will vary depending on [which CNI](https://kubernetes.io/docs/concepts/cluster-administration/networking/) you are using and on the Cloud Provider you are using. Add these to the `TS_ROUTES` variable as comma-separated values.
|
||||
|
||||
```bash
|
||||
SERVICE_CIDR=10.20.0.0/16
|
||||
POD_CIDR=10.42.0.0/15
|
||||
export ROUTES=$SERVICE_CIDR,$POD_CIDR
|
||||
export TS_ROUTES=$SERVICE_CIDR,$POD_CIDR
|
||||
```
|
||||
|
||||
1. Deploy the subnet-router pod.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user