Compare commits

..

3 Commits

Author SHA1 Message Date
James Tucker
20cd690970 .github/workflows: add cross-android
Fixes 4482

Signed-off-by: James Tucker <james@tailscale.com>
2022-04-21 10:26:40 -07:00
James Tucker
26fe00e7bd .github/workflows: add cross-android
Fixes 4482

Signed-off-by: James Tucker <james@tailscale.com>
2022-04-21 10:13:27 -07:00
James Tucker
972df80be2 .github/workflows: add cross-android
Fixes 4482

Signed-off-by: James Tucker <james@tailscale.com>
2022-04-21 10:00:02 -07:00
402 changed files with 6764 additions and 24846 deletions

View File

@@ -1,4 +1,4 @@
name: Wasm-Cross
name: Android-Cross
on:
push:
@@ -25,16 +25,17 @@ jobs:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Wasm client build
- name: Android build cmd
env:
GOOS: js
GOARCH: wasm
run: go build ./cmd/tsconnect/wasm
GOOS: android
GOARCH: amd64
run: go build ./cmd/...
- name: tsconnect static build
# Use our custom Go toolchain, we set build tags (to control binary size)
# that depend on it.
run: ./tool/go run ./cmd/tsconnect build
- 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
- uses: k0kubun/action-slack@v2.0.0
with:

View File

@@ -28,11 +28,6 @@ jobs:
- name: Basic build
run: go build ./cmd/...
- name: Build variants
run: |
go install --tags=ts_include_cli ./cmd/tailscaled
go install --tags=ts_omit_aws ./cmd/tailscaled
- name: Get QEMU
run: |
# The qemu in Ubuntu 20.04 (Focal) is too old; we need 5.x something

View File

@@ -21,9 +21,6 @@ jobs:
- name: Check out code
uses: actions/checkout@v3
- name: Run gofmt (goimports)
run: go run golang.org/x/tools/cmd/goimports -d --format-only .
- name: Run go vet
run: go vet ./...

View File

@@ -43,17 +43,6 @@ 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?)

View File

@@ -1 +0,0 @@
3.16

View File

@@ -39,18 +39,6 @@ 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
@@ -68,8 +56,5 @@ RUN GOARCH=$TARGETARCH go install -ldflags="\
-X tailscale.com/version.GitCommit=$VERSION_GIT_HASH" \
-v ./cmd/tailscale ./cmd/tailscaled
FROM alpine:3.16
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables
FROM ghcr.io/tailscale/alpine-base:3.14
COPY --from=build-env /go/bin/* /usr/local/bin/
COPY --from=build-env /go/src/tailscale/docs/k8s/run.sh /usr/local/bin/

View File

@@ -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.16
FROM alpine:3.15
RUN apk add --no-cache ca-certificates iptables iproute2 ip6tables

View File

@@ -1 +1 @@
1.29.0
1.23.0

149
api.md
View File

@@ -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)
- [POST tailnet ACL preview](#tailnet-acl-preview-post)
- [POST tailnet ACL validate](#tailnet-acl-validate-post)
- [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
- [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,9 +317,14 @@ 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
"keyExpiryDisabled": true,
"preauthorized": true
}
```
@@ -334,8 +339,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.
@@ -615,12 +620,7 @@ Response:
#### `POST /api/v2/tailnet/:tailnet/acl/validate` - run validation tests against the tailnet's active ACL
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.
Runs the provided ACL tests against the tailnet's existing ACL. 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 with tests
##### Example
```
POST /api/v2/tailnet/example.com/acl/validate
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \
@@ -641,28 +641,11 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \
]'
```
##### 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:
If all the tests pass, the response will be empty, with an http status code of 200.
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",
@@ -675,8 +658,6 @@ If there's a problem, the response body will be a JSON object with a non-empty `
}
```
An empty body or a JSON object with no `message` is returned on success.
<a name=tailnet-devices></a>
### Devices
@@ -684,7 +665,7 @@ An empty body or a JSON object with no `message` is returned on success.
<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.
@@ -693,7 +674,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`
@@ -713,7 +694,7 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/devices' \
-u "tskey-yourapikey123:"
```
Response
Response
```
{
"devices":[
@@ -815,15 +796,11 @@ Supply the tailnet in the path.
`capabilities` - A mapping of resources to permissible actions.
```
{
"capabilities": {
"capabilities": {
"devices": {
"create": {
"reusable": false,
"ephemeral": false,
"preauthorized": false,
"tags": [
"tag:example"
]
"ephemeral": false
}
}
}
@@ -843,13 +820,11 @@ The full key can no longer be retrieved by the server.
```
echo '{
"capabilities": {
"capabilities": {
"devices": {
"create": {
"reusable": false,
"ephemeral": false,
"preauthorized": false,
"tags": [ "tag:example" ]
"ephemeral": false
}
}
}
@@ -865,7 +840,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, "preauthorized": false, "tags": [ "tag:example" ]}}}
"capabilities": {"devices": {"create": {"reusable": false, "ephemeral": false}}}
}
```
@@ -895,22 +870,10 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/keys/k123456CNTRL' \
Response:
```
{
"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"
]
}
}
}
"id": "k123456CNTRL",
"created": "2021-12-09T22:13:53Z",
"expires": "2022-03-09T22:13:53Z",
"capabilities": {"devices": {"create": {"reusable": false, "ephemeral": false}}}
}
```
@@ -941,13 +904,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
@@ -955,7 +918,7 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers' \
-u "tskey-yourapikey123:"
```
Response
Response
```
{
"dns": ["8.8.8.8"],
@@ -965,7 +928,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).
@@ -979,7 +942,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).
@@ -1023,31 +986,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
@@ -1078,7 +1041,7 @@ If there are no DNS servers, it returns an error message:
}
```
If there are DNS servers:
If there are DNS servers:
```
{
"magicDNS":true,
@@ -1087,8 +1050,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.
@@ -1099,7 +1062,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:
@@ -1111,7 +1074,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
@@ -1120,7 +1083,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"]
}
```

View File

@@ -45,25 +45,4 @@ EOF
exit 0
fi
tags=""
ldflags="-X tailscale.com/version.Long=${LONG} -X tailscale.com/version.Short=${SHORT} -X tailscale.com/version.GitCommit=${GIT_HASH}"
# build_dist.sh arguments must precede go build arguments.
while [ "$#" -gt 1 ]; do
case "$1" in
--extra-small)
shift
ldflags="$ldflags -w -s"
tags="${tags:+$tags,}ts_omit_aws"
;;
--box)
shift
tags="${tags:+$tags,}ts_include_cli"
;;
*)
break
;;
esac
done
exec ./tool/go build ${tags:+-tags=$tags} -ldflags "$ldflags" "$@"
exec ./tool/go build -ldflags "-X tailscale.com/version.Long=${LONG} -X tailscale.com/version.Short=${SHORT} -X tailscale.com/version.GitCommit=${GIT_HASH}" "$@"

View File

@@ -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.16"
DEFAULT_BASE="ghcr.io/tailscale/alpine-base:3.14"
PUSH="${PUSH:-false}"
REPOS="${REPOS:-${DEFAULT_REPOS}}"
TAGS="${TAGS:-${DEFAULT_TAGS}}"
BASE="${BASE:-${DEFAULT_BASE}}"
go run github.com/tailscale/mkctr \
go run github.com/tailscale/mkctr@latest \
--gopaths="\
tailscale.com/cmd/tailscale:/usr/local/bin/tailscale, \
tailscale.com/cmd/tailscaled:/usr/local/bin/tailscaled" \
@@ -40,9 +40,7 @@ go run github.com/tailscale/mkctr \
-X tailscale.com/version.Long=${VERSION_LONG} \
-X tailscale.com/version.Short=${VERSION_SHORT} \
-X tailscale.com/version.GitCommit=${VERSION_GIT_HASH}" \
--files="docs/k8s/run.sh:/tailscale/run.sh" \
--base="${BASE}" \
--tags="${TAGS}" \
--repos="${REPOS}" \
--push="${PUSH}" \
/bin/sh /tailscale/run.sh
--push="${PUSH}"

View File

@@ -1,477 +0,0 @@
// 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/netip"
)
// 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 netip.AddrPort) (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
}

View File

@@ -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 and control plane API.
// Package apitype contains types for the Tailscale local API.
package apitype
import "tailscale.com/tailcfg"

View File

@@ -1,20 +0,0 @@
// 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"`
}

View File

@@ -1,262 +0,0 @@
// 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
}

View File

@@ -1,235 +0,0 @@
// 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
}

View File

@@ -1,711 +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.
//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/netip"
"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"
)
// 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 netip.Addr, 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?"
}

View File

@@ -1,97 +0,0 @@
// 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/netip"
)
// 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 []netip.Prefix `json:"advertisedRoutes"`
EnabledRoutes []netip.Prefix `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 []netip.Prefix `json:"routes"`
}
// SetRoutes updates the list of subnets that are enabled for a device.
// Subnets must be parsable by net/netip.ParsePrefix.
// 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 []netip.Prefix) (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
}

View File

@@ -1,42 +0,0 @@
// 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
}

View File

@@ -1,160 +1,616 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// 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 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 contains Tailscale client code.
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"
)
// 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.
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.
//
// 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.
// URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4.
//
// 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.
// 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.
//
// Most users will use AuthKey.
type AuthMethod interface {
modifyRequest(req *http.Request)
}
// 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), "")
}
func (c *Client) setAuth(r *http.Request) {
if c.auth != nil {
c.auth.modifyRequest(r)
// 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)
}
// NewClient is a convenience method for instantiating a new Client.
//
// 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 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
}
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)
type errorJSON struct {
Error string
}
// 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")
// 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)
}
c.setAuth(req)
resp, err := c.httpClient().Do(req)
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, resp, err
return nil, 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")
res, err := doLocalRequestNiceError(req)
if err != nil {
return nil, err
}
return b, resp, 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
}
// ErrResponse is the HTTP error returned by the Tailscale server.
type ErrResponse struct {
Status int
Message string
func get200(ctx context.Context, path string) ([]byte, error) {
return send(ctx, "GET", path, 200, nil)
}
func (e ErrResponse) Error() string {
return fmt.Sprintf("Status: %d, Message: %q", e.Status, e.Message)
// 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
}
// 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 {
// 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.
//
// 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)
if err != nil {
return err
}
errResp.Status = resp.StatusCode
return errResp
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?"
}

View File

@@ -22,11 +22,13 @@ 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")
)
@@ -41,18 +43,30 @@ func main() {
}
typeNames := strings.Split(*flagTypes, ",")
pkg, namedTypes, err := codegen.LoadTypes(*flagBuildTags, ".")
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, ".")
if err != nil {
log.Fatal(err)
}
it := codegen.NewImportTracker(pkg.Types)
if len(pkgs) != 1 {
log.Fatalf("wrong number of packages: %d", len(pkgs))
}
pkg := pkgs[0]
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, it, typ)
gen(buf, imports, typ, pkg.Types)
}
w := func(format string, args ...any) {
@@ -79,13 +93,62 @@ func main() {
w(" return false")
w("}")
}
cloneOutput := pkg.Name + "_clone.go"
if err := codegen.WritePackageFile("tailscale.com/cmd/cloner", pkg, cloneOutput, it, buf); err != nil {
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 {
log.Fatal(err)
}
}
func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
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)
}
t, ok := typ.Underlying().(*types.Struct)
if !ok {
return
@@ -106,11 +169,11 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
for i := 0; i < t.NumFields(); i++ {
fname := t.Field(i).Name()
ft := t.Field(i).Type()
if !codegen.ContainsPointers(ft) || codegen.HasNoClone(t.Tag(i)) {
if !codegen.ContainsPointers(ft) {
continue
}
if named, _ := ft.(*types.Named); named != nil {
if codegen.IsViewType(ft) {
if isViewType(ft) {
writef("dst.%s = src.%s", fname, fname)
continue
}
@@ -122,16 +185,11 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
switch ft := ft.Underlying().(type) {
case *types.Slice:
if codegen.ContainsPointers(ft.Elem()) {
n := it.QualifiedName(ft.Elem())
n := importedName(ft.Elem())
writef("dst.%s = make([]%s, len(src.%s))", fname, n, fname)
writef("for i := range dst.%s {", fname)
if ptr, isPtr := ft.Elem().(*types.Pointer); isPtr {
if _, isBasic := ptr.Elem().Underlying().(*types.Basic); isBasic {
writef("\tx := *src.%s[i]", fname)
writef("\tdst.%s[i] = &x", fname)
} else {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
}
if _, isPtr := ft.Elem().(*types.Pointer); isPtr {
writef("\tdst.%s[i] = src.%s[i].Clone()", fname, fname)
} else {
writef("\tdst.%s[i] = *src.%s[i].Clone()", fname, fname)
}
@@ -144,7 +202,7 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("dst.%s = src.%s.Clone()", fname, fname)
continue
}
n := it.QualifiedName(ft.Elem())
n := importedName(ft.Elem())
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = new(%s)", fname, n)
writef("\t*dst.%s = *src.%s", fname, fname)
@@ -154,9 +212,9 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("}")
case *types.Map:
writef("if dst.%s != nil {", fname)
writef("\tdst.%s = map[%s]%s{}", fname, it.QualifiedName(ft.Key()), it.QualifiedName(ft.Elem()))
writef("\tdst.%s = map[%s]%s{}", fname, importedName(ft.Key()), importedName(ft.Elem()))
if sliceType, isSlice := ft.Elem().(*types.Slice); isSlice {
n := it.QualifiedName(sliceType.Elem())
n := importedName(sliceType.Elem())
writef("\tfor k := range src.%s {", fname)
// use zero-length slice instead of nil to ensure
// the key is always copied.
@@ -179,10 +237,20 @@ func gen(buf *bytes.Buffer, it *codegen.ImportTracker, typ *types.Named) {
writef("return dst")
fmt.Fprintf(buf, "}\n\n")
buf.Write(codegen.AssertStructUnchanged(t, name, "Clone", it))
buf.Write(codegen.AssertStructUnchanged(t, 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() == "ж"
}
// 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:

View File

@@ -29,6 +29,7 @@ import (
"tailscale.com/atomicfile"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/logpolicy"
"tailscale.com/metrics"
"tailscale.com/net/stun"
"tailscale.com/tsweb"
@@ -36,15 +37,16 @@ import (
)
var (
dev = flag.Bool("dev", false, "run in localhost development mode")
addr = flag.String("a", ":443", "server HTTPS listen address, in form \":port\", \"ip:port\", or for IPv6 \"[ip]:port\". If the IP is omitted, it defaults to all interfaces.")
httpPort = flag.Int("http-port", 80, "The port on which to serve HTTP. Set to -1 to disable. The listener is bound to the same IP (if any) as specified in the -a flag.")
stunPort = flag.Int("stun-port", 3478, "The UDP port on which to serve STUN. The listener is bound to the same IP (if any) as specified in the -a flag.")
configPath = flag.String("c", "", "config file path")
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt")
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443")
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443")
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
dev = flag.Bool("dev", false, "run in localhost development mode")
addr = flag.String("a", ":443", "server HTTPS listen address, in form \":port\", \"ip:port\", or for IPv6 \"[ip]:port\". If the IP is omitted, it defaults to all interfaces.")
httpPort = flag.Int("http-port", 80, "The port on which to serve HTTP. Set to -1 to disable. The listener is bound to the same IP (if any) as specified in the -a flag.")
stunPort = flag.Int("stun-port", 3478, "The UDP port on which to serve STUN. The listener is bound to the same IP (if any) as specified in the -a flag.")
configPath = flag.String("c", "", "config file path")
certMode = flag.String("certmode", "letsencrypt", "mode for getting a cert. possible options: manual, letsencrypt")
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443")
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443")
logCollection = flag.String("logcollection", "", "If non-empty, logtail collection to log to")
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list")
@@ -133,6 +135,7 @@ func main() {
flag.Parse()
if *dev {
*logCollection = ""
*addr = ":3340" // above the keys DERP
log.Printf("Running in dev mode.")
tsweb.DevMode = true
@@ -143,6 +146,12 @@ func main() {
log.Fatalf("invalid server address: %v", err)
}
var logPol *logpolicy.Policy
if *logCollection != "" {
logPol = logpolicy.New(*logCollection)
log.SetOutput(logPol.Logtail)
}
cfg := loadConfig()
serveTLS := tsweb.IsProd443(*addr) || *certMode == "manual"

View File

@@ -13,6 +13,7 @@ import (
"nhooyr.io/websocket"
"tailscale.com/derp"
"tailscale.com/derp/wsconn"
)
var counterWebSocketAccepts = expvar.NewInt("derp_websocket_accepts")
@@ -44,8 +45,8 @@ func addWebSocketSupport(s *derp.Server, base http.Handler) http.Handler {
return
}
counterWebSocketAccepts.Add(1)
wc := websocket.NetConn(r.Context(), c, websocket.MessageBinary)
wc := wsconn.New(c)
brw := bufio.NewReadWriter(bufio.NewReader(wc), bufio.NewWriter(wc))
s.Accept(r.Context(), wc, brw, r.RemoteAddr)
s.Accept(wc, brw, r.RemoteAddr)
})
}

View File

@@ -1 +0,0 @@
version-cache.json

View File

@@ -1,48 +0,0 @@
# 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.

View File

@@ -1,67 +0,0 @@
// 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]
}

View File

@@ -1,373 +0,0 @@
// 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()
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
}
got := resp.StatusCode
want := http.StatusOK
if got != want {
return fmt.Errorf("wanted HTTP status code %d but got %d", want, got)
}
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
}

View File

@@ -135,13 +135,13 @@ func tailscaleIP(who *apitype.WhoIsResponse) string {
return ""
}
for _, nodeIP := range who.Node.Addresses {
if nodeIP.Addr().Is4() && nodeIP.IsSingleIP() {
return nodeIP.Addr().String()
if nodeIP.IP().Is4() && nodeIP.IsSingleIP() {
return nodeIP.IP().String()
}
}
for _, nodeIP := range who.Node.Addresses {
if nodeIP.IsSingleIP() {
return nodeIP.Addr().String()
return nodeIP.IP().String()
}
}
return ""

View File

@@ -19,15 +19,10 @@ 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))
}

View File

@@ -62,12 +62,6 @@ 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)
@@ -77,20 +71,20 @@ func main() {
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
modifyRequest(req, localClient)
modifyRequest(req)
}
var ln net.Listener
if *useHTTPS {
ln, err = ts.Listen("tcp", ":443")
ln = tls.NewListener(ln, &tls.Config{
GetCertificate: localClient.GetCertificate,
GetCertificate: tailscale.GetCertificate,
})
go func() {
// wait for tailscale to start before trying to fetch cert names
for i := 0; i < 60; i++ {
st, err := localClient.Status(context.Background())
st, err := tailscale.Status(context.Background())
if err != nil {
log.Printf("error retrieving tailscale status; retrying: %v", err)
} else {
@@ -106,7 +100,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
name, ok := localClient.ExpandSNIName(context.Background(), *hostname)
name, ok := tailscale.ExpandSNIName(context.Background(), *hostname)
if !ok {
log.Fatalf("can't get hostname for https redirect")
}
@@ -126,14 +120,14 @@ func main() {
log.Fatal(http.Serve(ln, proxy))
}
func modifyRequest(req *http.Request, localClient *tailscale.LocalClient) {
func modifyRequest(req *http.Request) {
// 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(), localClient, req.RemoteAddr)
user, err := getTailscaleUser(req.Context(), req.RemoteAddr)
if err != nil {
log.Printf("error getting Tailscale user: %v", err)
return
@@ -143,8 +137,8 @@ func modifyRequest(req *http.Request, localClient *tailscale.LocalClient) {
req.Header.Set("X-Webauth-Name", user.DisplayName)
}
func getTailscaleUser(ctx context.Context, localClient *tailscale.LocalClient, ipPort string) (*tailcfg.UserProfile, error) {
whois, err := localClient.WhoIs(ctx, ipPort)
func getTailscaleUser(ctx context.Context, ipPort string) (*tailcfg.UserProfile, error) {
whois, err := tailscale.WhoIs(ctx, ipPort)
if err != nil {
return nil, fmt.Errorf("failed to identify remote host: %w", err)
}

View File

@@ -7,9 +7,9 @@ package cli
import (
"context"
"errors"
"fmt"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
)
var bugReportCmd = &ffcli.Command{
@@ -28,10 +28,10 @@ func runBugReport(ctx context.Context, args []string) error {
default:
return errors.New("unknown argumets")
}
logMarker, err := localClient.BugReport(ctx, note)
logMarker, err := tailscale.BugReport(ctx, note)
if err != nil {
return err
}
fmt.Println(logMarker)
outln(logMarker)
return nil
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/atomicfile"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/version"
)
@@ -27,7 +28,7 @@ var certCmd = &ffcli.Command{
ShortHelp: "get TLS certs",
ShortUsage: "cert [flags] <domain>",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("cert", flag.ExitOnError)
fs := newFlagSet("cert")
fs.StringVar(&certArgs.certFile, "cert-file", "", "output cert file or \"-\" for stdout; defaults to DOMAIN.crt if --cert-file and --key-file are both unset")
fs.StringVar(&certArgs.keyFile, "key-file", "", "output cert file or \"-\" for stdout; defaults to DOMAIN.key if --cert-file and --key-file are both unset")
fs.BoolVar(&certArgs.serve, "serve-demo", false, "if true, serve on port :443 using the cert as a demo, instead of writing out the files to disk")
@@ -45,11 +46,11 @@ func runCert(ctx context.Context, args []string) error {
if certArgs.serve {
s := &http.Server{
TLSConfig: &tls.Config{
GetCertificate: localClient.GetCertificate,
GetCertificate: tailscale.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 := localClient.ExpandSNIName(r.Context(), r.Host); ok {
if v, ok := tailscale.ExpandSNIName(r.Context(), r.Host); ok {
http.Redirect(w, r, "https://"+v+r.URL.Path, http.StatusTemporaryRedirect)
return
}
@@ -63,7 +64,7 @@ func runCert(ctx context.Context, args []string) error {
if len(args) != 1 {
var hint bytes.Buffer
if st, err := localClient.Status(ctx); err == nil {
if st, err := tailscale.Status(ctx); err == nil {
if st.BackendState != ipn.Running.String() {
fmt.Fprintf(&hint, "\nTailscale is not running.\n")
} else if len(st.CertDomains) == 0 {
@@ -79,7 +80,7 @@ func runCert(ctx context.Context, args []string) error {
domain := args[0]
printf := func(format string, a ...any) {
fmt.Printf(format, a...)
printf(format, a...)
}
if certArgs.certFile == "-" || certArgs.keyFile == "-" {
printf = log.Printf
@@ -89,7 +90,7 @@ func runCert(ctx context.Context, args []string) error {
certArgs.certFile = domain + ".crt"
certArgs.keyFile = domain + ".key"
}
certPEM, keyPEM, err := localClient.CertPair(ctx, domain)
certPEM, keyPEM, err := tailscale.CertPair(ctx, domain)
if err != nil {
return err
}
@@ -138,7 +139,7 @@ func runCert(ctx context.Context, args []string) error {
func writeIfChanged(filename string, contents []byte, mode os.FileMode) (changed bool, err error) {
if filename == "-" {
os.Stdout.Write(contents)
Stdout.Write(contents)
return false, nil
}
if old, err := os.ReadFile(filename); err == nil && bytes.Equal(contents, old) {

View File

@@ -33,6 +33,22 @@ import (
"tailscale.com/version/distro"
)
var Stderr io.Writer = os.Stderr
var Stdout io.Writer = os.Stdout
func printf(format string, a ...any) {
fmt.Fprintf(Stdout, format, a...)
}
// outln is like fmt.Println in the common case, except when Stdout is
// changed (as in js/wasm).
//
// It's not named println because that looks like the Go built-in
// which goes to stderr and formats slightly differently.
func outln(a ...any) {
fmt.Fprintln(Stdout, a...)
}
// ActLikeCLI reports whether a GUI application should act like the
// CLI based on os.Args, GOOS, the context the process is running in
// (pty, parent PID), etc.
@@ -79,6 +95,16 @@ func ActLikeCLI() bool {
return false
}
func newFlagSet(name string) *flag.FlagSet {
onError := flag.ExitOnError
if runtime.GOOS == "js" {
onError = flag.ContinueOnError
}
fs := flag.NewFlagSet(name, onError)
fs.SetOutput(Stderr)
return fs
}
// CleanUpArgs rewrites command line arguments for simplicity and backwards compatibility.
// In particular, it rewrites --authkey to --auth-key.
func CleanUpArgs(args []string) []string {
@@ -99,12 +125,8 @@ 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"}
}
@@ -112,11 +134,11 @@ func Run(args []string) (err error) {
var warnOnce sync.Once
tailscale.SetVersionMismatchHandler(func(clientVer, serverVer string) {
warnOnce.Do(func() {
fmt.Fprintf(os.Stderr, "Warning: client version %q != tailscaled server version %q\n", clientVer, serverVer)
fmt.Fprintf(Stderr, "Warning: client version %q != tailscaled server version %q\n", clientVer, serverVer)
})
})
rootfs := flag.NewFlagSet("tailscale", flag.ExitOnError)
rootfs := newFlagSet("tailscale")
rootfs.StringVar(&rootArgs.socket, "socket", paths.DefaultTailscaledSocket(), "path to tailscaled's unix socket")
rootCmd := &ffcli.Command{
@@ -171,10 +193,10 @@ change in the future.
return err
}
localClient.Socket = rootArgs.socket
tailscale.TailscaledSocket = rootArgs.socket
rootfs.Visit(func(f *flag.Flag) {
if f.Name == "socket" {
localClient.UseSocketOnly = true
tailscale.TailscaledSocketSetExplicitly = true
}
})

View File

@@ -9,13 +9,13 @@ import (
"encoding/json"
"flag"
"fmt"
"net/netip"
"reflect"
"strings"
"testing"
qt "github.com/frankban/quicktest"
"github.com/google/go-cmp/cmp"
"inet.af/netaddr"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tstest"
@@ -56,7 +56,7 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
flags []string // argv to be parsed by FlagSet
curPrefs *ipn.Prefs
curExitNodeIP netip.Addr
curExitNodeIP netaddr.IP
curUser string // os.Getenv("USER") on the client side
goos string // empty means "linux"
distro distro.Distro
@@ -152,10 +152,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("10.0.42.0/24"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("10.0.42.0/24"),
netaddr.MustParseIPPrefix("0.0.0.0/0"),
netaddr.MustParseIPPrefix("::/0"),
},
},
want: accidentalUpPrefix + " --advertise-routes=10.0.42.0/24 --advertise-exit-node",
@@ -168,10 +168,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("10.0.42.0/24"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("10.0.42.0/24"),
netaddr.MustParseIPPrefix("0.0.0.0/0"),
netaddr.MustParseIPPrefix("::/0"),
},
},
want: "",
@@ -184,10 +184,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("10.0.42.0/24"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("10.0.42.0/24"),
netaddr.MustParseIPPrefix("0.0.0.0/0"),
netaddr.MustParseIPPrefix("::/0"),
},
},
want: "",
@@ -212,8 +212,8 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("1.2.0.0/16"),
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("1.2.0.0/16"),
},
},
want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16",
@@ -226,10 +226,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
netip.MustParsePrefix("1.2.0.0/16"),
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("0.0.0.0/0"),
netaddr.MustParseIPPrefix("::/0"),
netaddr.MustParseIPPrefix("1.2.0.0/16"),
},
},
want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16",
@@ -255,16 +255,16 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
ControlURL: ipn.DefaultControlURL,
RouteAll: true,
AllowSingleHosts: false,
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
ExitNodeIP: netaddr.MustParseIP("100.64.5.6"),
CorpDNS: false,
ShieldsUp: true,
AdvertiseTags: []string{"tag:foo", "tag:bar"},
Hostname: "myhostname",
ForceDaemon: true,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/16"),
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("10.0.0.0/16"),
netaddr.MustParseIPPrefix("0.0.0.0/0"),
netaddr.MustParseIPPrefix("::/0"),
},
NetfilterMode: preftype.NetfilterNoDivert,
OperatorUser: "alice",
@@ -280,14 +280,14 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
ControlURL: ipn.DefaultControlURL,
RouteAll: true,
AllowSingleHosts: false,
ExitNodeIP: netip.MustParseAddr("100.64.5.6"),
ExitNodeIP: netaddr.MustParseIP("100.64.5.6"),
CorpDNS: false,
ShieldsUp: true,
AdvertiseTags: []string{"tag:foo", "tag:bar"},
Hostname: "myhostname",
ForceDaemon: true,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/16"),
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("10.0.0.0/16"),
},
NetfilterMode: preftype.NetfilterNoDivert,
OperatorUser: "alice",
@@ -344,10 +344,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
netip.MustParsePrefix("1.2.0.0/16"),
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("0.0.0.0/0"),
netaddr.MustParseIPPrefix("::/0"),
netaddr.MustParseIPPrefix("1.2.0.0/16"),
},
},
want: accidentalUpPrefix + " --operator=expbits --advertise-exit-node --advertise-routes=1.2.0.0/16",
@@ -360,10 +360,10 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
AllowSingleHosts: true,
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
netip.MustParsePrefix("1.2.0.0/16"),
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("0.0.0.0/0"),
netaddr.MustParseIPPrefix("::/0"),
netaddr.MustParseIPPrefix("1.2.0.0/16"),
},
},
want: accidentalUpPrefix + " --advertise-routes=1.2.0.0/16 --operator=expbits --advertise-exit-node",
@@ -391,14 +391,14 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
CorpDNS: true,
NetfilterMode: preftype.NetfilterOn,
ExitNodeIP: netip.MustParseAddr("100.64.5.4"),
ExitNodeIP: netaddr.MustParseIP("100.64.5.4"),
},
want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.4",
},
{
name: "error_exit_node_omit_with_id_pref",
flags: []string{"--hostname=foo"},
curExitNodeIP: netip.MustParseAddr("100.64.5.7"),
curExitNodeIP: netaddr.MustParseIP("100.64.5.7"),
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
@@ -412,7 +412,7 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
{
name: "error_exit_node_and_allow_lan_omit_with_id_pref", // Isue 3480
flags: []string{"--hostname=foo"},
curExitNodeIP: netip.MustParseAddr("100.2.3.4"),
curExitNodeIP: netaddr.MustParseIP("100.2.3.4"),
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AllowSingleHosts: true,
@@ -493,16 +493,14 @@ func TestCheckForAccidentalSettingReverts(t *testing.T) {
if err != nil {
t.Fatal(err)
}
upEnv := upCheckEnv{
applyImplicitPrefs(newPrefs, tt.curPrefs, tt.curUser)
var got string
if err := checkForAccidentalSettingReverts(newPrefs, tt.curPrefs, upCheckEnv{
goos: goos,
flagSet: flagSet,
curExitNodeIP: tt.curExitNodeIP,
distro: tt.distro,
user: tt.curUser,
}
applyImplicitPrefs(newPrefs, tt.curPrefs, upEnv)
var got string
if err := checkForAccidentalSettingReverts(newPrefs, tt.curPrefs, upEnv); err != nil {
}); err != nil {
got = err.Error()
}
if strings.TrimSpace(got) != tt.want {
@@ -562,9 +560,9 @@ func TestPrefsFromUpArgs(t *testing.T) {
WantRunning: true,
AllowSingleHosts: true,
CorpDNS: true,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/0"),
netip.MustParsePrefix("::/0"),
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("0.0.0.0/0"),
netaddr.MustParseIPPrefix("::/0"),
},
NetfilterMode: preftype.NetfilterOn,
},
@@ -631,7 +629,7 @@ func TestPrefsFromUpArgs(t *testing.T) {
exitNodeIP: "100.105.106.107",
},
st: &ipnstate.Status{
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.105.106.107")},
TailscaleIPs: []netaddr.IP{netaddr.MustParseIP("100.105.106.107")},
},
wantErr: `cannot use 100.105.106.107 as an exit node as it is a local IP address to this machine; did you mean --advertise-exit-node?`,
},
@@ -666,13 +664,13 @@ 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,
AdvertiseRoutes: []netip.Prefix{
netip.MustParsePrefix("fd7a:115c:a1e0:b1a::bb:10.0.0.0/112"),
WantRunning: true,
NoSNAT: true,
AdvertiseRoutes: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("fd7a:115c:a1e0:b1a::bb:10.0.0.0/112"),
},
},
},
@@ -681,7 +679,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",
},
@@ -690,7 +688,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",
},
@@ -786,10 +784,6 @@ 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
@@ -891,28 +885,6 @@ 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) {
@@ -937,9 +909,6 @@ 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)
}
@@ -956,7 +925,7 @@ func TestUpdatePrefs(t *testing.T) {
}
}
var cmpIP = cmp.Comparer(func(a, b netip.Addr) bool {
var cmpIP = cmp.Comparer(func(a, b netaddr.IP) bool {
return a == b
})

View File

@@ -31,7 +31,7 @@ permission to use it.
See: https://tailscale.com/kb/1152/synology-outbound/
`),
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("configure-host", flag.ExitOnError)
fs := newFlagSet("configure-host")
return fs
})(),
}

View File

@@ -15,9 +15,6 @@ import (
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"os"
"runtime"
"strconv"
@@ -25,14 +22,13 @@ import (
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/control/controlhttp"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"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{
@@ -40,7 +36,7 @@ var debugCmd = &ffcli.Command{
Exec: runDebug,
LongHelp: `"tailscale debug" contains misc debug facilities; it is not a stable interface.`,
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("debug", flag.ExitOnError)
fs := newFlagSet("debug")
fs.StringVar(&debugArgs.file, "file", "", "get, delete:NAME, or NAME")
fs.StringVar(&debugArgs.cpuFile, "cpu-profile", "", "if non-empty, grab a CPU profile for --profile-sec seconds and write it to this file; - for stdout")
fs.StringVar(&debugArgs.memFile, "mem-profile", "", "if non-empty, grab a memory profile and write it to this file; - for stdout")
@@ -63,7 +59,7 @@ var debugCmd = &ffcli.Command{
Exec: runDaemonMetrics,
ShortHelp: "print tailscaled's metrics",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("metrics", flag.ExitOnError)
fs := newFlagSet("metrics")
fs.BoolVar(&metricsArgs.watch, "watch", false, "print JSON dump of delta values")
return fs
})(),
@@ -73,11 +69,6 @@ var debugCmd = &ffcli.Command{
Exec: runEnv,
ShortHelp: "print cmd/tailscale environment",
},
{
Name: "stat",
Exec: runStat,
ShortHelp: "stat a file",
},
{
Name: "hostinfo",
Exec: runHostinfo,
@@ -103,7 +94,7 @@ var debugCmd = &ffcli.Command{
Exec: runPrefs,
ShortHelp: "print prefs",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("prefs", flag.ExitOnError)
fs := newFlagSet("prefs")
fs.BoolVar(&prefsArgs.pretty, "pretty", false, "If true, pretty-print output")
return fs
})(),
@@ -113,7 +104,7 @@ var debugCmd = &ffcli.Command{
Exec: runWatchIPN,
ShortHelp: "subscribe to IPN message bus",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("watch-ipn", flag.ExitOnError)
fs := newFlagSet("watch-ipn")
fs.BoolVar(&watchIPNArgs.netmap, "netmap", true, "include netmap in messages")
return fs
})(),
@@ -123,17 +114,6 @@ 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 := flag.NewFlagSet("ts2021", flag.ExitOnError)
fs.StringVar(&ts2021Args.host, "host", "controlplane.tailscale.com", "hostname of control plane")
fs.IntVar(&ts2021Args.version, "version", int(tailcfg.CurrentCapabilityVersion), "protocol version")
return fs
})(),
},
},
}
@@ -146,7 +126,7 @@ var debugArgs struct {
func writeProfile(dst string, v []byte) error {
if dst == "-" {
_, err := os.Stdout.Write(v)
_, err := Stdout.Write(v)
return err
}
return os.WriteFile(dst, v, 0600)
@@ -170,7 +150,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 := localClient.Profile(ctx, "profile", debugArgs.cpuSec); err != nil {
if v, err := tailscale.Profile(ctx, "profile", debugArgs.cpuSec); err != nil {
return err
} else {
if err := writeProfile(out, v); err != nil {
@@ -182,7 +162,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 := localClient.Profile(ctx, "heap", 0); err != nil {
if v, err := tailscale.Profile(ctx, "heap", 0); err != nil {
return err
} else {
if err := writeProfile(out, v); err != nil {
@@ -194,25 +174,25 @@ func runDebug(ctx context.Context, args []string) error {
if debugArgs.file != "" {
usedFlag = true // TODO(bradfitz): add "file" subcommand
if debugArgs.file == "get" {
wfs, err := localClient.WaitingFiles(ctx)
wfs, err := tailscale.WaitingFiles(ctx)
if err != nil {
fatalf("%v\n", err)
}
e := json.NewEncoder(os.Stdout)
e := json.NewEncoder(Stdout)
e.SetIndent("", "\t")
e.Encode(wfs)
return nil
}
delete := strings.HasPrefix(debugArgs.file, "delete:")
if delete {
return localClient.DeleteWaitingFile(ctx, strings.TrimPrefix(debugArgs.file, "delete:"))
return tailscale.DeleteWaitingFile(ctx, strings.TrimPrefix(debugArgs.file, "delete:"))
}
rc, size, err := localClient.GetWaitingFile(ctx, debugArgs.file)
rc, size, err := tailscale.GetWaitingFile(ctx, debugArgs.file)
if err != nil {
return err
}
log.Printf("Size: %v\n", size)
io.Copy(os.Stdout, rc)
io.Copy(Stdout, rc)
return nil
}
if usedFlag {
@@ -226,14 +206,14 @@ func runDebug(ctx context.Context, args []string) error {
func runLocalCreds(ctx context.Context, args []string) error {
port, token, err := safesocket.LocalTCPPortAndToken()
if err == nil {
fmt.Printf("curl -u:%s http://localhost:%d/localapi/v0/status\n", token, port)
printf("curl -u:%s http://localhost:%d/localapi/v0/status\n", token, port)
return nil
}
if runtime.GOOS == "windows" {
fmt.Printf("curl http://localhost:%v/localapi/v0/status\n", safesocket.WindowsLocalPort)
printf("curl http://localhost:%v/localapi/v0/status\n", safesocket.WindowsLocalPort)
return nil
}
fmt.Printf("curl --unix-socket %s http://foo/localapi/v0/status\n", paths.DefaultTailscaledSocket())
printf("curl --unix-socket %s http://foo/localapi/v0/status\n", paths.DefaultTailscaledSocket())
return nil
}
@@ -242,15 +222,15 @@ var prefsArgs struct {
}
func runPrefs(ctx context.Context, args []string) error {
prefs, err := localClient.GetPrefs(ctx)
prefs, err := tailscale.GetPrefs(ctx)
if err != nil {
return err
}
if prefsArgs.pretty {
fmt.Println(prefs.Pretty())
outln(prefs.Pretty())
} else {
j, _ := json.MarshalIndent(prefs, "", "\t")
fmt.Println(string(j))
outln(string(j))
}
return nil
}
@@ -268,7 +248,7 @@ func runWatchIPN(ctx context.Context, args []string) error {
n.NetMap = nil
}
j, _ := json.MarshalIndent(n, "", "\t")
fmt.Printf("%s\n", j)
printf("%s\n", j)
})
bc.RequestEngineStatus()
pump(ctx, bc, c)
@@ -276,13 +256,13 @@ func runWatchIPN(ctx context.Context, args []string) error {
}
func runDERPMap(ctx context.Context, args []string) error {
dm, err := localClient.CurrentDERPMap(ctx)
dm, err := tailscale.CurrentDERPMap(ctx)
if err != nil {
return fmt.Errorf(
"failed to get local derp map, instead `curl %s/derpmap/default`: %w", ipn.DefaultControlURL, err,
)
}
enc := json.NewEncoder(os.Stdout)
enc := json.NewEncoder(Stdout)
enc.SetIndent("", "\t")
enc.Encode(dm)
return nil
@@ -293,35 +273,13 @@ func localAPIAction(action string) func(context.Context, []string) error {
if len(args) > 0 {
return errors.New("unexpected arguments")
}
return localClient.DebugAction(ctx, action)
return tailscale.DebugAction(ctx, action)
}
}
func runEnv(ctx context.Context, args []string) error {
for _, e := range os.Environ() {
fmt.Println(e)
}
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())
}
}
outln(e)
}
return nil
}
@@ -334,11 +292,11 @@ func runHostinfo(ctx context.Context, args []string) error {
}
func runDaemonGoroutines(ctx context.Context, args []string) error {
goroutines, err := localClient.Goroutines(ctx)
goroutines, err := tailscale.Goroutines(ctx)
if err != nil {
return err
}
os.Stdout.Write(goroutines)
Stdout.Write(goroutines)
return nil
}
@@ -349,12 +307,12 @@ var metricsArgs struct {
func runDaemonMetrics(ctx context.Context, args []string) error {
last := map[string]int64{}
for {
out, err := localClient.DaemonMetrics(ctx)
out, err := tailscale.DaemonMetrics(ctx)
if err != nil {
return err
}
if !metricsArgs.watch {
os.Stdout.Write(out)
Stdout.Write(out)
return nil
}
bs := bufio.NewScanner(bytes.NewReader(out))
@@ -391,9 +349,9 @@ func runDaemonMetrics(ctx context.Context, args []string) error {
if len(changes) > 0 {
format := fmt.Sprintf("%%-%ds %%+5d => %%v\n", maxNameLen)
for _, c := range changes {
fmt.Fprintf(os.Stdout, format, c.name, c.to-c.from, c.to)
fmt.Fprintf(Stdout, format, c.name, c.to-c.from, c.to)
}
io.WriteString(os.Stdout, "\n")
io.WriteString(Stdout, "\n")
}
time.Sleep(time.Second)
}
@@ -404,23 +362,23 @@ func runVia(ctx context.Context, args []string) error {
default:
return errors.New("expect either <site-id> <v4-cidr> or <v6-route>")
case 1:
ipp, err := netip.ParsePrefix(args[0])
ipp, err := netaddr.ParseIPPrefix(args[0])
if err != nil {
return err
}
if !ipp.Addr().Is6() {
if !ipp.IP().Is6() {
return errors.New("with one argument, expect an IPv6 CIDR")
}
if !tsaddr.TailscaleViaRange().Contains(ipp.Addr()) {
if !tsaddr.TailscaleViaRange().Contains(ipp.IP()) {
return errors.New("not a via route")
}
if ipp.Bits() < 96 {
return errors.New("short length, want /96 or more")
}
v4 := tsaddr.UnmapVia(ipp.Addr())
a := ipp.Addr().As16()
v4 := tsaddr.UnmapVia(ipp.IP())
a := ipp.IP().As16()
siteID := binary.BigEndian.Uint32(a[8:12])
fmt.Printf("site %v (0x%x), %v\n", siteID, siteID, netip.PrefixFrom(v4, ipp.Bits()-96))
fmt.Printf("site %v (0x%x), %v\n", siteID, siteID, netaddr.IPPrefixFrom(v4, ipp.Bits()-96))
case 2:
siteID, err := strconv.ParseUint(args[0], 0, 32)
if err != nil {
@@ -429,7 +387,7 @@ func runVia(ctx context.Context, args []string) error {
if siteID > 0xff {
return fmt.Errorf("site-id values over 255 are currently reserved")
}
ipp, err := netip.ParsePrefix(args[1])
ipp, err := netaddr.ParseIPPrefix(args[1])
if err != nil {
return err
}
@@ -441,67 +399,3 @@ 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
}

View File

@@ -6,11 +6,10 @@ package cli
import (
"context"
"flag"
"fmt"
"os"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
)
@@ -19,14 +18,7 @@ var downCmd = &ffcli.Command{
ShortUsage: "down",
ShortHelp: "Disconnect from Tailscale",
Exec: runDown,
FlagSet: newDownFlagSet(),
}
func newDownFlagSet() *flag.FlagSet {
downf := flag.NewFlagSet("down", flag.ExitOnError)
registerAcceptRiskFlag(downf)
return downf
Exec: runDown,
}
func runDown(ctx context.Context, args []string) error {
@@ -34,21 +26,15 @@ func runDown(ctx context.Context, args []string) error {
return fmt.Errorf("too many non-flag arguments: %q", args)
}
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)
st, err := tailscale.Status(ctx)
if err != nil {
return fmt.Errorf("error fetching current status: %w", err)
}
if st.BackendState == "Stopped" {
fmt.Fprintf(os.Stderr, "Tailscale was already stopped.\n")
fmt.Fprintf(Stderr, "Tailscale was already stopped.\n")
return nil
}
_, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
_, err = tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
WantRunning: false,
},

View File

@@ -14,7 +14,6 @@ import (
"log"
"mime"
"net/http"
"net/netip"
"os"
"path"
"path/filepath"
@@ -24,6 +23,8 @@ 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"
@@ -54,7 +55,7 @@ var fileCpCmd = &ffcli.Command{
ShortHelp: "Copy file(s) to a host",
Exec: runCp,
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("cp", flag.ExitOnError)
fs := newFlagSet("cp")
fs.StringVar(&cpArgs.name, "name", "", "alternate filename to use, especially useful when <file> is \"-\" (stdin)")
fs.BoolVar(&cpArgs.verbose, "verbose", false, "verbose output")
fs.BoolVar(&cpArgs.targets, "targets", false, "list possible file cp targets")
@@ -85,7 +86,7 @@ func runCp(ctx context.Context, args []string) error {
hadBrackets = true
target = strings.TrimSuffix(strings.TrimPrefix(target, "["), "]")
}
if ip, err := netip.ParseAddr(target); err == nil && ip.Is6() && !hadBrackets {
if ip, err := netaddr.ParseIP(target); err == nil && ip.Is6() && !hadBrackets {
return fmt.Errorf("an IPv6 literal must be written as [%s]", ip)
} else if hadBrackets && (err != nil || !ip.Is6()) {
return errors.New("unexpected brackets around target")
@@ -100,7 +101,7 @@ func runCp(ctx context.Context, args []string) error {
return fmt.Errorf("can't send to %s: %v", target, err)
}
if isOffline {
fmt.Fprintf(os.Stderr, "# warning: %s is offline\n", target)
fmt.Fprintf(Stderr, "# warning: %s is offline\n", target)
}
if len(files) > 1 {
@@ -156,7 +157,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 := localClient.PushFile(ctx, stableID, contentLength, name, fileContents)
err := tailscale.PushFile(ctx, stableID, contentLength, name, fileContents)
if err != nil {
return err
}
@@ -168,18 +169,18 @@ func runCp(ctx context.Context, args []string) error {
}
func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNodeID, isOffline bool, err error) {
ip, err := netip.ParseAddr(ipStr)
ip, err := netaddr.ParseIP(ipStr)
if err != nil {
return "", false, err
}
fts, err := localClient.FileTargets(ctx)
fts, err := tailscale.FileTargets(ctx)
if err != nil {
return "", false, err
}
for _, ft := range fts {
n := ft.Node
for _, a := range n.Addresses {
if a.Addr() != ip {
if a.IP() != ip {
continue
}
isOffline = n.Online != nil && !*n.Online
@@ -191,9 +192,9 @@ func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNode
// fileTargetErrorDetail returns a non-nil error saying why ip is an
// invalid file sharing target.
func fileTargetErrorDetail(ctx context.Context, ip netip.Addr) error {
func fileTargetErrorDetail(ctx context.Context, ip netaddr.IP) error {
found := false
if st, err := localClient.Status(ctx); err == nil && st.Self != nil {
if st, err := tailscale.Status(ctx); err == nil && st.Self != nil {
for _, peer := range st.Peer {
for _, pip := range peer.TailscaleIPs {
if pip == ip {
@@ -260,7 +261,7 @@ func runCpTargets(ctx context.Context, args []string) error {
if len(args) > 0 {
return errors.New("invalid arguments with --targets")
}
fts, err := localClient.FileTargets(ctx)
fts, err := tailscale.FileTargets(ctx)
if err != nil {
return err
}
@@ -281,7 +282,7 @@ func runCpTargets(ctx context.Context, args []string) error {
if detail != "" {
detail = "\t" + detail
}
fmt.Printf("%s\t%s%s\n", n.Addresses[0].Addr(), n.ComputedName, detail)
printf("%s\t%s%s\n", n.Addresses[0].IP(), n.ComputedName, detail)
}
return nil
}
@@ -315,7 +316,7 @@ var fileGetCmd = &ffcli.Command{
ShortHelp: "Move files out of the Tailscale file inbox",
Exec: runFileGet,
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("get", flag.ExitOnError)
fs := newFlagSet("get")
fs.BoolVar(&getArgs.wait, "wait", false, "wait for a file to arrive if inbox is empty")
fs.BoolVar(&getArgs.loop, "loop", false, "run get in a loop, receiving files as they come in")
fs.BoolVar(&getArgs.verbose, "verbose", false, "verbose output")
@@ -384,7 +385,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 := localClient.GetWaitingFile(ctx, wf.Name)
rc, size, err := tailscale.GetWaitingFile(ctx, wf.Name)
if err != nil {
return "", 0, fmt.Errorf("opening inbox file %q: %w", wf.Name, err)
}
@@ -406,7 +407,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
var err error
var errs []error
for len(errs) == 0 {
wfs, err = localClient.WaitingFiles(ctx)
wfs, err = tailscale.WaitingFiles(ctx)
if err != nil {
errs = append(errs, fmt.Errorf("getting WaitingFiles: %w", err))
break
@@ -415,7 +416,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
break
}
if getArgs.verbose {
fmt.Printf("waiting for file...")
printf("waiting for file...")
}
if err := waitForFile(ctx); err != nil {
errs = append(errs, err)
@@ -427,7 +428,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)
@@ -436,9 +437,9 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
continue
}
if getArgs.verbose {
fmt.Printf("wrote %v as %v (%d bytes)\n", wf.Name, writtenFile, size)
printf("wrote %v as %v (%d bytes)\n", wf.Name, writtenFile, size)
}
if err = localClient.DeleteWaitingFile(ctx, wf.Name); err != nil {
if err = tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil {
errs = append(errs, fmt.Errorf("deleting %q from inbox: %v", wf.Name, err))
continue
}
@@ -448,7 +449,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error {
// persistently stuck files are basically an error
errs = append(errs, fmt.Errorf("moved %d/%d files", deleted, len(wfs)))
} else if getArgs.verbose {
fmt.Printf("moved %d/%d files\n", deleted, len(wfs))
printf("moved %d/%d files\n", deleted, len(wfs))
}
return errs
}
@@ -471,7 +472,7 @@ func runFileGet(ctx context.Context, args []string) error {
for {
errs := runFileGetOneBatch(ctx, dir)
for _, err := range errs {
fmt.Println(err)
outln(err)
}
if len(errs) > 0 {
// It's possible whatever caused the error(s) (e.g. conflicting target file,
@@ -493,7 +494,7 @@ func runFileGet(ctx context.Context, args []string) error {
return nil
}
for _, err := range errs[:len(errs)-1] {
fmt.Println(err)
outln(err)
}
return errs[len(errs)-1]
}
@@ -502,7 +503,7 @@ func wipeInbox(ctx context.Context) error {
if getArgs.wait {
return errors.New("can't use --wait with /dev/null target")
}
wfs, err := localClient.WaitingFiles(ctx)
wfs, err := tailscale.WaitingFiles(ctx)
if err != nil {
return fmt.Errorf("getting WaitingFiles: %w", err)
}
@@ -511,7 +512,7 @@ func wipeInbox(ctx context.Context) error {
if getArgs.verbose {
log.Printf("deleting %v ...", wf.Name)
}
if err := localClient.DeleteWaitingFile(ctx, wf.Name); err != nil {
if err := tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil {
return fmt.Errorf("deleting %q: %v", wf.Name, err)
}
deleted++

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
)
var idTokenCmd = &ffcli.Command{
@@ -24,7 +25,7 @@ func runIDToken(ctx context.Context, args []string) error {
return errors.New("usage: id-token <aud>")
}
tr, err := localClient.IDToken(ctx, args[0])
tr, err := tailscale.IDToken(ctx, args[0])
if err != nil {
return err
}

View File

@@ -9,9 +9,10 @@ import (
"errors"
"flag"
"fmt"
"net/netip"
"github.com/peterbourgon/ff/v3/ffcli"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/ipn/ipnstate"
)
@@ -22,7 +23,7 @@ var ipCmd = &ffcli.Command{
LongHelp: "Show Tailscale IP addresses for peer. Peer defaults to the current machine.",
Exec: runIP,
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("ip", flag.ExitOnError)
fs := newFlagSet("ip")
fs.BoolVar(&ipArgs.want1, "1", false, "only print one IP address")
fs.BoolVar(&ipArgs.want4, "4", false, "only print IPv4 address")
fs.BoolVar(&ipArgs.want6, "6", false, "only print IPv6 address")
@@ -58,7 +59,7 @@ func runIP(ctx context.Context, args []string) error {
if !v4 && !v6 {
v4, v6 = true, true
}
st, err := localClient.Status(ctx)
st, err := tailscale.Status(ctx)
if err != nil {
return err
}
@@ -85,7 +86,7 @@ func runIP(ctx context.Context, args []string) error {
for _, ip := range ips {
if ip.Is4() && v4 || ip.Is6() && v6 {
match = true
fmt.Println(ip)
outln(ip)
}
}
if !match {
@@ -100,7 +101,7 @@ func runIP(ctx context.Context, args []string) error {
}
func peerMatchingIP(st *ipnstate.Status, ipStr string) (ps *ipnstate.PeerStatus, ok bool) {
ip, err := netip.ParseAddr(ipStr)
ip, err := netaddr.ParseIP(ipStr)
if err != nil {
return
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
)
var logoutCmd = &ffcli.Command{
@@ -29,5 +30,5 @@ func runLogout(ctx context.Context, args []string) error {
if len(args) > 0 {
return fmt.Errorf("too many non-flag arguments: %q", args)
}
return localClient.Logout(ctx)
return tailscale.Logout(ctx)
}

View File

@@ -13,6 +13,7 @@ import (
"strconv"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
)
var ncCmd = &ffcli.Command{
@@ -23,13 +24,13 @@ var ncCmd = &ffcli.Command{
}
func runNC(ctx context.Context, args []string) error {
st, err := localClient.Status(ctx)
st, err := tailscale.Status(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
description, ok := isRunningOrStarting(st)
if !ok {
fmt.Printf("%s\n", description)
printf("%s\n", description)
os.Exit(1)
}
@@ -44,7 +45,7 @@ func runNC(ctx context.Context, args []string) error {
}
// TODO(bradfitz): also add UDP too, via flag?
c, err := localClient.DialTCP(ctx, hostOrIP, uint16(port))
c, err := tailscale.DialTCP(ctx, hostOrIP, uint16(port))
if err != nil {
return fmt.Errorf("Dial(%q, %v): %w", hostOrIP, port, err)
}

View File

@@ -13,12 +13,12 @@ import (
"io/ioutil"
"log"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/net/netcheck"
@@ -33,7 +33,7 @@ var netcheckCmd = &ffcli.Command{
ShortHelp: "Print an analysis of local network conditions",
Exec: runNetcheck,
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("netcheck", flag.ExitOnError)
fs := newFlagSet("netcheck")
fs.StringVar(&netcheckArgs.format, "format", "", `output format; empty (for human-readable), "json" or "json-line"`)
fs.DurationVar(&netcheckArgs.every, "every", 0, "if non-zero, do an incremental report with the given frequency")
fs.BoolVar(&netcheckArgs.verbose, "verbose", false, "verbose logs")
@@ -60,10 +60,10 @@ func runNetcheck(ctx context.Context, args []string) error {
}
if strings.HasPrefix(netcheckArgs.format, "json") {
fmt.Fprintln(os.Stderr, "# Warning: this JSON format is not yet considered a stable interface")
fmt.Fprintln(Stderr, "# Warning: this JSON format is not yet considered a stable interface")
}
dm, err := localClient.CurrentDERPMap(ctx)
dm, err := tailscale.CurrentDERPMap(ctx)
noRegions := dm != nil && len(dm.Regions) == 0
if noRegions {
log.Printf("No DERP map from tailscaled; using default.")
@@ -112,38 +112,36 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
}
if j != nil {
j = append(j, '\n')
os.Stdout.Write(j)
Stdout.Write(j)
return nil
}
fmt.Printf("\nReport:\n")
fmt.Printf("\t* UDP: %v\n", report.UDP)
printf("\nReport:\n")
printf("\t* UDP: %v\n", report.UDP)
if report.GlobalV4 != "" {
fmt.Printf("\t* IPv4: yes, %v\n", report.GlobalV4)
printf("\t* IPv4: yes, %v\n", report.GlobalV4)
} else {
fmt.Printf("\t* IPv4: (no addr found)\n")
printf("\t* IPv4: (no addr found)\n")
}
if report.GlobalV6 != "" {
fmt.Printf("\t* IPv6: yes, %v\n", report.GlobalV6)
printf("\t* IPv6: yes, %v\n", report.GlobalV6)
} else if report.IPv6 {
fmt.Printf("\t* IPv6: (no addr found)\n")
} else if report.OSHasIPv6 {
fmt.Printf("\t* IPv6: no, but OS has support\n")
printf("\t* IPv6: (no addr found)\n")
} else {
fmt.Printf("\t* IPv6: no, unavailable in OS\n")
printf("\t* IPv6: no\n")
}
fmt.Printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP)
fmt.Printf("\t* HairPinning: %v\n", report.HairPinning)
fmt.Printf("\t* PortMapping: %v\n", portMapping(report))
printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP)
printf("\t* HairPinning: %v\n", report.HairPinning)
printf("\t* PortMapping: %v\n", portMapping(report))
// When DERP latency checking failed,
// magicsock will try to pick the DERP server that
// most of your other nodes are also using
if len(report.RegionLatency) == 0 {
fmt.Printf("\t* Nearest DERP: unknown (no response to latency probes)\n")
printf("\t* Nearest DERP: unknown (no response to latency probes)\n")
} else {
fmt.Printf("\t* Nearest DERP: %v\n", dm.Regions[report.PreferredDERP].RegionName)
fmt.Printf("\t* DERP latency:\n")
printf("\t* Nearest DERP: %v\n", dm.Regions[report.PreferredDERP].RegionName)
printf("\t* DERP latency:\n")
var rids []int
for rid := range dm.Regions {
rids = append(rids, rid)
@@ -170,7 +168,7 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
if netcheckArgs.verbose {
derpNum = fmt.Sprintf("derp%d, ", rid)
}
fmt.Printf("\t\t- %3s: %-7s (%s%s)\n", r.RegionCode, latency, derpNum, r.RegionName)
printf("\t\t- %3s: %-7s (%s%s)\n", r.RegionCode, latency, derpNum, r.RegionName)
}
}
return nil

View File

@@ -11,14 +11,14 @@ import (
"fmt"
"log"
"net"
"net/netip"
"os"
"strings"
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"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 from the Tailscale layer
The 'tailscale ping' command pings a peer node at 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.
@@ -46,12 +46,10 @@ relay node.
`),
Exec: runPing,
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("ping", flag.ExitOnError)
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 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.BoolVar(&pingArgs.tsmp, "tsmp", false, "do a TSMP-level ping (through IP + wireguard, but not involving host OS stack)")
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
@@ -63,39 +61,38 @@ 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 := localClient.Status(ctx)
st, err := tailscale.Status(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
description, ok := isRunningOrStarting(st)
if !ok {
fmt.Printf("%s\n", description)
printf("%s\n", description)
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)
@@ -103,7 +100,7 @@ func runPing(ctx context.Context, args []string) error {
return err
}
if self {
fmt.Printf("%v is local Tailscale IP\n", ip)
printf("%v is local Tailscale IP\n", ip)
return nil
}
@@ -115,57 +112,48 @@ func runPing(ctx context.Context, args []string) error {
anyPong := false
for {
n++
ctx, cancel := context.WithTimeout(ctx, pingArgs.timeout)
pr, err := localClient.Ping(ctx, netip.MustParseAddr(ip), pingType())
cancel()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
fmt.Printf("ping %q timed out\n", ip)
if n == pingArgs.num {
if !anyPong {
return errors.New("no reply")
}
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)
return nil
}
continue
return errors.New(pr.Err)
}
return err
}
if pr.Err != "" {
if pr.IsLocalIP {
fmt.Println(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 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
}
return errors.New(pr.Err)
if pr.Endpoint != "" && pingArgs.untilDirect {
return nil
}
time.Sleep(time.Second)
case <-ctx.Done():
return ctx.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 {
fmt.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)
}
fmt.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")
@@ -185,7 +173,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 := localClient.Status(ctx)
st, err := tailscale.Status(ctx)
if err != nil {
return "", false, err
}

View File

@@ -1,78 +0,0 @@
// 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
}

View File

@@ -10,18 +10,20 @@ import (
"errors"
"fmt"
"log"
"net/netip"
"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{
@@ -32,9 +34,6 @@ 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>")
}
@@ -49,7 +48,7 @@ func runSSH(ctx context.Context, args []string) error {
username = lu.Username
}
st, err := localClient.Status(ctx)
st, err := tailscale.Status(ctx)
if err != nil {
return err
}
@@ -62,7 +61,7 @@ func runSSH(ctx context.Context, args []string) error {
hostForSSH = v
}
ssh, err := findSSH()
ssh, err := exec.LookPath("ssh")
if err != nil {
// TODO(bradfitz): use Go's crypto/ssh client instead
// of failing. But for now:
@@ -77,49 +76,55 @@ func runSSH(ctx context.Context, args []string) error {
return err
}
argv := []string{ssh}
argv := append([]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 %q", knownHostsFile),
"-o", fmt.Sprintf("UserKnownHostsFile %s",
shellescape.Quote(knownHostsFile),
),
"-o", "UpdateHostKeys no",
"-o", "StrictHostKeyChecking yes",
)
// 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,
))
"-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
}
// 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)
}
return execSSH(ssh, argv)
if err := syscall.Exec(ssh, argv, os.Environ()); err != nil {
return err
}
return errors.New("unreachable")
}
func writeKnownHosts(st *ipnstate.Status) (knownHostsFile string, err error) {
@@ -163,10 +168,10 @@ func nodeDNSNameFromArg(st *ipnstate.Status, arg string) (dnsName string, ok boo
if arg == "" {
return
}
argIP, _ := netip.ParseAddr(arg)
argIP, _ := netaddr.ParseIP(arg)
for _, ps := range st.Peer {
dnsName = ps.DNSName
if argIP.IsValid() {
if !argIP.IsZero() {
for _, ip := range ps.TailscaleIPs {
if ip == argIP {
return dnsName, true
@@ -183,28 +188,3 @@ 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 := netip.ParseAddr(ipStr)
if err != nil {
return false
}
return tsaddr.IsTailscaleIP(ip)
}

View File

@@ -1,26 +0,0 @@
// 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 !windows
// +build !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")
}

View File

@@ -1,38 +0,0 @@
// 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
}

View File

@@ -1,51 +0,0 @@
// 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 ""
}
}

View File

@@ -13,12 +13,13 @@ import (
"fmt"
"net"
"net/http"
"net/netip"
"os"
"strings"
"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"
@@ -46,7 +47,7 @@ https://github.com/tailscale/tailscale/blob/main/ipn/ipnstate/ipnstate.go
`),
Exec: runStatus,
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("status", flag.ExitOnError)
fs := newFlagSet("status")
fs.BoolVar(&statusArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)")
fs.BoolVar(&statusArgs.web, "web", false, "run webserver with HTML showing status")
fs.BoolVar(&statusArgs.active, "active", false, "filter output to only peers with active sessions (not applicable to web mode)")
@@ -72,9 +73,9 @@ func runStatus(ctx context.Context, args []string) error {
if len(args) > 0 {
return errors.New("unexpected non-flag arguments to 'tailscale status'")
}
getStatus := localClient.Status
getStatus := tailscale.Status
if !statusArgs.peers {
getStatus = localClient.StatusWithoutPeers
getStatus = tailscale.StatusWithoutPeers
}
st, err := getStatus(ctx)
if err != nil {
@@ -92,7 +93,7 @@ func runStatus(ctx context.Context, args []string) error {
if err != nil {
return err
}
fmt.Printf("%s", j)
printf("%s", j)
return nil
}
if statusArgs.web {
@@ -101,7 +102,7 @@ func runStatus(ctx context.Context, args []string) error {
return err
}
statusURL := interfaces.HTTPOfListener(ln)
fmt.Printf("Serving Tailscale status at %v ...\n", statusURL)
printf("Serving Tailscale status at %v ...\n", statusURL)
go func() {
<-ctx.Done()
ln.Close()
@@ -114,7 +115,7 @@ func runStatus(ctx context.Context, args []string) error {
http.NotFound(w, r)
return
}
st, err := localClient.Status(ctx)
st, err := tailscale.Status(ctx)
if err != nil {
http.Error(w, err.Error(), 500)
return
@@ -128,22 +129,20 @@ func runStatus(ctx context.Context, args []string) error {
return err
}
// print health check information prior to checking LocalBackend state as
// it may provide an explanation to the user if we choose to exit early
if len(st.Health) > 0 {
fmt.Printf("# Health check:\n")
for _, m := range st.Health {
fmt.Printf("# - %s\n", m)
}
fmt.Println()
}
description, ok := isRunningOrStarting(st)
if !ok {
fmt.Println(description)
outln(description)
os.Exit(1)
}
if len(st.Health) > 0 {
printf("# Health check:\n")
for _, m := range st.Health {
printf("# - %s\n", m)
}
outln()
}
var buf bytes.Buffer
f := func(format string, a ...any) { fmt.Fprintf(&buf, format, a...) }
printPS := func(ps *ipnstate.PeerStatus) {
@@ -213,7 +212,7 @@ func runStatus(ctx context.Context, args []string) error {
printPS(ps)
}
}
os.Stdout.Write(buf.Bytes())
Stdout.Write(buf.Bytes())
return nil
}
@@ -260,7 +259,7 @@ func ownerLogin(st *ipnstate.Status, ps *ipnstate.PeerStatus) string {
return u.LoginName
}
func firstIPString(v []netip.Addr) string {
func firstIPString(v []netaddr.IP) string {
if len(v) == 0 {
return ""
}

View File

@@ -13,18 +13,18 @@ import (
"flag"
"fmt"
"log"
"net/netip"
"os"
"reflect"
"runtime"
"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 --auth-key, --force-reauth, and --qr are not
is also used. (The flags --authkey, --force-reauth, and --qr are not
considered settings that need to be re-specified when modifying
settings.)
`),
@@ -85,7 +85,7 @@ var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgs)
func inTest() bool { return flag.Lookup("test.v") != nil }
func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet {
upf := flag.NewFlagSet("up", flag.ExitOnError)
upf := newFlagSet("up")
upf.BoolVar(&upArgs.qr, "qr", false, "show QR code for login URLs")
upf.BoolVar(&upArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)")
@@ -115,8 +115,6 @@ 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
}
@@ -149,7 +147,6 @@ type upArgsT struct {
hostname string
opUser string
json bool
timeout time.Duration
}
func (a upArgsT) getAuthKey() (string, error) {
@@ -195,22 +192,22 @@ type upOutputJSON struct {
}
func warnf(format string, args ...any) {
fmt.Printf("Warning: "+format+"\n", args...)
printf("Warning: "+format+"\n", args...)
}
var (
ipv4default = netip.MustParsePrefix("0.0.0.0/0")
ipv6default = netip.MustParsePrefix("::/0")
ipv4default = netaddr.MustParseIPPrefix("0.0.0.0/0")
ipv6default = netaddr.MustParseIPPrefix("::/0")
)
func validateViaPrefix(ipp netip.Prefix) error {
func validateViaPrefix(ipp netaddr.IPPrefix) error {
if !tsaddr.IsViaPrefix(ipp) {
return fmt.Errorf("%v is not a 4-in-6 prefix", ipp)
}
if ipp.Bits() < (128 - 32) {
return fmt.Errorf("%v 4-in-6 prefix must be at least a /%v", ipp, 128-32)
}
a := ipp.Addr().As16()
a := ipp.IP().As16()
// The first 64 bits of a are the via prefix.
// The next 32 bits are the "site ID".
// The last 32 bits are the IPv4.
@@ -223,13 +220,13 @@ func validateViaPrefix(ipp netip.Prefix) error {
return nil
}
func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]netip.Prefix, error) {
routeMap := map[netip.Prefix]bool{}
func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]netaddr.IPPrefix, error) {
routeMap := map[netaddr.IPPrefix]bool{}
if advertiseRoutes != "" {
var default4, default6 bool
advroutes := strings.Split(advertiseRoutes, ",")
for _, s := range advroutes {
ipp, err := netip.ParsePrefix(s)
ipp, err := netaddr.ParseIPPrefix(s)
if err != nil {
return nil, fmt.Errorf("%q is not a valid IP address or CIDR prefix", s)
}
@@ -255,10 +252,10 @@ func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]
}
}
if advertiseDefaultRoute {
routeMap[netip.MustParsePrefix("0.0.0.0/0")] = true
routeMap[netip.MustParsePrefix("::/0")] = true
routeMap[netaddr.MustParseIPPrefix("0.0.0.0/0")] = true
routeMap[netaddr.MustParseIPPrefix("::/0")] = true
}
routes := make([]netip.Prefix, 0, len(routeMap))
routes := make([]netaddr.IPPrefix, 0, len(routeMap))
for r := range routeMap {
routes = append(routes, r)
}
@@ -266,7 +263,7 @@ func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]
if routes[i].Bits() != routes[j].Bits() {
return routes[i].Bits() < routes[j].Bits()
}
return routes[i].Addr().Less(routes[j].Addr())
return routes[i].IP().Less(routes[j].IP())
})
return routes, nil
}
@@ -362,7 +359,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)
applyImplicitPrefs(prefs, curPrefs, env.user)
if err := checkForAccidentalSettingReverts(prefs, curPrefs, env); err != nil {
return false, nil, err
@@ -404,12 +401,12 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
return simpleUp, justEditMP, nil
}
func runUp(ctx context.Context, args []string) (retErr error) {
func runUp(ctx context.Context, args []string) error {
if len(args) > 0 {
fatalf("too many non-flag arguments: %q", args)
}
st, err := localClient.Status(ctx)
st, err := tailscale.Status(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
@@ -450,12 +447,12 @@ func runUp(ctx context.Context, args []string) (retErr error) {
}
if len(prefs.AdvertiseRoutes) > 0 {
if err := localClient.CheckIPForwarding(context.Background()); err != nil {
if err := tailscale.CheckIPForwarding(context.Background()); err != nil {
warnf("%v", err)
}
}
curPrefs, err := localClient.GetPrefs(ctx)
curPrefs, err := tailscale.GetPrefs(ctx)
if err != nil {
return err
}
@@ -469,30 +466,12 @@ func runUp(ctx context.Context, args []string) (retErr 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 := localClient.EditPrefs(ctx, justEditMP)
_, err := tailscale.EditPrefs(ctx, justEditMP)
return err
}
@@ -538,7 +517,7 @@ func runUp(ctx context.Context, args []string) (retErr error) {
if env.upArgs.json {
printUpDoneJSON(ipn.NeedsMachineAuth, "")
} else {
fmt.Fprintf(os.Stderr, "\nTo authorize your machine, visit (as admin):\n\n\t%s\n\n", prefs.AdminPageURL())
fmt.Fprintf(Stderr, "\nTo authorize your machine, visit (as admin):\n\n\t%s\n\n", prefs.AdminPageURL())
}
case ipn.Running:
// Done full authentication process
@@ -546,7 +525,7 @@ func runUp(ctx context.Context, args []string) (retErr error) {
printUpDoneJSON(ipn.Running, "")
} else if printed {
// Only need to print an update if we printed the "please click" message earlier.
fmt.Fprintf(os.Stderr, "Success.\n")
fmt.Fprintf(Stderr, "Success.\n")
}
select {
case running <- true:
@@ -575,13 +554,13 @@ func runUp(ctx context.Context, args []string) (retErr error) {
fmt.Println(string(data))
}
} else {
fmt.Fprintf(os.Stderr, "\nTo authenticate, visit:\n\n\t%s\n\n", *url)
fmt.Fprintf(Stderr, "\nTo authenticate, visit:\n\n\t%s\n\n", *url)
if upArgs.qr {
q, err := qrcode.New(*url, qrcode.Medium)
if err != nil {
log.Printf("QR code error: %v", err)
} else {
fmt.Fprintf(os.Stderr, "%s\n", q.ToString(false))
fmt.Fprintf(Stderr, "%s\n", q.ToString(false))
}
}
}
@@ -603,7 +582,7 @@ func runUp(ctx context.Context, args []string) (retErr error) {
// Special case: bare "tailscale up" means to just start
// running, if there's ever been a login.
if simpleUp {
_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
_, err := tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
WantRunning: true,
},
@@ -613,7 +592,7 @@ func runUp(ctx context.Context, args []string) (retErr error) {
return err
}
} else {
if err := localClient.CheckPrefs(ctx, prefs); err != nil {
if err := tailscale.CheckPrefs(ctx, prefs); err != nil {
return err
}
@@ -654,12 +633,6 @@ func runUp(ctx context.Context, args []string) (retErr 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
@@ -677,30 +650,6 @@ func runUp(ctx context.Context, args []string) (retErr 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") {
fmt.Printf("%s\n", st.Health[0])
return
}
fmt.Printf("# Health check:\n")
for _, m := range st.Health {
fmt.Printf(" - %s\n", m)
}
}
@@ -757,7 +706,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", "timeout", "accept-risk":
case "auth-key", "force-reauth", "reset", "qr", "json":
return true
}
return false
@@ -790,7 +739,7 @@ type upCheckEnv struct {
flagSet *flag.FlagSet
upArgs upArgsT
backendState string
curExitNodeIP netip.Addr
curExitNodeIP netaddr.IP
distro distro.Distro
}
@@ -881,20 +830,13 @@ func checkForAccidentalSettingReverts(newPrefs, curPrefs *ipn.Prefs, env upCheck
return errors.New(sb.String())
}
// 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
// applyImplicitPrefs mutates prefs to add implicit preferences. Currently
// this is just the operator user, which 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, 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 {
func applyImplicitPrefs(prefs, oldPrefs *ipn.Prefs, curUser string) {
if prefs.OperatorUser == "" && oldPrefs.OperatorUser == curUser {
prefs.OperatorUser = oldPrefs.OperatorUser
}
}
@@ -913,10 +855,10 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) {
ret := make(map[string]any)
exitNodeIPStr := func() string {
if prefs.ExitNodeIP.IsValid() {
if !prefs.ExitNodeIP.IsZero() {
return prefs.ExitNodeIP.String()
}
if prefs.ExitNodeID.IsZero() || !env.curExitNodeIP.IsValid() {
if prefs.ExitNodeID.IsZero() || env.curExitNodeIP.IsZero() {
return ""
}
return env.curExitNodeIP.String()
@@ -991,13 +933,13 @@ func fmtFlagValueArg(flagName string, val any) string {
return fmt.Sprintf("--%s=%v", flagName, shellquote.Join(fmt.Sprint(val)))
}
func hasExitNodeRoutes(rr []netip.Prefix) bool {
func hasExitNodeRoutes(rr []netaddr.IPPrefix) bool {
var v4, v6 bool
for _, r := range rr {
if r.Bits() == 0 {
if r.Addr().Is4() {
if r.IP().Is4() {
v4 = true
} else if r.Addr().Is6() {
} else if r.IP().Is6() {
v6 = true
}
}
@@ -1008,11 +950,11 @@ func hasExitNodeRoutes(rr []netip.Prefix) bool {
// withoutExitNodes returns rr unchanged if it has only 1 or 0 /0
// routes. If it has both IPv4 and IPv6 /0 routes, then it returns
// a copy with all /0 routes removed.
func withoutExitNodes(rr []netip.Prefix) []netip.Prefix {
func withoutExitNodes(rr []netaddr.IPPrefix) []netaddr.IPPrefix {
if !hasExitNodeRoutes(rr) {
return rr
}
var out []netip.Prefix
var out []netaddr.IPPrefix
for _, r := range rr {
if r.Bits() > 0 {
out = append(out, r)
@@ -1023,11 +965,11 @@ func withoutExitNodes(rr []netip.Prefix) []netip.Prefix {
// exitNodeIP returns the exit node IP from p, using st to map
// it from its ID form to an IP address if needed.
func exitNodeIP(p *ipn.Prefs, st *ipnstate.Status) (ip netip.Addr) {
func exitNodeIP(p *ipn.Prefs, st *ipnstate.Status) (ip netaddr.IP) {
if p == nil {
return
}
if p.ExitNodeIP.IsValid() {
if !p.ExitNodeIP.IsZero() {
return p.ExitNodeIP
}
id := p.ExitNodeID

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/version"
)
@@ -18,7 +19,7 @@ var versionCmd = &ffcli.Command{
ShortUsage: "version [flags]",
ShortHelp: "Print Tailscale version",
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("version", flag.ExitOnError)
fs := newFlagSet("version")
fs.BoolVar(&versionArgs.daemon, "daemon", false, "also print local node's daemon version")
return fs
})(),
@@ -34,16 +35,16 @@ func runVersion(ctx context.Context, args []string) error {
return fmt.Errorf("too many non-flag arguments: %q", args)
}
if !versionArgs.daemon {
fmt.Println(version.String())
outln(version.String())
return nil
}
fmt.Printf("Client: %s\n", version.String())
printf("Client: %s\n", version.String())
st, err := localClient.StatusWithoutPeers(ctx)
st, err := tailscale.StatusWithoutPeers(ctx)
if err != nil {
return err
}
fmt.Printf("Daemon: %s\n", st.Version)
printf("Daemon: %s\n", st.Version)
return nil
}

View File

@@ -20,7 +20,6 @@ import (
"net"
"net/http"
"net/http/cgi"
"net/netip"
"net/url"
"os"
"os/exec"
@@ -28,6 +27,8 @@ import (
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/types/preftype"
@@ -75,7 +76,7 @@ Tailscale, as opposed to a CLI or a native app.
`),
FlagSet: (func() *flag.FlagSet {
webf := flag.NewFlagSet("web", flag.ExitOnError)
webf := newFlagSet("web")
webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic")
webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script")
return webf
@@ -208,20 +209,12 @@ func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) {
return "", nil, err
}
token, err := r.Cookie("qtoken")
if err == nil {
return qnapAuthnQtoken(r, user.Value, token.Value)
if err != nil {
return "", nil, err
}
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},
"user": []string{user},
"qtoken": []string{token.Value},
"user": []string{user.Value},
}
u := url.URL{
Scheme: r.URL.Scheme,
@@ -229,26 +222,7 @@ func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResp
Path: "/cgi-bin/authLogin.cgi",
RawQuery: query.Encode(),
}
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)
resp, err := http.Get(u.String())
if err != nil {
return "", nil, err
}
@@ -264,7 +238,7 @@ func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
if authResp.AuthPassed == 0 {
return "", nil, fmt.Errorf("not authenticated")
}
return user, authResp, nil
return user.Value, authResp, nil
}
func synoAuthn() (string, error) {
@@ -344,7 +318,7 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(mi{"error": err.Error()})
return
}
prefs, err := localClient.GetPrefs(r.Context())
prefs, err := tailscale.GetPrefs(r.Context())
if err != nil && !postData.Reauthenticate {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})
@@ -374,12 +348,12 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
return
}
st, err := localClient.Status(r.Context())
st, err := tailscale.Status(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
prefs, err := localClient.GetPrefs(r.Context())
prefs, err := tailscale.GetPrefs(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -393,8 +367,8 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
Status: st.BackendState,
DeviceName: deviceName,
}
exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0")
exitNodeRouteV6 := netip.MustParsePrefix("::/0")
exitNodeRouteV4 := netaddr.MustParseIPPrefix("0.0.0.0/0")
exitNodeRouteV6 := netaddr.MustParseIPPrefix("::/0")
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
data.AdvertiseExitNode = true
@@ -432,7 +406,7 @@ func tailscaleUp(ctx context.Context, prefs *ipn.Prefs, forceReauth bool) (authU
prefs.NetfilterMode = preftype.NetfilterOff
}
st, err := localClient.Status(ctx)
st, err := tailscale.Status(ctx)
if err != nil {
return "", fmt.Errorf("can't fetch status: %v", err)
}

View File

@@ -1,18 +1,15 @@
tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/depaware)
filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus
filippo.io/edwards25519/field from filippo.io/edwards25519
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
github.com/fxamacker/cbor/v2 from tailscale.com/tka
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L github.com/josharian/native from github.com/mdlayher/netlink+
L 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/interfaces
L github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
github.com/kballard/go-shellquote from tailscale.com/cmd/tailscale/cli
github.com/klauspost/compress/flate from nhooyr.io/websocket
L 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
@@ -30,50 +27,47 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck
github.com/toqueteos/webbrowser from tailscale.com/cmd/tailscale/cli
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/intern from inet.af/netaddr
💣 go4.org/mem from tailscale.com/derp+
go4.org/netipx from tailscale.com/wgengine/filter
go4.org/unsafe/assume-no-moving-gc from go4.org/intern
W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+
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
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
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/dnsfallback from tailscale.com/control/controlhttp
tailscale.com/net/dnscache from tailscale.com/derp/derphttp
tailscale.com/net/flowtrack from tailscale.com/wgengine/filter+
💣 tailscale.com/net/interfaces from tailscale.com/cmd/tailscale/cli+
tailscale.com/net/netaddr from tailscale.com/disco+
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+
tailscale.com/tka from tailscale.com/types/key
W tailscale.com/tsconst from tailscale.com/net/interfaces
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/wgengine/filter
@@ -83,7 +77,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/types/key from tailscale.com/derp+
tailscale.com/types/logger from tailscale.com/cmd/tailscale/cli+
tailscale.com/types/netmap from tailscale.com/ipn
tailscale.com/types/nettype from tailscale.com/net/netcheck+
tailscale.com/types/opt from tailscale.com/net/netcheck+
tailscale.com/types/pad32 from tailscale.com/derp
tailscale.com/types/persist from tailscale.com/ipn
@@ -91,26 +84,23 @@ 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+
tailscale.com/util/singleflight from tailscale.com/net/dnscache
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+
W tailscale.com/util/winutil from tailscale.com/hostinfo
W 💣 tailscale.com/util/winutil/vss from tailscale.com/util/winutil
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/argon2 from tailscale.com/tka
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/blake2b from golang.org/x/crypto/nacl/box
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+
@@ -123,6 +113,7 @@ 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+
@@ -162,7 +153,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
embed from tailscale.com/cmd/tailscale/cli+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from tailscale.com/tka
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/hex from crypto/x509+
@@ -183,7 +173,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
image/color from github.com/skip2/go-qrcode+
image/png from github.com/skip2/go-qrcode
io from bufio+
io/fs from crypto/x509+
io/fs from crypto/rand+
io/ioutil from golang.org/x/sys/cpu+
log from expvar+
math from compress/flate+
@@ -198,7 +188,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
net/http/cgi from tailscale.com/cmd/tailscale/cli
net/http/httptrace from github.com/tcnksm/go-httpstat+
net/http/internal from net/http
net/netip from net+
net/netip from net
net/textproto from golang.org/x/net/http/httpguts+
net/url from crypto/x509+
os from crypto/rand+
@@ -210,7 +200,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 tailscale.com/util/singleflight+
runtime/debug from golang.org/x/sync/singleflight+
sort from compress/flate+
strconv from compress/flate+
strings from bufio+

View File

@@ -17,6 +17,7 @@ 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"}
}

View File

@@ -20,12 +20,12 @@ import (
"net"
"net/http"
"net/http/httptrace"
"net/netip"
"net/url"
"os"
"strings"
"time"
"inet.af/netaddr"
"tailscale.com/derp/derphttp"
"tailscale.com/envknob"
"tailscale.com/ipn"
@@ -266,11 +266,11 @@ func debugPortmap(ctx context.Context) error {
return err
}
gatewayAndSelfIP := func() (gw, self netip.Addr, ok bool) {
gatewayAndSelfIP := func() (gw, self netaddr.IP, ok bool) {
if v := os.Getenv("TS_DEBUG_GW_SELF"); strings.Contains(v, "/") {
i := strings.Index(v, "/")
gw = netip.MustParseAddr(v[:i])
self = netip.MustParseAddr(v[i+1:])
gw = netaddr.MustParseIP(v[:i])
self = netaddr.MustParseIP(v[i+1:])
return gw, self, true
}
return linkMon.GatewayAndSelfIP()

View File

@@ -1,7 +1,5 @@
tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/depaware)
filippo.io/edwards25519 from github.com/hdevalence/ed25519consensus
filippo.io/edwards25519/field from filippo.io/edwards25519
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/internal/common+
W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
@@ -64,13 +62,11 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm
L github.com/coreos/go-iptables/iptables from tailscale.com/wgengine/router
LD 💣 github.com/creack/pty from tailscale.com/ssh/tailssh
github.com/fxamacker/cbor/v2 from tailscale.com/tka
W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+
W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns+
github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/header+
github.com/hdevalence/ed25519consensus from tailscale.com/tka
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
L github.com/insomniacslk/dhcp/iana from github.com/insomniacslk/dhcp/dhcpv4
L github.com/insomniacslk/dhcp/interfaces from github.com/insomniacslk/dhcp/dhcpv4
@@ -80,15 +76,12 @@ 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
github.com/klauspost/compress/flate from nhooyr.io/websocket
L 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+
@@ -96,8 +89,6 @@ 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
@@ -117,9 +108,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/u-root/uio/uio from github.com/insomniacslk/dhcp/dhcpv4+
L 💣 github.com/vishvananda/netlink/nl from github.com/tailscale/netlink
L github.com/vishvananda/netns from github.com/tailscale/netlink+
github.com/x448/float16 from github.com/fxamacker/cbor/v2
💣 go4.org/mem from tailscale.com/control/controlbase+
go4.org/netipx from tailscale.com/ipn/ipnlocal+
💣 go4.org/intern from inet.af/netaddr
💣 go4.org/mem from tailscale.com/client/tailscale+
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+
W 💣 golang.zx2c4.com/wireguard/conn/winrio from golang.zx2c4.com/wireguard/conn
@@ -133,8 +124,7 @@ 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/bits from gvisor.dev/gvisor/pkg/bufferv2
💣 gvisor.dev/gvisor/pkg/bufferv2 from gvisor.dev/gvisor/pkg/tcpip+
💣 gvisor.dev/gvisor/pkg/buffer from gvisor.dev/gvisor/pkg/tcpip/stack
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
@@ -146,8 +136,9 @@ 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/header+
gvisor.dev/gvisor/pkg/tcpip from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
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+
@@ -156,47 +147,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/header/parse+
gvisor.dev/gvisor/pkg/tcpip/transport from gvisor.dev/gvisor/pkg/tcpip/transport/internal/network+
💣 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/transport/icmp from tailscale.com/wgengine/netstack
gvisor.dev/gvisor/pkg/tcpip/transport/internal/network from gvisor.dev/gvisor/pkg/tcpip/transport/raw+
gvisor.dev/gvisor/pkg/tcpip/transport/internal/network from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
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/udp+
gvisor.dev/gvisor/pkg/tcpip/transport/raw from gvisor.dev/gvisor/pkg/tcpip/transport/icmp+
💣 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 tailscale.com/net/tstun+
gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
inet.af/netaddr from inet.af/wf+
inet.af/peercred from tailscale.com/ipn/ipnserver
W 💣 inet.af/wf from tailscale.com/wf
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
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
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/ipn/ipnlocal+
tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+
tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale+
tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled+
tailscale.com/control/controlbase from tailscale.com/control/controlclient+
tailscale.com/control/controlclient from tailscale.com/ipn/ipnlocal+
tailscale.com/control/controlclient from tailscale.com/cmd/tailscaled+
tailscale.com/control/controlhttp from tailscale.com/control/controlclient
tailscale.com/control/controlknobs from tailscale.com/control/controlclient+
tailscale.com/derp from tailscale.com/derp/derphttp+
tailscale.com/derp/derphttp from tailscale.com/net/netcheck+
tailscale.com/derp/derphttp from tailscale.com/cmd/tailscaled+
L tailscale.com/derp/wsconn from tailscale.com/derp/derphttp
tailscale.com/disco from tailscale.com/derp+
tailscale.com/envknob from tailscale.com/control/controlclient+
tailscale.com/envknob from tailscale.com/cmd/tailscaled+
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/hostinfo from tailscale.com/control/controlclient+
tailscale.com/ipn from tailscale.com/ipn/ipnlocal+
tailscale.com/ipn/ipnlocal from tailscale.com/ssh/tailssh+
tailscale.com/ipn from tailscale.com/client/tailscale+
tailscale.com/ipn/ipnlocal from tailscale.com/ipn/ipnserver+
tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled
tailscale.com/ipn/ipnstate from tailscale.com/control/controlclient+
tailscale.com/ipn/ipnstate from tailscale.com/client/tailscale+
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
@@ -207,44 +199,42 @@ 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/control/controlclient+
tailscale.com/logtail/backoff from tailscale.com/control/controlclient+
tailscale.com/logtail from tailscale.com/cmd/tailscaled+
tailscale.com/logtail/backoff from tailscale.com/cmd/tailscaled+
tailscale.com/logtail/filch from tailscale.com/logpolicy
💣 tailscale.com/metrics from tailscale.com/derp+
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dns from tailscale.com/cmd/tailscaled+
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/control/controlclient+
tailscale.com/net/netaddr from tailscale.com/disco+
💣 tailscale.com/net/interfaces from tailscale.com/cmd/tailscaled+
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/net/netns+
tailscale.com/net/netns from tailscale.com/derp/derphttp+
tailscale.com/net/netknob from tailscale.com/logpolicy+
tailscale.com/net/netns from tailscale.com/cmd/tailscaled+
💣 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/net/netcheck+
tailscale.com/net/portmapper from tailscale.com/cmd/tailscaled+
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/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/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/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/apitype+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
LD tailscale.com/tempfork/gliderlabs/ssh from tailscale.com/ssh/tailssh
tailscale.com/tka from tailscale.com/types/key
W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tstime from tailscale.com/wgengine/magicsock
💣 tailscale.com/tstime/mono from tailscale.com/net/tstun+
@@ -254,49 +244,47 @@ 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/control/controlbase+
tailscale.com/types/logger from tailscale.com/control/controlclient+
tailscale.com/types/key from tailscale.com/cmd/tailscaled+
tailscale.com/types/logger from tailscale.com/cmd/tailscaled+
tailscale.com/types/netmap from tailscale.com/control/controlclient+
tailscale.com/types/nettype from tailscale.com/wgengine/magicsock+
tailscale.com/types/nettype from tailscale.com/wgengine/magicsock
tailscale.com/types/opt from tailscale.com/control/controlclient+
tailscale.com/types/pad32 from tailscale.com/derp
tailscale.com/types/persist from tailscale.com/control/controlclient+
tailscale.com/types/preftype from tailscale.com/ipn+
tailscale.com/types/structs from tailscale.com/control/controlclient+
tailscale.com/types/views from tailscale.com/ipn/ipnlocal+
tailscale.com/util/clientmetric from tailscale.com/control/controlclient+
tailscale.com/util/cloudenv from tailscale.com/net/dns/resolver+
tailscale.com/util/clientmetric from tailscale.com/cmd/tailscaled+
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/mak from tailscale.com/control/controlclient+
tailscale.com/util/multierr from tailscale.com/control/controlclient+
tailscale.com/util/osshare from tailscale.com/ipn/ipnlocal+
tailscale.com/util/multierr from tailscale.com/cmd/tailscaled+
tailscale.com/util/netconv from tailscale.com/wgengine/magicsock
tailscale.com/util/osshare from tailscale.com/cmd/tailscaled+
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+
tailscale.com/version from tailscale.com/derp+
tailscale.com/version/distro from tailscale.com/hostinfo+
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+
W tailscale.com/wf from tailscale.com/cmd/tailscaled
tailscale.com/wgengine from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine from tailscale.com/cmd/tailscaled+
tailscale.com/wgengine/filter from tailscale.com/control/controlclient+
tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/monitor from tailscale.com/control/controlclient+
tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled+
tailscale.com/wgengine/router 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/wgcfg from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal
tailscale.com/wgengine/wglog from tailscale.com/wgengine
W 💣 tailscale.com/wgengine/winnet from tailscale.com/wgengine/router
golang.org/x/crypto/acme from tailscale.com/ipn/localapi
golang.org/x/crypto/argon2 from tailscale.com/tka
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box+
golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box
golang.org/x/crypto/blake2s from golang.zx2c4.com/wireguard/device+
LD golang.org/x/crypto/blowfish from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf+
golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305+
@@ -310,7 +298,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+
@@ -324,12 +312,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.org/x/sys/windows/svc/eventlog+
W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+
W golang.org/x/sys/windows/svc from golang.org/x/sys/windows/svc/mgr+
W golang.org/x/sys/windows/svc/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
@@ -361,31 +349,30 @@ 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/tcnksm/go-httpstat+
crypto/tls from github.com/aws/aws-sdk-go-v2/aws/transport/http+
crypto/x509 from crypto/tls+
crypto/x509/pkix from crypto/x509+
embed from tailscale.com+
embed from crypto/elliptic+
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base32 from tailscale.com/tka
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/hex from crypto/x509+
encoding/json from expvar+
encoding/pem from crypto/tls+
encoding/xml from github.com/tailscale/goupnp+
encoding/xml from github.com/aws/aws-sdk-go-v2/aws/protocol/xml+
errors from bufio+
expvar from tailscale.com/derp+
flag from tailscale.com/control/controlclient+
flag from tailscale.com/cmd/tailscaled+
fmt from compress/flate+
hash from crypto+
hash/crc32 from compress/gzip+
hash/fnv from tailscale.com/wgengine/magicsock+
hash/fnv from gvisor.dev/gvisor/pkg/tcpip/network/ipv6+
hash/maphash from go4.org/mem
html from tailscale.com/ipn/ipnlocal+
html from net/http/pprof+
io from bufio+
io/fs from crypto/x509+
io/ioutil from github.com/godbus/dbus/v5+
io/fs from crypto/rand+
io/ioutil from github.com/aws/aws-sdk-go-v2/aws/protocol/query+
log from expvar+
LD log/syslog from tailscale.com/ssh/tailssh
math from compress/flate+
@@ -402,19 +389,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 golang.org/x/net/http/httpguts+
net/textproto from github.com/aws/aws-sdk-go-v2/aws/signer/v4+
net/url from crypto/x509+
os from crypto/rand+
os/exec from github.com/coreos/go-iptables/iptables+
os/exec from github.com/aws/aws-sdk-go-v2/credentials/processcreds+
os/signal from tailscale.com/cmd/tailscaled+
os/user from github.com/godbus/dbus/v5+
path from github.com/godbus/dbus/v5+
path from github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds+
path/filepath from crypto/x509+
reflect from crypto/x509+
regexp from github.com/coreos/go-iptables/iptables+
regexp from github.com/aws/aws-sdk-go-v2/internal/endpoints/v2+
regexp/syntax from regexp
runtime/debug from github.com/klauspost/compress/zstd+
runtime/pprof from tailscale.com/log/logheap+
runtime/pprof from net/http/pprof+
runtime/trace from net/http/pprof
sort from compress/flate+
strconv from compress/flate+

View File

@@ -9,3 +9,4 @@ package main
// Force registration of tailssh with LocalBackend.
import _ "tailscale.com/ssh/tailssh"

View File

@@ -21,7 +21,6 @@ import (
"net"
"net/http"
"net/http/pprof"
"net/netip"
"os"
"os/signal"
"path/filepath"
@@ -30,6 +29,7 @@ import (
"syscall"
"time"
"inet.af/netaddr"
"tailscale.com/cmd/tailscaled/childproc"
"tailscale.com/control/controlclient"
"tailscale.com/envknob"
@@ -128,8 +128,6 @@ var subCommands = map[string]*func([]string) error{
"be-child": &beChildFunc,
}
var beCLI func() // non-nil if CLI is linked in
func main() {
printVersion := false
flag.IntVar(&args.verbose, "verbose", 0, "log verbosity level; 0 is default, 1 or higher are increasingly verbose")
@@ -139,17 +137,12 @@ 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", "", "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.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.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")
flag.BoolVar(&printVersion, "version", false, "print version information and exit")
if len(os.Args) > 0 && filepath.Base(os.Args[0]) == "tailscale" && beCLI != nil {
beCLI()
return
}
if len(os.Args) > 1 {
sub := os.Args[1]
if fp, ok := subCommands[sub]; ok {
@@ -165,12 +158,13 @@ func main() {
}
}
if beWindowsSubprocess() {
return
}
flag.Parse()
if flag.NArg() > 0 {
// 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())
}
log.Fatalf("tailscaled does not take non-flag arguments: %q", flag.Args())
}
if printVersion {
@@ -193,16 +187,6 @@ 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)
@@ -274,6 +258,13 @@ func ipnServerOpts() (o ipnserver.Options) {
}
switch goos {
case "js":
// The js/wasm client has no state storage so for now
// treat all interactive logins as ephemeral.
// TODO(bradfitz): if we start using browser LocalStorage
// or something, then rethink this.
o.LoginFlags = controlclient.LoginEphemeral
fallthrough
default:
o.SurviveDisconnects = true
o.AutostartStateKey = ipn.GlobalDaemonStateKey
@@ -341,7 +332,6 @@ 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)
@@ -351,7 +341,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)
}
}
@@ -366,11 +356,11 @@ func run() error {
ns.ProcessSubnets = useNetstack || wrapNetstack
if useNetstack {
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
dialer.UseNetstackForIP = func(ip netaddr.IP) bool {
_, ok := e.PeerForIP(ip)
return ok
}
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
dialer.NetstackDialTCP = func(ctx context.Context, dst netaddr.IPPort) (net.Conn, error) {
return ns.DialContextTCP(ctx, dst)
}
}
@@ -436,7 +426,6 @@ 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.
@@ -515,7 +504,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, err)
tstun.Diagnose(logf, name)
return nil, false, fmt.Errorf("tstun.New(%q): %w", name, err)
}
conf.Tun = dev
@@ -575,11 +564,11 @@ func runDebugServer(mux *http.ServeMux, addr string) {
}
func newNetstack(logf logger.Logf, dialer *tsdial.Dialer, e wgengine.Engine) (*netstack.Impl, error) {
tunDev, magicConn, dns, ok := e.(wgengine.InternalsGetter).GetInternals()
tunDev, magicConn, 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, dns)
return netstack.Create(logf, tunDev, e, magicConn, dialer)
}
// mustStartProxyListeners creates listeners for local SOCKS and HTTP

View File

@@ -25,14 +25,13 @@ import (
"encoding/json"
"fmt"
"log"
"net/netip"
"os"
"time"
"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"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/store"
@@ -61,31 +60,12 @@ 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})
}
@@ -95,10 +75,7 @@ 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 {
@@ -121,29 +98,26 @@ func (service *ipnService) Execute(args []string, r <-chan svc.ChangeRequest, ch
}()
changes <- svc.Status{State: svc.Running, Accepts: svcAccepts}
syslogf("Service running")
for {
for ctx.Err() == nil {
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:
changes <- svc.Status{State: svc.StopPending}
syslogf("Service stop pending")
cancel() // so BabysitProc will kill the child process
cancel()
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 {
@@ -245,7 +219,7 @@ func beFirewallKillswitch() bool {
// is passed in via stdin encoded in json.
dcd := json.NewDecoder(os.Stdin)
for {
var routes []netip.Prefix
var routes []netaddr.IPPrefix
if err := dcd.Decode(&routes); err != nil {
log.Fatalf("parent process died or requested exit, exiting (%v)", err)
}
@@ -260,19 +234,19 @@ func startIPNServer(ctx context.Context, logid string) error {
linkMon, err := monitor.New(logf)
if err != nil {
return fmt.Errorf("monitor: %w", err)
return err
}
dialer := new(tsdial.Dialer)
getEngineRaw := func() (wgengine.Engine, *netstack.Impl, error) {
getEngineRaw := func() (wgengine.Engine, error) {
dev, devName, err := tstun.New(logf, "Tailscale")
if err != nil {
return nil, nil, fmt.Errorf("TUN: %w", err)
return nil, fmt.Errorf("TUN: %w", err)
}
r, err := router.New(logf, dev, nil)
if err != nil {
dev.Close()
return nil, nil, fmt.Errorf("router: %w", err)
return nil, fmt.Errorf("router: %w", err)
}
if wrapNetstack {
r = netstack.NewSubnetRouterWrapper(r)
@@ -281,7 +255,7 @@ func startIPNServer(ctx context.Context, logid string) error {
if err != nil {
r.Close()
dev.Close()
return nil, nil, fmt.Errorf("DNS: %w", err)
return nil, fmt.Errorf("DNS: %w", err)
}
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Tun: dev,
@@ -294,24 +268,23 @@ func startIPNServer(ctx context.Context, logid string) error {
if err != nil {
r.Close()
dev.Close()
return nil, nil, fmt.Errorf("engine: %w", err)
return nil, fmt.Errorf("engine: %w", err)
}
ns, err := newNetstack(logf, dialer, eng)
if err != nil {
return nil, nil, fmt.Errorf("newNetstack: %w", err)
return nil, fmt.Errorf("newNetstack: %w", err)
}
ns.ProcessLocalIPs = false
ns.ProcessSubnets = wrapNetstack
if err := ns.Start(); err != nil {
return nil, nil, fmt.Errorf("failed to start netstack: %w", err)
return nil, fmt.Errorf("failed to start netstack: %w", err)
}
return wgengine.NewWatchdog(eng), ns, nil
return wgengine.NewWatchdog(eng), nil
}
type engineOrError struct {
Engine wgengine.Engine
Netstack *netstack.Impl
Err error
Engine wgengine.Engine
Err error
}
engErrc := make(chan engineOrError)
t0 := time.Now()
@@ -320,7 +293,7 @@ func startIPNServer(ctx context.Context, logid string) error {
for try := 1; ; try++ {
logf("tailscaled: getting engine... (try %v)", try)
t1 := time.Now()
eng, ns, err := getEngineRaw()
eng, 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",
@@ -333,7 +306,7 @@ func startIPNServer(ctx context.Context, logid string) error {
}
}
timer := time.NewTimer(5 * time.Second)
engErrc <- engineOrError{eng, ns, err}
engErrc <- engineOrError{eng, err}
if err == nil {
timer.Stop()
return
@@ -345,14 +318,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, *netstack.Impl, error) {
getEngine := func() (wgengine.Engine, error) {
if msg := envknob.String("TS_DEBUG_WIN_FAIL"); msg != "" {
return nil, nil, fmt.Errorf("pretending to be a service failure: %v", msg)
return nil, fmt.Errorf("pretending to be a service failure: %v", msg)
}
for {
res := <-engErrc
if res.Engine != nil {
return res.Engine, res.Netstack, nil
return res.Engine, nil
}
if time.Since(t0) < time.Minute || windowsUptime() < 10*time.Minute {
// Ignore errors during early boot. Windows 10 auto logs in the GUI
@@ -363,12 +336,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, nil, fmt.Errorf("%w\n\nlogid: %v", res.Err, logid)
return nil, fmt.Errorf("%w\n\nlogid: %v", res.Err, logid)
}
}
store, err := store.New(logf, statePathOrDefault())
if err != nil {
return fmt.Errorf("store: %w", err)
return err
}
ln, _, err := safesocket.Listen(args.socketpath, safesocket.WindowsLocalPort)

View File

@@ -1,25 +0,0 @@
// 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 ts_include_cli
// +build ts_include_cli
package main
import (
"fmt"
"os"
"tailscale.com/cmd/tailscale/cli"
)
func init() {
beCLI = func() {
args := os.Args[1:]
if err := cli.Run(args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
}

View File

@@ -1,4 +0,0 @@
src/wasm_exec.js
src/main.wasm
node_modules/
dist/

View File

@@ -1,195 +0,0 @@
// 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)
}
log.Printf("Linting...\n")
if err := runYarn("lint"); err != nil {
log.Fatalf("Linting failed: %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)
}

View File

@@ -1,151 +0,0 @@
// 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"
"time"
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.ts", "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,
Plugins: []esbuild.Plugin{{
Name: "tailscale-tailwind",
Setup: func(build esbuild.PluginBuild) {
setupEsbuildTailwind(build, dev)
},
}},
}, 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")
return runYarn()
}
func runYarn(args ...string) error {
cmd := exec.Command(*yarnPath, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// 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"`
}
func setupEsbuildTailwind(build esbuild.PluginBuild, dev bool) {
build.OnLoad(esbuild.OnLoadOptions{
Filter: "./src/index.css$",
}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
start := time.Now()
yarnArgs := []string{"--silent", "tailwind", "-i", args.Path}
if !dev {
yarnArgs = append(yarnArgs, "--minify")
}
cmd := exec.Command(*yarnPath, yarnArgs...)
tailwindOutput, err := cmd.Output()
log.Printf("Ran tailwind in %v\n", time.Since(start))
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
log.Printf("Tailwind stderr: %s", exitErr.Stderr)
}
return esbuild.OnLoadResult{}, fmt.Errorf("Cannot run tailwind: %w", err)
}
tailwindOutputStr := string(tailwindOutput)
return esbuild.OnLoadResult{
Contents: &tailwindOutputStr,
Loader: esbuild.LoaderCSS,
}, nil
})
}

View File

@@ -1,38 +0,0 @@
// 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()
}

View File

@@ -1,2 +0,0 @@
This is here to make sure the dist/ directory exists for the go:embed command
in serve.go.

View File

@@ -1,38 +0,0 @@
<!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 class="flex flex-col min-h-screen">
<div class="bg-gray-100 border-b border-gray-200 pt-4 pb-2 mb-6">
<header class="container mx-auto px-4 flex flex-row items-center">
<h1 class="text-3xl font-bold grow">Tailscale Connect</h1>
<div class="text-gray-600" id="state">Loading…</div>
</header>
</div>
<form
id="ssh-form"
class="container mx-auto px-4 hidden flex justify-center"
>
<input type="text" class="input username" placeholder="Username" />
<div class="select-with-arrow mx-2">
<select class="select"></select>
</div>
<input
type="submit"
class="button bg-green-500 border-green-500 text-white hover:bg-green-600 hover:border-green-600"
value="SSH"
/>
</form>
<div id="no-ssh" class="container mx-auto px-4 hidden text-center">
None of your machines have
<a href="https://tailscale.com/kb/1193/tailscale-ssh/" class="link"
>Tailscale SSH</a
>
enabled. Give it a try!
</div>
<script src="dist/index.js"></script>
</body>
</html>

View File

@@ -1,20 +0,0 @@
{
"name": "tsconnect",
"version": "0.0.1",
"license": "BSD-3-Clause",
"devDependencies": {
"@types/golang-wasm-exec": "^1.15.0",
"@types/qrcode": "^1.4.2",
"qrcode": "^1.5.0",
"tailwindcss": "^3.1.6",
"typescript": "^4.7.4",
"xterm": "^4.18.0"
},
"scripts": {
"lint": "tsc --noEmit"
},
"prettier": {
"semi": false,
"printWidth": 80
}
}

View File

@@ -1,148 +0,0 @@
// 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.ts": "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)
}

View File

@@ -1,15 +0,0 @@
// 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 Type definitions for types generated by the esbuild build
* process.
*/
declare module "*.wasm" {
const path: string
export default path
}
declare const DEBUG: boolean

View File

@@ -1,75 +0,0 @@
/* 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";
@tailwind base;
@tailwind components;
@tailwind utilities;
.link {
@apply text-blue-600;
}
.link:hover {
@apply underline;
}
.button {
@apply font-medium py-1 px-2 rounded-md border border-transparent text-center cursor-pointer;
transition-property: background-color, border-color, color, box-shadow;
transition-duration: 120ms;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
min-width: 80px;
}
.button:focus {
@apply outline-none ring;
}
.button:disabled {
@apply pointer-events-none select-none;
}
.input {
@apply appearance-none leading-tight rounded-md bg-white border border-gray-300 hover:border-gray-400 transition-colors px-3;
height: 2.375rem;
}
.input::placeholder {
@apply text-gray-400;
}
.input:disabled {
@apply border-gray-200;
@apply bg-gray-50;
@apply cursor-not-allowed;
}
.input:focus {
@apply outline-none ring border-transparent;
}
.select {
@apply appearance-none py-2 px-3 leading-tight rounded-md bg-white border border-gray-300;
}
.select-with-arrow {
@apply relative;
}
.select-with-arrow .select {
width: 100%;
}
.select-with-arrow::after {
@apply absolute;
content: "";
top: 50%;
right: 0.5rem;
transform: translate(-0.3em, -0.15em);
width: 0.6em;
height: 0.4em;
opacity: 0.6;
background-color: currentColor;
clip-path: polygon(100% 0%, 0 0%, 50% 100%);
}

View File

@@ -1,54 +0,0 @@
// 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 Go()
WebAssembly.instantiateStreaming(
fetch(`./dist/${wasmUrl}`),
go.importObject
).then((result) => {
// The Go process should never exit, if it does then it's an unhandled panic.
go.run(result.instance).then(() => handleGoPanic())
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),
notifyPanicRecover: handleGoPanic,
})
})
function handleGoPanic(err?: string) {
if (DEBUG && err) {
console.error("Go panic", err)
}
if (panicNode) {
panicNode.remove()
}
panicNode = document.createElement("div")
panicNode.className =
"rounded bg-red-500 p-2 absolute top-2 right-2 text-white font-bold text-right cursor-pointer"
panicNode.textContent = "Tailscale has encountered an error."
const panicDetailNode = document.createElement("div")
panicDetailNode.className = "text-sm font-normal"
panicDetailNode.textContent = "Click to reload"
panicNode.appendChild(panicDetailNode)
panicNode.addEventListener("click", () => location.reload(), {
once: true,
})
document.body.appendChild(panicNode)
setTimeout(() => {
panicNode!.remove()
}, 10000)
}
let panicNode: HTMLDivElement | undefined

View File

@@ -1,14 +0,0 @@
// 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: IPNStateStorage = {
setState(id, value) {
window.sessionStorage[`ipn-state-${id}`] = value
},
getState(id) {
return window.sessionStorage[`ipn-state-${id}`] || ""
},
}

View File

@@ -1,73 +0,0 @@
// 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 * as qrcode from "qrcode"
export async function showLoginURL(url: string) {
if (loginNode) {
loginNode.remove()
}
loginNode = document.createElement("div")
loginNode.className = "flex flex-col items-center justify-items-center"
const linkNode = document.createElement("a")
linkNode.className = "link"
linkNode.href = url
linkNode.target = "_blank"
loginNode.appendChild(linkNode)
try {
const dataURL = await qrcode.toDataURL(url, { width: 512 })
const imageNode = document.createElement("img")
imageNode.className = "mx-auto"
imageNode.src = dataURL
imageNode.width = 256
imageNode.height = 256
linkNode.appendChild(imageNode)
} catch (err) {
console.error("Could not generate QR code:", err)
}
linkNode.appendChild(document.createTextNode(url))
document.body.appendChild(loginNode)
}
export function hideLoginURL() {
if (!loginNode) {
return
}
loginNode.remove()
loginNode = undefined
}
let loginNode: HTMLDivElement | undefined
export function showLogoutButton(ipn: IPN) {
if (logoutButtonNode) {
logoutButtonNode.remove()
}
logoutButtonNode = document.createElement("button")
logoutButtonNode.className =
"button bg-gray-500 border-gray-500 text-white hover:bg-gray-600 hover:border-gray-600 ml-2 font-bold"
logoutButtonNode.textContent = "Logout"
logoutButtonNode.addEventListener(
"click",
() => {
ipn.logout()
},
{ once: true }
)
const headerNode = document.getElementsByTagName("header")[0]!
headerNode.appendChild(logoutButtonNode)
}
export function hideLogoutButton() {
if (!logoutButtonNode) {
return
}
logoutButtonNode.remove()
logoutButtonNode = undefined
}
let logoutButtonNode: HTMLButtonElement | undefined

View File

@@ -1,65 +0,0 @@
// 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 { showSSHForm, hideSSHForm } from "./ssh"
import { IPNState } from "./wasm_js"
/**
* @fileoverview Notification callback functions (bridged from ipn.Notify)
*/
export function notifyState(ipn: IPN, state: IPNState) {
let stateLabel
switch (state) {
case IPNState.NoState:
stateLabel = "Initializing…"
break
case IPNState.InUseOtherUser:
stateLabel = "In-use by another user"
break
case IPNState.NeedsLogin:
stateLabel = "Needs Login"
hideLogoutButton()
hideSSHForm()
ipn.login()
break
case IPNState.NeedsMachineAuth:
stateLabel = "Needs authorization"
break
case IPNState.Stopped:
stateLabel = "Stopped"
hideLogoutButton()
hideSSHForm()
break
case IPNState.Starting:
stateLabel = "Starting…"
break
case IPNState.Running:
stateLabel = "Running"
hideLoginURL()
showLogoutButton(ipn)
break
}
const stateNode = document.getElementById("state") as HTMLDivElement
stateNode.textContent = stateLabel ?? ""
}
export function notifyNetMap(ipn: IPN, netMapStr: string) {
const netMap = JSON.parse(netMapStr) as IPNNetMap
if (DEBUG) {
console.log("Received net map: " + JSON.stringify(netMap, null, 2))
}
showSSHForm(netMap.peers, ipn)
}
export function notifyBrowseToURL(ipn: IPN, url: string) {
showLoginURL(url)
}

View File

@@ -1,80 +0,0 @@
// 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 showSSHForm(peers: IPNNetMapPeerNode[], ipn: IPN) {
const formNode = document.getElementById("ssh-form") as HTMLDivElement
const noSSHNode = document.getElementById("no-ssh") as HTMLDivElement
const sshPeers = peers.filter(
(p) => p.tailscaleSSHEnabled && p.online !== false
)
if (sshPeers.length == 0) {
formNode.classList.add("hidden")
noSSHNode.classList.remove("hidden")
return
}
sshPeers.sort((a, b) => a.name.localeCompare(b.name))
const selectNode = formNode.querySelector("select")!
selectNode.innerHTML = ""
for (const p of sshPeers) {
const option = document.createElement("option")
option.textContent = p.name.split(".")[0]
option.value = p.name
selectNode.appendChild(option)
}
const usernameNode = formNode.querySelector(".username") as HTMLInputElement
formNode.onsubmit = (e) => {
e.preventDefault()
const hostname = selectNode.value
ssh(hostname, usernameNode.value, ipn)
}
noSSHNode.classList.add("hidden")
formNode.classList.remove("hidden")
}
export function hideSSHForm() {
const formNode = document.getElementById("ssh-form") as HTMLDivElement
formNode.classList.add("hidden")
}
function ssh(hostname: string, username: string, ipn: IPN) {
const termContainerNode = document.createElement("div")
termContainerNode.className = "p-3"
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: ((data: string) => void) | undefined
term.onData((e) => {
onDataHook?.(e)
})
term.focus()
ipn.ssh(hostname, username, {
writeFn: (input) => term.write(input),
setReadFn: (hook) => (onDataHook = hook),
rows: term.rows,
cols: term.cols,
onDone: () => {
term.dispose()
termContainerNode.remove()
},
})
}

View File

@@ -1,86 +0,0 @@
// 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 Type definitions for types exported by the wasm_js.go Go
* module. Not actually a .d.ts file so that we can use enums from it in
* esbuild's simplified TypeScript compiler (see https://github.com/evanw/esbuild/issues/2298#issuecomment-1146378367)
*/
declare global {
function newIPN(config: IPNConfig): IPN
interface IPN {
run(callbacks: IPNCallbacks): void
login(): void
logout(): void
ssh(
host: string,
username: string,
termConfig: {
writeFn: (data: string) => void
setReadFn: (readFn: (data: string) => void) => void
rows: number
cols: number
onDone: () => void
}
): void
}
interface IPNStateStorage {
setState(id: string, value: string): void
getState(id: string): string
}
type IPNConfig = {
stateStorage?: IPNStateStorage
}
type IPNCallbacks = {
notifyState: (state: IPNState) => void
notifyNetMap: (netMapStr: string) => void
notifyBrowseToURL: (url: string) => void
notifyPanicRecover: (err: string) => void
}
type IPNNetMap = {
self: IPNNetMapSelfNode
peers: IPNNetMapPeerNode[]
}
type IPNNetMapNode = {
name: string
addresses: string[]
machineKey: string
nodeKey: string
}
type IPNNetMapSelfNode = IPNNetMapNode & {
machineStatus: IPNMachineStatus
}
type IPNNetMapPeerNode = IPNNetMapNode & {
online?: boolean
tailscaleSSHEnabled: boolean
}
}
/** Mirrors values from ipn/backend.go */
export const enum IPNState {
NoState = 0,
InUseOtherUser = 1,
NeedsLogin = 2,
NeedsMachineAuth = 3,
Stopped = 4,
Starting = 5,
Running = 6,
}
/** Mirrors values from MachineStatus in tailcfg.go */
export const enum IPNMachineStatus {
MachineUnknown = 0,
MachineUnauthorized = 1,
MachineAuthorized = 2,
MachineInvalid = 3,
}

View File

@@ -1,8 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.ts"],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "ES2020",
"moduleResolution": "node",
"isolatedModules": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View File

@@ -1,62 +0,0 @@
// 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")
yarnPath = flag.String("yarnpath", "../../tool/yarn", "path yarn executable used to install JavaScript dependencies")
)
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)
}

View File

@@ -1,430 +0,0 @@
// 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"
"net/netip"
"strings"
"syscall/js"
"time"
"golang.org/x/crypto/ssh"
"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 netip.Addr) bool {
return true
}
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (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,
notifyPanicRecover(err: 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) != 3 {
log.Printf("Usage: ssh(hostname, userName, termConfig)")
return nil
}
go jsIPN.ssh(
args[0].String(),
args[1].String(),
args[2])
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) {
// Panics in the notify callback are likely due to be due to bugs in
// this bridging module (as opposed to actual bugs in Tailscale) and
// thus may be recoverable. Let the UI know, and allow the user to
// choose if they want to reload the page.
defer func() {
if r := recover(); r != nil {
fmt.Println("Panic recovered:", r)
jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r))
}
}()
log.Printf("NOTIFY: %+v", n)
if n.State != nil {
notifyState(*n.State)
}
if nm := n.NetMap; nm != nil && i.lb.State() == ipn.Running {
jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{
Name: nm.Name,
Addresses: mapSlice(nm.Addresses, func(a netip.Prefix) string { return a.Addr().String() }),
NodeKey: nm.NodeKey.String(),
MachineKey: nm.MachineKey.String(),
},
MachineStatus: int(nm.MachineStatus),
},
Peers: mapSlice(nm.Peers, func(p *tailcfg.Node) jsNetMapPeerNode {
name := p.Name
if name == "" {
// In practice this should only happen for Hello.
name = p.Hostinfo.Hostname()
}
return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{
Name: name,
Addresses: mapSlice(p.Addresses, func(a netip.Prefix) string { return a.Addr().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, username string, termConfig js.Value) {
writeFn := termConfig.Get("writeFn")
setReadFn := termConfig.Get("setReadFn")
rows := termConfig.Get("rows").Int()
cols := termConfig.Get("cols").Int()
onDone := termConfig.Get("onDone")
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(),
User: username,
}
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"`
MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"`
}
type jsNetMapSelfNode struct {
jsNetMapNode
MachineStatus int `json:"machineStatus"`
}
type jsNetMapPeerNode struct {
jsNetMapNode
Online *bool `json:"online,omitempty"`
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)
}

View File

@@ -1,644 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
dependencies:
"@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9"
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
version "2.0.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
"@nodelib/fs.walk@^1.2.3":
version "1.2.8"
resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
dependencies:
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@types/golang-wasm-exec@^1.15.0":
version "1.15.0"
resolved "https://registry.yarnpkg.com/@types/golang-wasm-exec/-/golang-wasm-exec-1.15.0.tgz#d0aafbb2b0dc07eaf45dfb83bfb6cdd5b2b3c55c"
integrity sha512-FrL97mp7WW8LqNinVkzTVKOIQKuYjQqgucnh41+1vRQ+bf1LT8uh++KRf9otZPXsa6H1p8ruIGz1BmCGttOL6Q==
"@types/node@*":
version "18.6.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.1.tgz#828e4785ccca13f44e2fb6852ae0ef11e3e20ba5"
integrity sha512-z+2vB6yDt1fNwKOeGbckpmirO+VBDuQqecXkgeIqDlaOtmKn6hPR/viQ8cxCfqLU4fTlvM3+YjM367TukWdxpg==
"@types/qrcode@^1.4.2":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.4.2.tgz#7d7142d6fa9921f195db342ed08b539181546c74"
integrity sha512-7uNT9L4WQTNJejHTSTdaJhfBSCN73xtXaHFyBJ8TSwiLhe4PRuTue7Iph0s2nG9R/ifUaSnGhLUOZavlBEqDWQ==
dependencies:
"@types/node" "*"
acorn-node@^1.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8"
integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==
dependencies:
acorn "^7.0.0"
acorn-walk "^7.0.0"
xtend "^4.0.2"
acorn-walk@^7.0.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
acorn@^7.0.0:
version "7.4.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
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"
anymatch@~3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
dependencies:
normalize-path "^3.0.0"
picomatch "^2.0.4"
arg@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==
binary-extensions@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
braces@^3.0.2, braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
dependencies:
fill-range "^7.0.1"
camelcase-css@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
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==
chokidar@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
dependencies:
anymatch "~3.1.2"
braces "~3.0.2"
glob-parent "~5.1.2"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.6.0"
optionalDependencies:
fsevents "~2.3.2"
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, 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==
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
defined@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
integrity sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==
detective@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.1.tgz#6af01eeda11015acb0e73f933242b70f24f91034"
integrity sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==
dependencies:
acorn-node "^1.8.2"
defined "^1.0.0"
minimist "^1.2.6"
didyoumean@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
dijkstrajs@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257"
integrity sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg==
dlv@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
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==
fast-glob@^3.2.11:
version "3.2.11"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9"
integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==
dependencies:
"@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3"
glob-parent "^5.1.2"
merge2 "^1.3.0"
micromatch "^4.0.4"
fastq@^1.6.0:
version "1.13.0"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"
integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==
dependencies:
reusify "^1.0.4"
fill-range@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
dependencies:
to-regex-range "^5.0.1"
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"
fsevents@~2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
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==
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
dependencies:
is-glob "^4.0.1"
glob-parent@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
dependencies:
is-glob "^4.0.3"
has@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
dependencies:
function-bind "^1.1.1"
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
dependencies:
binary-extensions "^2.0.0"
is-core-module@^2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69"
integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==
dependencies:
has "^1.0.3"
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
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==
is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
dependencies:
is-extglob "^2.1.1"
is-number@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
lilconfig@^2.0.5:
version "2.0.6"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4"
integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==
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"
merge2@^1.3.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
micromatch@^4.0.4:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
dependencies:
braces "^3.0.2"
picomatch "^2.3.1"
minimist@^1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
nanoid@^3.3.4:
version "3.3.4"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
object-hash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"
integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==
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==
path-parse@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
picocolors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
pify@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
pngjs@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
postcss-import@^14.1.0:
version "14.1.0"
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-14.1.0.tgz#a7333ffe32f0b8795303ee9e40215dac922781f0"
integrity sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==
dependencies:
postcss-value-parser "^4.0.0"
read-cache "^1.0.0"
resolve "^1.1.7"
postcss-js@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.0.tgz#31db79889531b80dc7bc9b0ad283e418dce0ac00"
integrity sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==
dependencies:
camelcase-css "^2.0.1"
postcss-load-config@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855"
integrity sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==
dependencies:
lilconfig "^2.0.5"
yaml "^1.10.2"
postcss-nested@5.0.6:
version "5.0.6"
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-5.0.6.tgz#466343f7fc8d3d46af3e7dba3fcd47d052a945bc"
integrity sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==
dependencies:
postcss-selector-parser "^6.0.6"
postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.6:
version "6.0.10"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d"
integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==
dependencies:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.14:
version "8.4.14"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf"
integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==
dependencies:
nanoid "^3.3.4"
picocolors "^1.0.0"
source-map-js "^1.0.2"
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"
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
quick-lru@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
read-cache@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==
dependencies:
pify "^2.3.0"
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
dependencies:
picomatch "^2.2.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==
resolve@^1.1.7, resolve@^1.22.1:
version "1.22.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
dependencies:
is-core-module "^2.9.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
reusify@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
run-parallel@^1.1.9:
version "1.2.0"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
dependencies:
queue-microtask "^1.2.2"
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=
source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
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"
supports-preserve-symlinks-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
tailwindcss@^3.1.6:
version "3.1.6"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.1.6.tgz#bcb719357776c39e6376a8d84e9834b2b19a49f1"
integrity sha512-7skAOY56erZAFQssT1xkpk+kWt2NrO45kORlxFPXUt3CiGsVPhH1smuH5XoDH6sGPXLyBv+zgCKA2HWBsgCytg==
dependencies:
arg "^5.0.2"
chokidar "^3.5.3"
color-name "^1.1.4"
detective "^5.2.1"
didyoumean "^1.2.2"
dlv "^1.1.3"
fast-glob "^3.2.11"
glob-parent "^6.0.2"
is-glob "^4.0.3"
lilconfig "^2.0.5"
normalize-path "^3.0.0"
object-hash "^3.0.0"
picocolors "^1.0.0"
postcss "^8.4.14"
postcss-import "^14.1.0"
postcss-js "^4.0.0"
postcss-load-config "^3.1.4"
postcss-nested "5.0.6"
postcss-selector-parser "^6.0.10"
postcss-value-parser "^4.2.0"
quick-lru "^5.1.1"
resolve "^1.22.1"
to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
dependencies:
is-number "^7.0.0"
typescript@^4.7.4:
version "4.7.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
util-deprecate@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
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"
xtend@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
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==
yaml@^1.10.2:
version "1.10.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
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"

View File

@@ -2,13 +2,177 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build ignore
// +build ignore
//go:build !windows
// +build !windows
// The tsshd binary was an experimental SSH server that accepts connections
// The tsshd binary is an SSH server that accepts connections
// from anybody on the same Tailscale network.
//
// Its functionality moved into tailscaled.
// It does not use passwords or SSH public key.
//
// See https://github.com/tailscale/tailscale/issues/3802
// 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})))
}

View File

@@ -0,0 +1,12 @@
// 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")
}

View File

@@ -1,58 +0,0 @@
// 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"
"net/netip"
)
//go:generate go run tailscale.com/cmd/viewer --type=StructWithPtrs,StructWithoutPtrs,Map,StructWithSlices
type StructWithoutPtrs struct {
Int int
Pfx netip.Prefix
}
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 []netip.Prefix
Data []byte
}

View File

@@ -1,183 +0,0 @@
// 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 (
"net/netip"
)
// 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 netip.Prefix
}{})
// 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 []netip.Prefix
Data []byte
}{})

View File

@@ -1,316 +0,0 @@
// 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"
"net/netip"
"go4.org/mem"
"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() netip.Prefix { 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 netip.Prefix
}{})
// 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 []netip.Prefix
Data []byte
}{})

View File

@@ -1,378 +0,0 @@
// 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/netip.Prefix", "net/netip.Prefix":
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")
}
}

View File

@@ -6,7 +6,6 @@ package controlclient
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
@@ -42,31 +41,30 @@ 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
statusFunc func(Status) // called to update Client status; always non-nil
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
unregisterHealthWatch func()
mu sync.Mutex // mutex guards the following fields
mu sync.Mutex // mutex guards the following fields
statusFunc func(Status) // called to update Client status
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
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
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
state State
authCtx context.Context // context used for auth requests
@@ -93,9 +91,6 @@ 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) {}
}
@@ -103,14 +98,13 @@ 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{}),
statusFunc: opts.Status,
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{}),
}
c.authCtx, c.authCancel = context.WithCancel(context.Background())
c.mapCtx, c.mapCancel = context.WithCancel(context.Background())
@@ -296,7 +290,6 @@ 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.")
@@ -304,7 +297,6 @@ func (c *Auto) authRoutine() {
}
if !goal.wantLoggedIn {
health.SetAuthRoutineInError(nil)
err := c.direct.TryLogout(ctx)
goal.sendLogoutError(err)
if err != nil {
@@ -343,7 +335,6 @@ func (c *Auto) authRoutine() {
f = "TryLogin"
}
if err != nil {
health.SetAuthRoutineInError(err)
report(err, f)
bo.BackOff(ctx, err)
continue
@@ -368,7 +359,6 @@ func (c *Auto) authRoutine() {
}
// success
health.SetAuthRoutineInError(nil)
c.mu.Lock()
c.loggedIn = true
c.loginGoal = nil
@@ -469,7 +459,7 @@ func (c *Auto) mapRoutine() {
c.mu.Unlock()
health.SetInPollNetMap(false)
err := c.direct.PollNetMap(ctx, func(nm *netmap.NetworkMap) {
err := c.direct.PollNetMap(ctx, -1, func(nm *netmap.NetworkMap) {
health.SetInPollNetMap(true)
c.mu.Lock()
@@ -538,6 +528,13 @@ 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")
@@ -558,20 +555,19 @@ func (c *Auto) SetNetInfo(ni *tailcfg.NetInfo) {
if !c.direct.SetNetInfo(ni) {
return
}
c.logf("NetInfo: %v", ni)
// Send new NetInfo to server
// Send new Hostinfo (which includes 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()
@@ -599,10 +595,13 @@ 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,
}
c.statusFunc(new)
if statusFunc != nil {
statusFunc(new)
}
c.mu.Lock()
c.inSendStatus--
@@ -667,8 +666,11 @@ 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.
func (c *Auto) UpdateEndpoints(endpoints []tailcfg.Endpoint) {
changed := c.direct.SetEndpoints(endpoints)
//
// 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)
if changed {
c.sendNewMapRequest()
}
@@ -683,6 +685,7 @@ func (c *Auto) Shutdown() {
direct := c.direct
if !closed {
c.closed = true
c.statusFunc = nil
}
c.mu.Unlock()

View File

@@ -11,6 +11,8 @@ package controlclient
import (
"context"
"net/http"
"time"
"tailscale.com/tailcfg"
)
@@ -27,6 +29,9 @@ 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()
@@ -42,6 +47,9 @@ 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.
@@ -67,10 +75,17 @@ 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(endpoints []tailcfg.Endpoint)
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)
}
// UserVisibleError is an error that should be shown to users.

View File

@@ -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"}
equalHandles := []string{"LoginFinished", "LogoutFinished", "Err", "URL", "NetMap", "State", "Persist", "Hostinfo"}
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)

View File

@@ -16,7 +16,6 @@ import (
"io/ioutil"
"log"
"net/http"
"net/netip"
"net/url"
"os"
"reflect"
@@ -27,6 +26,8 @@ import (
"time"
"go4.org/mem"
"golang.org/x/sync/singleflight"
"inet.af/netaddr"
"tailscale.com/control/controlknobs"
"tailscale.com/envknob"
"tailscale.com/health"
@@ -37,9 +38,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"
@@ -49,7 +50,6 @@ 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,8 +57,7 @@ import (
// Direct is the client that connects to a tailcontrol server for a node.
type Direct struct {
httpc *http.Client // HTTP client used to talk to tailcontrol
dialer *tsdial.Dialer
serverURL string // URL of the tailcontrol server
serverURL string // URL of the tailcontrol server
timeNow func() time.Time
lastPrintMap time.Time
newDecompressor func() (Decompressor, error)
@@ -77,17 +76,18 @@ type Direct struct {
serverKey key.MachinePublic // original ("legacy") nacl crypto_box-based public key
serverNoiseKey key.MachinePublic
sfGroup singleflight.Group[struct{}, *noiseClient] // protects noiseClient creation.
sfGroup singleflight.Group // protects noiseClient creation.
noiseClient *noiseClient
persist persist.Persist
authKey string
tryingNewKey key.NodePrivate
expiry *time.Time
persist persist.Persist
authKey string
tryingNewKey key.NodePrivate
expiry *time.Time
// hostinfo is mutated in-place while mu is held.
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,10 +106,6 @@ 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.
@@ -126,10 +122,11 @@ type Options struct {
Pinger Pinger
}
// Pinger is the LocalBackend.Ping method.
// Pinger is a subset of the wgengine.Engine interface, containing just the Ping method.
type Pinger interface {
// Ping is a request to do a ping with the peer handling the given IP.
Ping(ctx context.Context, ip netip.Addr, pingType tailcfg.PingType) (*ipnstate.PingResult, error)
// 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))
}
type Decompressor interface {
@@ -173,12 +170,13 @@ 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(opts.Dialer.SystemDial, dnsCache)
tr.DialTLSContext = dnscache.TLSDialer(opts.Dialer.SystemDial, dnsCache, tr.TLSClientConfig)
tr.DialContext = dnscache.Dialer(dialer.DialContext, dnsCache)
tr.DialTLSContext = dnscache.TLSDialer(dialer.DialContext, dnsCache, tr.TLSClientConfig)
tr.ForceAttemptHTTP2 = true
// Disable implicit gzip compression; the various
// handlers (register, map, set-dns, etc) do their own
@@ -204,17 +202,11 @@ 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
}
@@ -259,11 +251,14 @@ func (c *Direct) SetNetInfo(ni *tailcfg.NetInfo) bool {
c.mu.Lock()
defer c.mu.Unlock()
if reflect.DeepEqual(ni, c.netinfo) {
if c.hostinfo == nil {
c.logf("[unexpected] SetNetInfo called with no HostInfo; ignoring NetInfo update: %+v", ni)
return false
}
c.netinfo = ni.Clone()
c.logf("NetInfo: %v", ni)
if reflect.DeepEqual(ni, c.hostinfo.NetInfo) {
return false
}
c.hostinfo.NetInfo = ni.Clone()
return true
}
@@ -340,14 +335,6 @@ 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
@@ -355,7 +342,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
serverKey := c.serverKey
serverNoiseKey := c.serverNoiseKey
authKey := c.authKey
hi := c.hostInfoLocked()
hi := c.hostinfo.Clone()
backendLogID := hi.BackendLogID
expired := c.expiry != nil && !c.expiry.IsZero() && c.expiry.Before(c.timeNow())
c.mu.Unlock()
@@ -389,7 +376,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 from %s: ts2021=%s, legacy=%v", c.serverURL, keys.PublicKey.ShortString(), keys.LegacyPublicKey.ShortString())
c.logf("control server key %s from %s", serverKey.ShortString(), c.serverURL)
c.mu.Lock()
c.serverKey = keys.LegacyPublicKey
@@ -588,19 +575,20 @@ func sameEndpoints(a, b []tailcfg.Endpoint) bool {
// whether they've changed.
//
// It does not retain the provided slice.
func (c *Direct) newEndpoints(endpoints []tailcfg.Endpoint) (changed bool) {
func (c *Direct) newEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) (changed bool) {
c.mu.Lock()
defer c.mu.Unlock()
// Nothing new?
if sameEndpoints(c.endpoints, endpoints) {
if c.localPort == localPort && 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)", epStrs)
c.logf("[v2] client.newEndpoints(%v, %v)", localPort, epStrs)
c.localPort = localPort
c.endpoints = append(c.endpoints[:0], endpoints...)
if len(endpoints) > 0 {
c.everEndpoints = true
@@ -611,37 +599,28 @@ func (c *Direct) newEndpoints(endpoints []tailcfg.Endpoint) (changed bool) {
// 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(endpoints []tailcfg.Endpoint) (changed bool) {
func (c *Direct) SetEndpoints(localPort uint16, 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(endpoints)
return c.newEndpoints(localPort, 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.
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
//
// 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)
}
// 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, false, nil)
return c.sendMapRequest(ctx, 1, nil)
}
// If we go more than pollTimeout without hearing from the server,
@@ -650,7 +629,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, readOnly bool, cb func(*netmap.NetworkMap)) error {
func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netmap.NetworkMap)) error {
metricMapRequests.Add(1)
metricMapRequestsActive.Add(1)
defer metricMapRequestsActive.Add(-1)
@@ -665,8 +644,9 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
serverURL := c.serverURL
serverKey := c.serverKey
serverNoiseKey := c.serverNoiseKey
hi := c.hostInfoLocked()
hi := c.hostinfo.Clone()
backendLogID := hi.BackendLogID
localPort := c.localPort
var epStrs []string
var epTypes []tailcfg.EndpointType
for _, ep := range c.endpoints {
@@ -692,7 +672,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
}
allowStream := maxPolls != 1
c.logf("[v1] PollNetMap: stream=%v ep=%v", allowStream, epStrs)
c.logf("[v1] PollNetMap: stream=%v :%v ep=%v", allowStream, localPort, epStrs)
vlogf := logger.Discard
if Debug.NetMap {
@@ -712,16 +692,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
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 &&
@@ -744,6 +714,17 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
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 {
@@ -948,6 +929,14 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
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.)
@@ -1167,8 +1156,8 @@ func TrimWGConfig() opt.Bool {
// It should not return false positives.
//
// TODO(bradfitz): Change controlclient.Options.SkipIPForwardingCheck into a
// func([]netip.Prefix) error signature instead.
func ipForwardingBroken(routes []netip.Prefix, state *interfaces.State) bool {
// func([]netaddr.IPPrefix) error signature instead.
func ipForwardingBroken(routes []netaddr.IPPrefix, state *interfaces.State) bool {
warn, err := netutil.CheckIPForwarding(routes, state)
if err != nil {
// Oh well, we tried. This is just for debugging.
@@ -1206,9 +1195,12 @@ func answerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pinge
return
}
for _, t := range strings.Split(pr.Types, ",") {
switch pt := tailcfg.PingType(t); pt {
case tailcfg.PingTSMP, tailcfg.PingDisco, tailcfg.PingICMP, tailcfg.PingPeerAPI:
go doPingerPing(logf, c, pr, pinger, pt)
switch t {
case "TSMP", "disco":
go doPingerPing(logf, c, pr, pinger, t)
// TODO(tailscale/corp#754)
// case "host":
// case "peerapi":
default:
logf("unsupported ping request type: %q", t)
}
@@ -1280,12 +1272,13 @@ func (c *Direct) getNoiseClient() (*noiseClient, error) {
if nc != nil {
return nc, nil
}
nc, err, _ := c.sfGroup.Do(struct{}{}, func() (*noiseClient, error) {
np, err, _ := c.sfGroup.Do("noise", func() (any, error) {
k, err := c.getMachinePrivKey()
if err != nil {
return nil, err
}
nc, err := newNoiseClient(k, serverNoiseKey, c.serverURL, c.dialer)
nc, err = newNoiseClient(k, serverNoiseKey, c.serverURL)
if err != nil {
return nil, err
}
@@ -1297,7 +1290,7 @@ func (c *Direct) getNoiseClient() (*noiseClient, error) {
if err != nil {
return nil, err
}
return nc, nil
return np.(*noiseClient), nil
}
// setDNSNoise sends the SetDNSRequest request to the control plane server over Noise,
@@ -1407,23 +1400,16 @@ 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 tailcfg.PingType) {
if pr.URL == "" || !pr.IP.IsValid() || pinger == nil {
func doPingerPing(logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, pinger Pinger, pingType string) {
if pr.URL == "" || pr.IP.IsZero() || pinger == nil {
logf("invalid ping request: missing url, ip or pinger")
return
}
start := time.Now()
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))
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))
})
}
func postPingResult(start time.Time, logf logger.Logf, c *http.Client, pr *tailcfg.PingRequest, res *tailcfg.PingResponse) error {

View File

@@ -8,13 +8,12 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"time"
"inet.af/netaddr"
"tailscale.com/hostinfo"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)
@@ -31,7 +30,6 @@ func TestNewDirect(t *testing.T) {
GetMachinePrivateKey: func() (key.MachinePrivate, error) {
return k, nil
},
Dialer: new(tsdial.Dialer),
}
c, err := NewDirect(opts)
if err != nil {
@@ -68,25 +66,29 @@ func TestNewDirect(t *testing.T) {
}
endpoints := fakeEndpoints(1, 2, 3)
changed = c.newEndpoints(endpoints)
changed = c.newEndpoints(12, endpoints)
if !changed {
t.Errorf("c.newEndpoints want true got %v", changed)
t.Errorf("c.newEndpoints(12) want true got %v", changed)
}
changed = c.newEndpoints(endpoints)
changed = c.newEndpoints(12, endpoints)
if changed {
t.Errorf("c.newEndpoints want false got %v", 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)
}
endpoints = fakeEndpoints(4, 5, 6)
changed = c.newEndpoints(endpoints)
changed = c.newEndpoints(13, endpoints)
if !changed {
t.Errorf("c.newEndpoints want true got %v", changed)
t.Errorf("c.newEndpoints(13) want true got %v", changed)
}
}
func fakeEndpoints(ports ...uint16) (ret []tailcfg.Endpoint) {
for _, port := range ports {
ret = append(ret, tailcfg.Endpoint{
Addr: netip.AddrPortFrom(netip.Addr{}, port),
Addr: netaddr.IPPortFrom(netaddr.IP{}, port),
})
}
return
@@ -104,7 +106,6 @@ func TestTsmpPing(t *testing.T) {
GetMachinePrivateKey: func() (key.MachinePrivate, error) {
return k, nil
},
Dialer: new(tsdial.Dialer),
}
c, err := NewDirect(opts)

View File

@@ -5,11 +5,10 @@
package controlclient
import (
"fmt"
"log"
"net/netip"
"sort"
"inet.af/netaddr"
"tailscale.com/envknob"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
@@ -166,6 +165,12 @@ 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
}
@@ -239,7 +244,7 @@ func undeltaPeers(mapRes *tailcfg.MapResponse, prev []*tailcfg.Node) {
sortNodes(newFull)
}
if len(mapRes.PeerSeenChange) != 0 || len(mapRes.OnlineChange) != 0 || len(mapRes.PeersChangedPatch) != 0 {
if len(mapRes.PeerSeenChange) != 0 || len(mapRes.OnlineChange) != 0 {
peerByID := make(map[tailcfg.NodeID]*tailcfg.Node, len(newFull))
for _, n := range newFull {
peerByID[n.ID] = n
@@ -260,16 +265,6 @@ 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
@@ -303,13 +298,13 @@ func cloneNodes(v1 []*tailcfg.Node) []*tailcfg.Node {
var debugSelfIPv6Only = envknob.Bool("TS_DEBUG_SELF_V6_ONLY")
func filterSelfAddresses(in []netip.Prefix) (ret []netip.Prefix) {
func filterSelfAddresses(in []netaddr.IPPrefix) (ret []netaddr.IPPrefix) {
switch {
default:
return in
case debugSelfIPv6Only:
for _, a := range in {
if a.Addr().Is6() {
if a.IP().Is6() {
ret = append(ret, a)
}
}

View File

@@ -34,16 +34,6 @@ 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 {
@@ -147,53 +137,7 @@ 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() {

View File

@@ -18,10 +18,8 @@ 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"
)
@@ -47,7 +45,6 @@ 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
@@ -60,7 +57,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, dialer *tsdial.Dialer) (*noiseClient, error) {
func newNoiseClient(priKey key.MachinePrivate, serverPubKey key.MachinePublic, serverURL string) (*noiseClient, error) {
u, err := url.Parse(serverURL)
if err != nil {
return nil, err
@@ -77,7 +74,6 @@ 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
@@ -141,6 +137,9 @@ 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()
@@ -154,7 +153,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), nc.dialer.SystemDial)
conn, err := controlhttp.Dial(ctx, nc.serverHost, nc.privKey, nc.serverPubKey, uint16(tailcfg.CurrentCapabilityVersion))
if err != nil {
return nil, err
}
@@ -162,6 +161,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}
mak.Set(&nc.connPool, ncc.id, ncc)
nc.connPool[ncc.id] = ncc
return ncc, nil
}

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"reflect"
"tailscale.com/tailcfg"
"tailscale.com/types/empty"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
@@ -74,8 +75,9 @@ 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
State State
Persist *persist.Persist // locally persisted configuration
Hostinfo *tailcfg.Hostinfo // current Hostinfo data
}
// Equal reports whether s and s2 are equal.
@@ -90,6 +92,7 @@ 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
}

View File

@@ -2,9 +2,6 @@
// 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.
//
@@ -28,21 +25,37 @@ 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.
@@ -52,12 +65,13 @@ import (
//
// 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, dialer dnscache.DialContextFunc) (*controlbase.Conn, error) {
func Dial(ctx context.Context, addr string, machineKey key.MachinePrivate, controlKey key.MachinePublic, protocolVersion uint16) (*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",
@@ -65,12 +79,12 @@ func Dial(ctx context.Context, addr string, machineKey key.MachinePrivate, contr
controlKey: controlKey,
version: protocolVersion,
proxyFunc: tshttpproxy.ProxyFromEnvironment,
dialer: dialer,
}
return a.dial(ctx)
return a.dial()
}
type dialParams struct {
ctx context.Context
host string
httpPort string
httpsPort string
@@ -78,132 +92,65 @@ 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
testFallbackDelay time.Duration
insecureTLS bool
}
// 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
}
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,
}
u443 := &url.URL{
Scheme: "https",
Host: net.JoinHostPort(a.host, a.httpsPort),
Path: serverUpgradePath,
}
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) {
func (a *dialParams) dial() (*controlbase.Conn, error) {
init, cont, err := controlbase.ClientDeferred(a.machineKey, a.controlKey, a.version)
if err != nil {
return nil, err
}
netConn, err := a.tryURLUpgrade(ctx, u, init)
u := &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
}
// 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)
if err != nil {
return nil, err
}
cbConn, err := cont(ctx, netConn)
if err != nil {
netConn.Close()
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
}
return cbConn, nil
return nil, fmt.Errorf("all connection attempts failed (HTTP: %v, HTTPS: %v)", httpErr, tlsErr)
}
// 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) {
func (a *dialParams) tryURL(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(a.dialer, dns)
tr.DialContext = dnscache.Dialer(dialer.DialContext, dns)
// Disable HTTP2, since h2 can't do protocol switching.
tr.TLSClientConfig.NextProtos = []string{}
tr.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
@@ -212,7 +159,7 @@ func (a *dialParams) tryURLUpgrade(ctx context.Context, u *url.URL, init []byte)
tr.TLSClientConfig.InsecureSkipVerify = true
tr.TLSClientConfig.VerifyConnection = nil
}
tr.DialTLSContext = dnscache.TLSDialer(a.dialer, dns, tr.TLSClientConfig)
tr.DialTLSContext = dnscache.TLSDialer(dialer.DialContext, dns, tr.TLSClientConfig)
tr.DisableCompression = true
// (mis)use httptrace to extract the underlying net.Conn from the
@@ -242,7 +189,7 @@ func (a *dialParams) tryURLUpgrade(ctx context.Context, u *url.URL, init []byte)
connCh <- info.Conn
},
}
ctx = httptrace.WithClientTrace(ctx, &trace)
ctx := httptrace.WithClientTrace(a.ctx, &trace)
req := &http.Request{
Method: "POST",
URL: u,

View File

@@ -1,61 +0,0 @@
// 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
}

View File

@@ -1,20 +0,0 @@
// 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"
)

Some files were not shown because too many files have changed in this diff Show More