Compare commits

..

35 Commits

Author SHA1 Message Date
Denton Gentry
f63619299b Try running vm.yml on a 22.04 runner.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-09-27 20:11:43 -07:00
Andrew Dunham
a5fab23e8f net/dns: format OSConfig correctly with no pointers (#5766)
Fixes tailscale/tailscale#5669

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2022-09-27 19:30:39 -04:00
Andrew Dunham
4b996ad5e3 util/deephash: add AppendSum method (#5768)
This method can be used to obtain the hex-formatted deephash.Sum
instance without allocations.

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2022-09-27 18:22:31 -04:00
Tom DNetto
ebd1637e50 ipn/ipnlocal,tailcfg: Identify client using NodeKey in tka RPCs
Updates https://github.com/tailscale/corp/pull/7024

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-09-27 09:37:28 -07:00
Mihai Parparita
e97d5634bf control/controlhttp: use custom port for non-localhost JS noise client connections
Control may not be bound to (just) localhost when sharing dev servers,
allow the Wasm client to connect to it in that case too.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-26 17:19:32 -07:00
Emmanuel T Odeke
f981b1d9da all: fix resource leaks with missing .Close() calls
Fixes #5706

Signed-off-by: Emmanuel T Odeke <emmanuel@orijtech.com>
2022-09-26 15:31:54 -07:00
Brad Fitzpatrick
9bdf0cd8cd ipn/ipnlocal: add c2n /debug/{goroutines,prefs,metrics}
* and move goroutine scrubbing code to its own package for reuse
* bump capver to 45

Change-Id: I9b4dfa5af44d2ecada6cc044cd1b5674ee427575
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-26 11:16:38 -07:00
Josh Soref
7686446c60 Drop duplicated $
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2022-09-26 10:10:50 -07:00
Andrew Dunham
b1867457a6 doctor: add package for running in-depth healthchecks; use in bugreport (#5413)
Change-Id: Iaa4e5b021a545447f319cfe8b3da2bd3e5e5782b
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2022-09-26 13:07:28 -04:00
Tom DNetto
e3beb4429f tka: Checkpoint every 50 updates
Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-09-26 10:07:14 -07:00
Brad Fitzpatrick
8a0cb4ef20 control/controlclient: fix recent set-dns regression
SetDNS calls were broken by 6d04184325 the other day. Unreleased.

Caught by tests in another repo.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-25 16:23:55 -07:00
Brad Fitzpatrick
fb4e23506f control/controlclient: stop restarting map polls on health change
At some point we started restarting map polls on health change, but we
don't remember why. Maybe it was a desperate workaround for something.
I'm not sure it ever worked.

Rather than have a haunted graveyard, remove it.

In its place, though, and somewhat as a safety backup, send those
updates over the HTTP/2 noise channel if we have one open. Then if
there was a reason that a map poll restart would help we could do it
server-side. But mostly we can gather error stats and show
machine-level health info for debugging.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-24 08:51:34 -07:00
Brad Fitzpatrick
6d04184325 control/controlclient: add a noiseClient.post helper method
In prep for a future change that would've been very copy/paste-y.

And because the set-dns call doesn't currently use a context,
so timeouts/cancelations are plumbed.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-23 13:18:22 -07:00
Will Norris
8c72aabbdf licenses: remove win.md file
This was renamed to windows.md
2022-09-23 11:21:25 -07:00
James Tucker
f7cb535693 net/speedtest: retune to meet iperf on localhost in a VM
- removed some in-flow time calls
- increase buffer size to 2MB to overcome syscall cost
- move relative time computation from record to report time

Signed-off-by: James Tucker <james@tailscale.com>
2022-09-23 10:46:04 -07:00
James Tucker
146f51ce76 net/packet: fix filtering of short IPv4 fragments
The fragment offset is an 8 byte offset rather than a byte offset, so
the short packet limit is now in fragment block size in order to compare
with the offset value.

The packet flags are in the first 3 bits of the flags/frags byte, and
so after conversion to a uint16 little endian value they are at the
start, not the end of the value - the mask for extracting "more
fragments" is adjusted to match this byte.

Extremely short fragments less than 80 bytes are dropped, but fragments
over 80 bytes are now accepted.

Fixes #5727

Signed-off-by: James Tucker <james@tailscale.com>
2022-09-23 10:43:28 -07:00
Mihai Parparita
c66e15772f tsweb: consider 304s as successful for quiet logging
Static resource handlers will generate lots of 304s, which are
effectively successful responses.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-23 10:40:32 -07:00
Andrew Dunham
e1bdbfe710 tailcfg, control/controlhttp, control/controlclient: add ControlDialPlan field (#5648)
* tailcfg, control/controlhttp, control/controlclient: add ControlDialPlan field

This field allows the control server to provide explicit information
about how to connect to it; useful if the client's link status can
change after the initial connection, or if the DNS settings pushed by
the control server break future connections.

Change-Id: I720afe6289ec27d40a41b3dcb310ec45bd7e5f3e
Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2022-09-23 13:06:55 -04:00
Aaron Klotz
acc7baac6d tailcfg, util/deephash: add DataPlaneAuditLogID to Node and DomainDataPlaneAuditLogID to MapResponse
We're adding two log IDs to facilitate data-plane audit logging: a node-specific
log ID, and a domain-specific log ID.

Updated util/deephash/deephash_test.go with revised expectations for tailcfg.Node.

Updates https://github.com/tailscale/corp/issues/6991

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2022-09-22 17:18:28 -06:00
Kyle Carberry
91794f6498 wgengine/magicsock: move firstDerp check after nil derpMap check
This fixes a race condition which caused `c.muCond.Broadcast()` to
never fire in the `firstDerp` if block. It resulted in `Close()`
hanging forever.

Signed-off-by: Kyle Carberry <kyle@carberry.com>
2022-09-22 11:54:56 -07:00
Brad Fitzpatrick
2c447de6cc cmd/tailscaled: use explicit equal sign in --port=$PORT in tailscaled.service
Personal preference (so it's obvious it's not a bool flag), but it
also matches the --state= before it.

Bonus: stop allowing PORT to sneak in extra flags to be passed as
their own arguments, as $FOO and ${FOO} expand differently. (${FOO} is
required to concat to strings)

Change-Id: I994626a5663fe0948116b46a971e5eb2c4023216
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-22 11:54:22 -07:00
Anton Schubert
021bedfb89 docker: add ability to use a custom control socket
Signed-off-by: Anton Schubert <anton.schubert@riedel.net>
2022-09-22 08:48:26 -07:00
hlts2
d988c9f098 fix auth key name
Signed-off-by: hlts2 <hiroto.funakoshi.hiroto@gmail.com>
2022-09-22 03:55:05 -07:00
Andrew Dunham
0607832397 wgengine/netstack: always respond to 4via6 echo requests (#5712)
As the comment in the code says, netstack should always respond to ICMP
echo requests to a 4via6 address, even if the netstack instance isn't
normally processing subnet traffic.

Follow-up to #5709

Change-Id: I504d0776c5824071b2a2e0e687bc33e24f6c4746
Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2022-09-21 18:07:57 -04:00
Will Norris
565dbc599a Revert "licenses: update win/apple licenses"
This reverts commit aadf63da1d.
2022-09-21 14:28:43 -07:00
License Updater
aadf63da1d licenses: update win/apple licenses
Signed-off-by: License Updater <noreply@tailscale.com>
2022-09-21 14:19:57 -07:00
Maisem Ali
d5781f61a9 ipn/ipnlocal: return usernames when Tailscale SSH is enabled
It was checking if the sshServer was initialized as a proxy, but that
could either not have been initialized yet or Tailscale SSH could have
been disabled after intialized.

Also bump tailcfg.CurrentCapabilityVersion

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-09-21 14:06:40 -07:00
Mihai Parparita
a7a0baf6b9 cmd/tsconnect: add error callback for SSH sessions
We were just logging them to the console, which is useful for debugging,
but we may want to show them in the UI too.

Updates tailscale/corp#6939

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-21 13:09:53 -07:00
Tom DNetto
e9b98dd2e1 control/controlclient,ipn/ipnlocal: wire tka enable/disable
Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-09-21 12:57:59 -07:00
Andrew Dunham
b9b0bf65a0 wgengine/netstack: handle 4via6 packets when pinging (#5709)
Change-Id: Ib6ebbaa11219fb91b550ed7fc6ede61f83262e89
Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2022-09-21 14:19:34 -04:00
Andrew Dunham
c6162c2a94 net/netcheck: add check for captive portal (#5593)
This doesn't change any behaviour for now, other than maybe running a
full netcheck more often. The intent is to start gathering data on
captive portals, and additionally, seeing this in the 'tailscale
netcheck' command should provide a bit of additional information to
users.

Updates #1634

Change-Id: I6ba08f9c584dc0200619fa97f9fde1a319f25c76
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2022-09-20 15:31:49 -04:00
Brad Fitzpatrick
aa5e494aba tsweb: export go_version in standard expvar vars
For monitoring.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-20 09:59:43 -07:00
Berk D. Demir
ff13c66f55 cmd/tailscale: fix configure-host command for Synology
d5e7e309 changed the `hostinfo.GetVersion` from distro and distro version
to UTS Name Release and moved distribution information under
`hostinfo.Distro*`.

`tailscale configure-host` command implementation for Synology DSM
environments relies on the old semantics of this string for matching DSM
Major version so it's been broken for a few days.

Pull in `hostinfo` and prefix match `hostinfo.DistroVersion` to match
DSM major version.

Signed-off-by: Berk D. Demir <bdd@mindcast.org>
2022-09-19 21:15:21 -07:00
Brad Fitzpatrick
ed248b04a7 cmd/tailscale: remove leftover debug prints from earlier commit
From 6632504f45

Change-Id: If21789232b3ecc14c1639cf87814af6fa73f535f
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-19 21:13:56 -07:00
Mihai Parparita
8158dd2edc cmd/tsconnect: allow SSH connection timeout to be overridden
5 seconds may not be enough if we're still loading the derp map and
connecting to a slow machine.

Updates #5693

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-19 18:00:12 -07:00
87 changed files with 3725 additions and 408 deletions

View File

@@ -2,7 +2,7 @@ name: CIFuzz
on: [pull_request]
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:

View File

@@ -11,7 +11,7 @@ concurrency:
jobs:
ubuntu2004-LTS-cloud-base:
runs-on: [ self-hosted, linux, vm ]
runs-on: ubuntu-22.04
if: "!contains(github.event.head_commit.message, '[ci skip]')"
@@ -32,7 +32,7 @@ jobs:
env:
HOME: "/tmp"
TMPDIR: "/tmp"
XDG_CACHE_HOME: "/var/lib/ghrunner/cache"
XDG_CACHE_HOME: "$HOME/.cache"
- uses: k0kubun/action-slack@v2.0.0
with:

132
api.md
View File

@@ -3,12 +3,11 @@
The Tailscale API is a (mostly) RESTful API. Typically, POST bodies should be JSON encoded and responses will be JSON encoded.
# Authentication
Currently based on {some authentication method}. Visit the [admin panel](https://login.tailscale.com/admin) and navigate to the `Settings` page. Generate an API Key and keep it safe. Provide the key as the user key in basic auth when making calls to Tailscale API endpoints (leave the password blank).
# APIs
- **[Devices](#device)**
* **[Devices](#device)**
- [GET device](#device-get)
- [DELETE device](#device-delete)
- Routes
@@ -20,12 +19,12 @@ Currently based on {some authentication method}. Visit the [admin panel](https:/
- [POST device tags](#device-tags-post)
- Key
- [POST device key](#device-key-post)
- **[Tailnets](#tailnet)**
* **[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 validate](#tailnet-acl-validate-post)
- [Devices](#tailnet-devices)
- [GET tailnet devices](#tailnet-devices-get)
- [Keys](#tailnet-keys)
@@ -42,9 +41,7 @@ Currently based on {some authentication method}. Visit the [admin panel](https:/
- [POST tailnet DNS searchpaths](#tailnet-dns-searchpaths-post)
## 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".
You can use the deviceID to specify operations on a specific device, like retrieving its subnet routes.
@@ -55,23 +52,20 @@ This is your deviceID.
<a name=device-get></a>
#### `GET /api/v2/device/:deviceid` - lists the details for a device
Returns the details for the specified device.
Supply the device of interest in the path using its ID.
Use the `fields` query parameter to explicitly indicate which fields are returned.
##### Parameters
##### Query Parameters
`fields` - Controls which fields will be included in the returned response.
Currently, supported options are:
- `all`: returns all fields in the response.
- `default`: return all fields except:
- `enabledRoutes`
- `advertisedRoutes`
- `clientConnectivity` (which contains the following fields: `mappingVariesByDestIP`, `derp`, `endpoints`, `latency`, and `clientSupports`)
* `all`: returns all fields in the response.
* `default`: return all fields except:
* `enabledRoutes`
* `advertisedRoutes`
* `clientConnectivity` (which contains the following fields: `mappingVariesByDestIP`, `derp`, `endpoints`, `latency`, and `clientSupports`)
Use commas to separate multiple options.
If more than one option is indicated, then the union is used.
@@ -79,7 +73,6 @@ For example, for `fields=default,all`, all fields are returned.
If the `fields` parameter is not provided, then the default option is used.
##### Example
```
GET /api/v2/device/12345
curl 'https://api.tailscale.com/api/v2/device/12345?fields=all' \
@@ -87,7 +80,6 @@ curl 'https://api.tailscale.com/api/v2/device/12345?fields=all' \
```
Response
```
{
"addresses":[
@@ -149,18 +141,16 @@ Response
<a name=device-delete></a>
#### `DELETE /api/v2/device/:deviceID` - deletes the 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.
##### Parameters
##### Parameters
No parameters.
##### Example
```
DELETE /api/v2/device/12345
curl -X DELETE 'https://api.tailscale.com/api/v2/device/12345' \
@@ -170,7 +160,6 @@ curl -X DELETE 'https://api.tailscale.com/api/v2/device/12345' \
Response
If successful, the response should be empty:
```
< HTTP/1.1 200 OK
...
@@ -179,13 +168,13 @@ If successful, the response should be empty:
```
If the device is not owned by your tailnet:
```
< HTTP/1.1 501 Not Implemented
...
{"message":"cannot delete devices outside of your tailnet"}
```
<a name=device-routes-get></a>
#### `GET /api/v2/device/:deviceID/routes` - fetch subnet routes that are advertised and enabled for a device
@@ -204,7 +193,6 @@ curl 'https://api.tailscale.com/api/v2/device/11055/routes' \
```
Response
```
{
"advertisedRoutes" : [
@@ -225,9 +213,7 @@ Sets which subnet routes are enabled to be routed by a device by replacing the e
##### Parameters
###### POST Body
`routes` - The new list of enabled subnet routes in JSON.
```
{
"routes": ["10.0.1.0/24", "1.2.0.0/16", "2.0.0.0/24"]
@@ -268,9 +254,7 @@ Marks a device as authorized, for Tailnets where device authorization is require
##### Parameters
###### POST Body
`authorized` - whether the device is authorized; only `true` is currently supported.
```
{
"authorized": true
@@ -351,9 +335,9 @@ The response is 2xx on success. The response body is currently an empty JSON
object.
## Tailnet
A tailnet is the name of your Tailscale network.
You can find it in the [Settings](https://login.tailscale.com/admin/settings/) page of the admin console.
You can find it in the top left corner of the [Admin Panel](https://login.tailscale.com/admin) beside the Tailscale logo.
`alice@example.com` belongs to the `example.com` tailnet and would use the following format for API calls:
@@ -362,10 +346,10 @@ GET /api/v2/tailnet/example.com/...
curl https://api.tailscale.com/api/v2/tailnet/example.com/...
```
For solo plans, the tailnet is the email you signed up with.
So `alice@gmail.com` has the tailnet `alice@gmail.com` since `@gmail.com` is a shared email host.
Her API calls would have the following format:
```
GET /api/v2/tailnet/alice@gmail.com/...
curl https://api.tailscale.com/api/v2/tailnet/alice@gmail.com/...
@@ -386,19 +370,15 @@ Retrieves the ACL that is currently set for the given tailnet. Supply the tailne
##### Parameters
###### Headers
`Accept` - Response is parsed `JSON` if `application/json` is explicitly named, otherwise HuJSON will be returned.
##### Returns
Returns the ACL HuJSON by default. Returns a parsed JSON of the ACL (sans comments) if the `Accept` type is explicitly set to `application/json`. An `ETag` header is also sent in the response, which can be optionally used in POST requests to avoid missed updates.
<!-- TODO (chungdaniel): define error types and a set of docs for them -->
##### Example
###### Requesting a HuJSON response:
```
GET /api/v2/tailnet/example.com/acl
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \
@@ -408,7 +388,6 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \
```
Response
```
...
Content-Type: application/hujson
@@ -447,7 +426,6 @@ Etag: "e0b2816b418b3f266309d94426ac7668ab3c1fa87798785bf82f1085cc2f6d9c"
```
###### Requesting a JSON response:
```
GET /api/v2/tailnet/example.com/acl
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \
@@ -457,7 +435,6 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \
```
Response
```
...
Content-Type: application/json
@@ -500,7 +477,6 @@ Returns the updated ACL in JSON or HuJSON according to the `Accept` header on su
##### Parameters
###### Headers
`If-Match` - A request header. Set this value to the ETag header provided in an `ACL GET` request to avoid missed updates.
`Accept` - Sets the return type of the updated ACL. Response is parsed `JSON` if `application/json` is explicitly named, otherwise HuJSON will be returned.
@@ -510,16 +486,15 @@ Returns the updated ACL in JSON or HuJSON according to the `Accept` header on su
The POST body should be a JSON or [HuJSON](https://github.com/tailscale/hujson#hujson---human-json) formatted JSON object.
An ACL policy may contain the following top-level properties:
- `Groups` - Static groups of users which can be used for ACL rules.
- `Hosts` - Hostname aliases to use in place of IP addresses or subnets.
- `ACLs` - Access control lists.
- `TagOwners` - Defines who is allowed to use which tags.
- `Tests` - Run on ACL updates to check correct functionality of defined ACLs.
* `Groups` - Static groups of users which can be used for ACL rules.
* `Hosts` - Hostname aliases to use in place of IP addresses or subnets.
* `ACLs` - Access control lists.
* `TagOwners` - Defines who is allowed to use which tags.
* `Tests` - Run on ACL updates to check correct functionality of defined ACLs.
See https://tailscale.com/kb/1018/acls for more information on those properties.
##### Example
```
POST /api/v2/tailnet/example.com/acl
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \
@@ -549,7 +524,6 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \
```
Response:
```
// Example/default ACLs for unrestricted connections.
{
@@ -575,7 +549,6 @@ Response:
```
Failed test error response:
```
{
"message": "test(s) failed",
@@ -599,17 +572,14 @@ Determines what rules match for a user on an ACL without saving the ACL to the s
##### Parameters
###### Query Parameters
`type` - can be 'user' or 'ipport'
`previewFor` - if type=user, a user's email. If type=ipport, a IP address + port like "10.0.0.1:80".
The provided ACL is queried with this parameter to determine which rules match.
###### POST Body
ACL JSON or HuJSON (see https://tailscale.com/kb/1018/acls)
##### Example
```
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/preview?previewFor=user1@example.com&type=user' \
-u "tskey-yourapikey123:" \
@@ -637,7 +607,6 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/preview?previewFo
```
Response:
```
{"matches":[{"users":["*"],"ports":["*:*"],"lineNumber":19}],"user":"user1@example.com"}
```
@@ -662,7 +631,6 @@ 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
```
POST /api/v2/tailnet/example.com/acl/validate
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate' \
@@ -674,7 +642,6 @@ 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' \
@@ -717,23 +684,21 @@ 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.
Supply the tailnet of interest in the path.
Use the `fields` query parameter to explicitly indicate which fields are returned.
##### Parameters
###### Query Parameters
`fields` - Controls which fields will be included in the returned response.
Currently, supported options are:
- `all`: Returns all fields in the response.
- `default`: return all fields except:
- `enabledRoutes`
- `advertisedRoutes`
- `clientConnectivity` (which contains the following fields: `mappingVariesByDestIP`, `derp`, `endpoints`, `latency`, and `clientSupports`)
* `all`: Returns all fields in the response.
* `default`: return all fields except:
* `enabledRoutes`
* `advertisedRoutes`
* `clientConnectivity` (which contains the following fields: `mappingVariesByDestIP`, `derp`, `endpoints`, `latency`, and `clientSupports`)
Use commas to separate multiple options.
If more than one option is indicated, then the union is used.
@@ -749,7 +714,6 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/devices' \
```
Response
```
{
"devices":[
@@ -812,7 +776,6 @@ for the user who owns the API key used to perform this query.
Supply the tailnet of interest in the path.
##### Parameters
No parameters.
##### Returns
@@ -829,7 +792,6 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/keys' \
```
Response:
```
{"keys": [
{"id": "kYKVU14CNTRL"},
@@ -850,9 +812,7 @@ Supply the tailnet in the path.
##### Parameters
###### POST Body
`capabilities` - A mapping of resources to permissible actions.
```
{
"capabilities": {
@@ -899,7 +859,6 @@ echo '{
```
Response:
```
{
"id": "k123456CNTRL",
@@ -918,7 +877,6 @@ Returns a JSON object with information about specific key.
Supply the tailnet and key ID of interest in the path.
##### Parameters
No parameters.
##### Returns
@@ -935,7 +893,6 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/keys/k123456CNTRL' \
```
Response:
```
{
"id": "k123456CNTRL",
@@ -965,11 +922,9 @@ Deletes a specific key.
Supply the tailnet and key ID of interest in the path.
##### Parameters
No parameters.
##### Returns
This reports status 200 upon success.
##### Example
@@ -986,12 +941,10 @@ 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.
Supply the tailnet of interest in the path.
##### Parameters
No parameters.
##### Example
@@ -1003,7 +956,6 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers' \
```
Response
```
{
"dns": ["8.8.8.8"],
@@ -1013,17 +965,13 @@ 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.
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).
##### Parameters
###### POST Body
`dns` - The new list of DNS nameservers in JSON.
```
{
"dns":["8.8.8.8"]
@@ -1031,15 +979,12 @@ 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.
If all nameservers have been removed, MagicDNS will be automatically disabled (until explicitly turned back on by the user).
##### Example
###### Adding DNS nameservers with the MagicDNS on:
```
POST /api/v2/tailnet/example.com/dns/nameservers
curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers' \
@@ -1048,7 +993,6 @@ curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameserve
```
Response:
```
{
"dns":["8.8.8.8"],
@@ -1057,7 +1001,6 @@ Response:
```
###### Removing all DNS nameservers with the MagicDNS on:
```
POST /api/v2/tailnet/example.com/dns/nameservers
curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameservers' \
@@ -1066,7 +1009,6 @@ curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/nameserve
```
Response:
```
{
"dns":[],
@@ -1077,16 +1019,13 @@ Response:
<a name=tailnet-dns-preferences-get></a>
#### `GET /api/v2/tailnet/:tailnet/dns/preferences` - retrieves the DNS preferences for a tailnet
Retrieves the DNS preferences that are currently set for the given tailnet.
Supply the tailnet of interest in the path.
##### Parameters
No parameters.
##### Example
```
GET /api/v2/tailnet/example.com/dns/preferences
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/preferences' \
@@ -1094,7 +1033,6 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/preferences' \
```
Response:
```
{
"magicDNS":false,
@@ -1104,7 +1042,6 @@ Response:
<a name=tailnet-dns-preferences-post></a>
#### `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.
@@ -1114,12 +1051,9 @@ 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
###### POST Body
The DNS preferences in JSON. Currently, MagicDNS is the only setting available.
`magicDNS` - Automatically registers DNS names for devices in your tailnet.
`magicDNS` - Automatically registers DNS names for devices in your tailnet.
```
{
"magicDNS": true
@@ -1127,7 +1061,6 @@ The DNS preferences in JSON. Currently, MagicDNS is the only setting available.
```
##### Example
```
POST /api/v2/tailnet/example.com/dns/preferences
curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/preferences' \
@@ -1135,10 +1068,10 @@ curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/preferenc
--data-binary '{"magicDNS": true}'
```
Response:
If there are no DNS servers, it returns an error message:
```
{
"message":"need at least one nameserver to enable MagicDNS"
@@ -1146,7 +1079,6 @@ If there are no DNS servers, it returns an error message:
```
If there are DNS servers:
```
{
"magicDNS":true,
@@ -1156,16 +1088,14 @@ 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.
Supply the tailnet of interest in the path.
##### Parameters
##### Parameters
No parameters.
##### Example
```
GET /api/v2/tailnet/example.com/dns/searchpaths
curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/searchpaths' \
@@ -1173,7 +1103,6 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/searchpaths' \
```
Response:
```
{
"searchPaths": ["user1.example.com"],
@@ -1183,15 +1112,12 @@ Response:
<a name=tailnet-dns-searchpaths-post></a>
#### `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
###### POST Body
`searchPaths` - A list of searchpaths in JSON.
```
{
"searchPaths": ["user1.example.com", "user2.example.com"]
@@ -1199,7 +1125,6 @@ Replaces the list of searchpaths with the list supplied by the user and returns
```
##### Example
```
POST /api/v2/tailnet/example.com/dns/searchpaths
curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/searchpaths' \
@@ -1208,7 +1133,6 @@ curl -X POST 'https://api.tailscale.com/api/v2/tailnet/example.com/dns/searchpat
```
Response:
```
{
"searchPaths": ["user1.example.com", "user2.example.com"],

View File

@@ -24,11 +24,17 @@ func New(socket string) (*BIRDClient, error) {
return newWithTimeout(socket, responseTimeout)
}
func newWithTimeout(socket string, timeout time.Duration) (*BIRDClient, error) {
func newWithTimeout(socket string, timeout time.Duration) (_ *BIRDClient, err error) {
conn, err := net.Dial("unix", socket)
if err != nil {
return nil, fmt.Errorf("failed to connect to BIRD: %w", err)
}
defer func() {
if err != nil {
conn.Close()
}
}()
b := &BIRDClient{
socket: socket,
conn: conn,

View File

@@ -267,15 +267,47 @@ func (lc *LocalClient) Profile(ctx context.Context, pprofType string, sec int) (
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)
// BugReportOpts contains options to pass to the Tailscale daemon when
// generating a bug report.
type BugReportOpts struct {
// Note contains an optional user-provided note to add to the logs.
Note string
// Diagnose specifies whether to print additional diagnostic information to
// the logs when generating this bugreport.
Diagnose bool
}
// BugReportWithOpts logs and returns a log marker that can be shared by the
// user with support.
//
// The opts type specifies options to pass to the Tailscale daemon when
// generating this bug report.
func (lc *LocalClient) BugReportWithOpts(ctx context.Context, opts BugReportOpts) (string, error) {
var qparams url.Values
if opts.Note != "" {
qparams.Set("note", opts.Note)
}
if opts.Diagnose {
qparams.Set("diagnose", "true")
}
uri := fmt.Sprintf("/localapi/v0/bugreport?%s", qparams.Encode())
body, err := lc.send(ctx, "POST", uri, 200, nil)
if err != nil {
return "", err
}
return strings.TrimSpace(string(body)), nil
}
// BugReport logs and returns a log marker that can be shared by the user with support.
//
// This is the same as calling BugReportWithOpts and only specifying the Note
// field.
func (lc *LocalClient) BugReport(ctx context.Context, note string) (string, error) {
return lc.BugReportWithOpts(ctx, BugReportOpts{Note: note})
}
// DebugAction invokes a debug action, such as "rebind" or "restun".
// These are development tools and subject to change or removal over time.
func (lc *LocalClient) DebugAction(ctx context.Context, action string) error {

View File

@@ -51,7 +51,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/safesocket from tailscale.com/client/tailscale
tailscale.com/syncs from tailscale.com/cmd/derper+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tka from tailscale.com/client/tailscale
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces
💣 tailscale.com/tstime/mono from tailscale.com/tstime/rate
tailscale.com/tstime/rate from tailscale.com/wgengine/filter

View File

@@ -110,11 +110,12 @@ func runSpeedtest(ctx context.Context, args []string) error {
w := tabwriter.NewWriter(os.Stdout, 12, 0, 0, ' ', tabwriter.TabIndent)
fmt.Println("Results:")
fmt.Fprintln(w, "Interval\t\tTransfer\t\tBandwidth\t\t")
startTime := results[0].IntervalStart
for _, r := range results {
if r.Total {
fmt.Fprintln(w, "-------------------------------------------------------------------------")
}
fmt.Fprintf(w, "%.2f-%.2f\tsec\t%.4f\tMBits\t%.4f\tMbits/sec\t\n", r.IntervalStart.Seconds(), r.IntervalEnd.Seconds(), r.MegaBits(), r.MBitsPerSecond())
fmt.Fprintf(w, "%.2f-%.2f\tsec\t%.4f\tMBits\t%.4f\tMbits/sec\t\n", r.IntervalStart.Sub(startTime).Seconds(), r.IntervalEnd.Sub(startTime).Seconds(), r.MegaBits(), r.MBitsPerSecond())
}
w.Flush()
return nil

View File

@@ -7,8 +7,10 @@ package cli
import (
"context"
"errors"
"flag"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
)
var bugReportCmd = &ffcli.Command{
@@ -16,6 +18,15 @@ var bugReportCmd = &ffcli.Command{
Exec: runBugReport,
ShortHelp: "Print a shareable identifier to help diagnose issues",
ShortUsage: "bugreport [note]",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("bugreport")
fs.BoolVar(&bugReportArgs.diagnose, "diagnose", false, "run additional in-depth checks")
return fs
})(),
}
var bugReportArgs struct {
diagnose bool
}
func runBugReport(ctx context.Context, args []string) error {
@@ -27,7 +38,10 @@ func runBugReport(ctx context.Context, args []string) error {
default:
return errors.New("unknown argumets")
}
logMarker, err := localClient.BugReport(ctx, note)
logMarker, err := localClient.BugReportWithOpts(ctx, tailscale.BugReportOpts{
Note: note,
Diagnose: bugReportArgs.diagnose,
})
if err != nil {
return err
}

View File

@@ -48,11 +48,11 @@ func runConfigureHost(ctx context.Context, args []string) error {
if uid := os.Getuid(); uid != 0 {
return fmt.Errorf("must be run as root, not %q (%v)", os.Getenv("USER"), uid)
}
osVer := hostinfo.GetOSVersion()
isDSM6 := strings.HasPrefix(osVer, "Synology 6")
isDSM7 := strings.HasPrefix(osVer, "Synology 7")
hi := hostinfo.New()
isDSM6 := strings.HasPrefix(hi.DistroVersion, "6.")
isDSM7 := strings.HasPrefix(hi.DistroVersion, "7.")
if !isDSM6 && !isDSM7 {
return fmt.Errorf("unsupported DSM version %q", osVer)
return fmt.Errorf("unsupported DSM version %q", hi.DistroVersion)
}
if _, err := os.Stat("/dev/net/tun"); os.IsNotExist(err) {
if err := os.MkdirAll("/dev/net", 0755); err != nil {

View File

@@ -133,6 +133,9 @@ func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP)
printf("\t* HairPinning: %v\n", report.HairPinning)
printf("\t* PortMapping: %v\n", portMapping(report))
if report.CaptivePortal != "" {
printf("\t* CaptivePortal: %v\n", report.CaptivePortal)
}
// When DERP latency checking failed,
// magicsock will try to pick the DERP server that

View File

@@ -380,7 +380,6 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
// Do this after validations to avoid the 5s delay if we're going to error
// out anyway.
wantSSH, haveSSH := env.upArgs.runSSH, curPrefs.RunSSH
fmt.Println("wantSSH", wantSSH, "haveSSH", haveSSH)
if wantSSH != haveSSH && isSSHOverTailscale() {
if wantSSH {
err = presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will reroute SSH traffic to Tailscale SSH and will result in your session disconnecting.`, env.upArgs.acceptedRisks)

View File

@@ -100,6 +100,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/util/groupmember from tailscale.com/cmd/tailscale/cli
tailscale.com/util/lineread from tailscale.com/net/interfaces+
tailscale.com/util/mak from tailscale.com/net/netcheck
tailscale.com/util/multierr from tailscale.com/control/controlhttp
tailscale.com/util/singleflight from tailscale.com/net/dnscache
L tailscale.com/util/strs from tailscale.com/hostinfo
W 💣 tailscale.com/util/winutil from tailscale.com/hostinfo+

View File

@@ -109,7 +109,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/tailscale/goupnp/scpd from github.com/tailscale/goupnp
github.com/tailscale/goupnp/soap from github.com/tailscale/goupnp+
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router
L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router+
github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck
LD github.com/u-root/u-root/pkg/termios from tailscale.com/ssh/tailssh
L github.com/u-root/uio/rand from github.com/insomniacslk/dhcp/dhcpv4
@@ -190,6 +190,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/derp from tailscale.com/derp/derphttp+
tailscale.com/derp/derphttp from tailscale.com/net/netcheck+
tailscale.com/disco from tailscale.com/derp+
tailscale.com/doctor from tailscale.com/ipn/ipnlocal
tailscale.com/doctor/routetable from tailscale.com/ipn/ipnlocal
tailscale.com/envknob from tailscale.com/control/controlclient+
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/hostinfo from tailscale.com/control/controlclient+
@@ -230,6 +232,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/ping from tailscale.com/net/netcheck
tailscale.com/net/portmapper from tailscale.com/net/netcheck+
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
tailscale.com/net/routetable from tailscale.com/doctor/routetable
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled
tailscale.com/net/stun from tailscale.com/net/netcheck+
tailscale.com/net/tlsdial from tailscale.com/control/controlclient+
@@ -272,6 +275,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 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/goroutines from tailscale.com/control/controlclient+
tailscale.com/util/groupmember from tailscale.com/ipn/ipnserver
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/lineread from tailscale.com/hostinfo+

View File

@@ -7,7 +7,7 @@ After=network-pre.target NetworkManager.service systemd-resolved.service
[Service]
EnvironmentFile=/etc/default/tailscaled
ExecStartPre=/usr/sbin/tailscaled --cleanup
ExecStart=/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port $PORT $FLAGS
ExecStart=/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=${PORT} $FLAGS
ExecStopPost=/usr/sbin/tailscaled --cleanup
Restart=on-failure

View File

@@ -46,7 +46,7 @@ function SSHSession({
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current) {
runSSHSession(ref.current, def, ipn, onDone)
runSSHSession(ref.current, def, ipn, onDone, (err) => console.error(err))
}
}, [ref])

View File

@@ -5,6 +5,8 @@ import { WebLinksAddon } from "xterm-addon-web-links"
export type SSHSessionDef = {
username: string
hostname: string
/** Defaults to 5 seconds */
timeoutSeconds?: number
}
export function runSSHSession(
@@ -12,6 +14,7 @@ export function runSSHSession(
def: SSHSessionDef,
ipn: IPN,
onDone: () => void,
onError?: (err: string) => void,
terminalOptions?: ITerminalOptions
) {
const parentWindow = termContainerNode.ownerDocument.defaultView ?? window
@@ -46,7 +49,7 @@ export function runSSHSession(
term.write(input)
},
writeErrorFn(err) {
console.error(err)
onError?.(err)
term.write(err)
},
setReadFn(hook) {
@@ -62,6 +65,7 @@ export function runSSHSession(
}
onDone()
},
timeoutSeconds: def.timeoutSeconds,
})
// Make terminal and SSH session track the size of the containing DOM node.

View File

@@ -23,6 +23,8 @@ declare global {
setReadFn: (readFn: (data: string) => void) => void
rows: number
cols: number
/** Defaults to 5 seconds */
timeoutSeconds?: number
onDone: () => void
}
): IPNSSHSession

View File

@@ -360,6 +360,10 @@ func (s *jsSSHSession) Run() {
setReadFn := s.termConfig.Get("setReadFn")
rows := s.termConfig.Get("rows").Int()
cols := s.termConfig.Get("cols").Int()
timeoutSeconds := 5.0
if jsTimeoutSeconds := s.termConfig.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber {
timeoutSeconds = jsTimeoutSeconds.Float()
}
onDone := s.termConfig.Get("onDone")
defer onDone.Invoke()
@@ -367,7 +371,7 @@ func (s *jsSSHSession) Run() {
writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err))
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second)))
defer cancel()
c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22"))
if err != nil {

View File

@@ -114,19 +114,11 @@ func NewNoStart(opts Options) (*Auto, error) {
}
c.authCtx, c.authCancel = context.WithCancel(context.Background())
c.mapCtx, c.mapCancel = context.WithCancel(context.Background())
c.unregisterHealthWatch = health.RegisterWatcher(c.onHealthChange)
c.unregisterHealthWatch = health.RegisterWatcher(direct.ReportHealthChange)
return c, nil
}
func (c *Auto) onHealthChange(sys health.Subsystem, err error) {
if sys == health.SysOverall {
return
}
c.logf("controlclient: restarting map request for %q health change to new state: %v", sys, err)
c.cancelMapSafely()
}
// SetPaused controls whether HTTP activity should be paused.
//
// The client can be paused and unpaused repeatedly, unlike Start and Shutdown, which can only be used once.

View File

@@ -8,12 +8,11 @@ import (
"bytes"
"compress/gzip"
"context"
"fmt"
"log"
"net/http"
"runtime"
"strconv"
"time"
"tailscale.com/util/goroutines"
)
func dumpGoroutinesToURL(c *http.Client, targetURL string) {
@@ -22,7 +21,7 @@ func dumpGoroutinesToURL(c *http.Client, targetURL string) {
zbuf := new(bytes.Buffer)
zw := gzip.NewWriter(zbuf)
zw.Write(scrubbedGoroutineDump())
zw.Write(goroutines.ScrubbedGoroutineDump())
zw.Close()
req, err := http.NewRequestWithContext(ctx, "PUT", targetURL, zbuf)
@@ -40,83 +39,3 @@ func dumpGoroutinesToURL(c *http.Client, targetURL string) {
log.Printf("dumpGoroutinesToURL complete to %v (after %v)", targetURL, d)
}
}
// scrubbedGoroutineDump returns the list of all current goroutines, but with the actual
// values of arguments scrubbed out, lest it contain some private key material.
func scrubbedGoroutineDump() []byte {
var buf []byte
// Grab stacks multiple times into increasingly larger buffer sizes
// to minimize the risk that we blow past our iOS memory limit.
for size := 1 << 10; size <= 1<<20; size += 1 << 10 {
buf = make([]byte, size)
buf = buf[:runtime.Stack(buf, true)]
if len(buf) < size {
// It fit.
break
}
}
return scrubHex(buf)
}
func scrubHex(buf []byte) []byte {
saw := map[string][]byte{} // "0x123" => "v1%3" (unique value 1 and its value mod 8)
foreachHexAddress(buf, func(in []byte) {
if string(in) == "0x0" {
return
}
if v, ok := saw[string(in)]; ok {
for i := range in {
in[i] = '_'
}
copy(in, v)
return
}
inStr := string(in)
u64, err := strconv.ParseUint(string(in[2:]), 16, 64)
for i := range in {
in[i] = '_'
}
if err != nil {
in[0] = '?'
return
}
v := []byte(fmt.Sprintf("v%d%%%d", len(saw)+1, u64%8))
saw[inStr] = v
copy(in, v)
})
return buf
}
var ohx = []byte("0x")
// foreachHexAddress calls f with each subslice of b that matches
// regexp `0x[0-9a-f]*`.
func foreachHexAddress(b []byte, f func([]byte)) {
for len(b) > 0 {
i := bytes.Index(b, ohx)
if i == -1 {
return
}
b = b[i:]
hx := hexPrefix(b)
f(hx)
b = b[len(hx):]
}
}
func hexPrefix(b []byte) []byte {
for i, c := range b {
if i < 2 {
continue
}
if !isHexByte(c) {
return b[:i]
}
}
return b
}
func isHexByte(b byte) bool {
return '0' <= b && b <= '9' || 'a' <= b && b <= 'f' || 'A' <= b && b <= 'F'
}

View File

@@ -76,6 +76,8 @@ type Direct struct {
popBrowser func(url string) // or nil
c2nHandler http.Handler // or nil
dialPlan ControlDialPlanner // can be nil
mu sync.Mutex // mutex guards the following fields
serverKey key.MachinePublic // original ("legacy") nacl crypto_box-based public key
serverNoiseKey key.MachinePublic
@@ -106,6 +108,7 @@ type Options struct {
KeepAlive bool
Logf logger.Logf
HTTPTestClient *http.Client // optional HTTP client to use (for tests only)
NoiseTestClient *http.Client // optional HTTP client to use for noise RPCs (tests only)
DebugFlags []string // debug settings to send to control
LinkMonitor *monitor.Mon // optional link monitor
PopBrowserURL func(url string) // optional func to open browser
@@ -132,6 +135,34 @@ type Options struct {
// MapResponse.PingRequest queries from the control plane.
// If nil, PingRequest queries are not answered.
Pinger Pinger
// DialPlan contains and stores a previous dial plan that we received
// from the control server; if nil, we fall back to using DNS.
//
// If we receive a new DialPlan from the server, this value will be
// updated.
DialPlan ControlDialPlanner
}
// ControlDialPlanner is the interface optionally supplied when creating a
// control client to control exactly how TCP connections to the control plane
// are dialed.
//
// It is usually implemented by an atomic.Pointer.
type ControlDialPlanner interface {
// Load returns the current plan for how to connect to control.
//
// The returned plan can be nil. If so, connections should be made by
// resolving the control URL using DNS.
Load() *tailcfg.ControlDialPlan
// Store updates the dial plan with new directions from the control
// server.
//
// The dial plan can span multiple connections to the control server.
// That is, a dial plan received when connected over Wi-Fi is still
// valid for a subsequent connection over LTE after a network switch.
Store(*tailcfg.ControlDialPlan)
}
// Pinger is the LocalBackend.Ping method.
@@ -215,6 +246,7 @@ func NewDirect(opts Options) (*Direct, error) {
popBrowser: opts.PopBrowserURL,
c2nHandler: opts.C2NHandler,
dialer: opts.Dialer,
dialPlan: opts.DialPlan,
}
if opts.Hostinfo == nil {
c.SetHostinfo(hostinfo.New())
@@ -226,6 +258,12 @@ func NewDirect(opts Options) (*Direct, error) {
c.SetNetInfo(ni)
}
}
if opts.NoiseTestClient != nil {
c.noiseClient = &noiseClient{
Client: opts.NoiseTestClient,
}
c.serverNoiseKey = key.NewMachine().Public() // prevent early error before hitting test client
}
return c, nil
}
@@ -908,6 +946,14 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
} else {
vlogf("netmap: got new map")
}
if resp.ControlDialPlan != nil {
if c.dialPlan != nil {
c.logf("netmap: got new dial plan from control")
c.dialPlan.Store(resp.ControlDialPlan)
} else {
c.logf("netmap: [unexpected] new dial plan; nowhere to store it")
}
}
select {
case timeoutReset <- struct{}{}:
@@ -1358,12 +1404,17 @@ func (c *Direct) getNoiseClient() (*noiseClient, error) {
if nc != nil {
return nc, nil
}
var dp func() *tailcfg.ControlDialPlan
if c.dialPlan != nil {
dp = c.dialPlan.Load
}
nc, err, _ := c.sfGroup.Do(struct{}{}, func() (*noiseClient, error) {
k, err := c.getMachinePrivKey()
if err != nil {
return nil, err
}
nc, err := newNoiseClient(k, serverNoiseKey, c.serverURL, c.dialer)
c.logf("creating new noise client")
nc, err := newNoiseClient(k, serverNoiseKey, c.serverURL, c.dialer, dp)
if err != nil {
return nil, err
}
@@ -1383,15 +1434,11 @@ func (c *Direct) getNoiseClient() (*noiseClient, error) {
func (c *Direct) setDNSNoise(ctx context.Context, req *tailcfg.SetDNSRequest) error {
newReq := *req
newReq.Version = tailcfg.CurrentCapabilityVersion
np, err := c.getNoiseClient()
nc, err := c.getNoiseClient()
if err != nil {
return err
}
bodyData, err := json.Marshal(newReq)
if err != nil {
return err
}
res, err := np.Post(fmt.Sprintf("https://%v/%v", np.host, "machine/set-dns"), "application/json", bytes.NewReader(bodyData))
res, err := nc.post(ctx, "/machine/set-dns", &newReq)
if err != nil {
return err
}
@@ -1539,6 +1586,38 @@ func postPingResult(start time.Time, logf logger.Logf, c *http.Client, pr *tailc
return nil
}
// ReportHealthChange reports to the control plane a change to this node's
// health.
func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) {
if sys == health.SysOverall {
// We don't report these. These include things like the network is down
// (in which case we can't report anyway) or the user wanted things
// stopped, as opposed to the more unexpected failure types in the other
// subsystems.
return
}
np, err := c.getNoiseClient()
if err != nil {
// Don't report errors to control if the server doesn't support noise.
return
}
req := &tailcfg.HealthChangeRequest{
Subsys: string(sys),
}
if sysErr != nil {
req.Error = sysErr.Error()
}
// Best effort, no logging:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := np.post(ctx, "/machine/update-health", req)
if err != nil {
return
}
res.Body.Close()
}
var (
metricMapRequestsActive = clientmetric.NewGauge("controlclient_map_requests_active")

View File

@@ -48,6 +48,7 @@ type mapSession struct {
lastHealth []string
lastPopBrowserURL string
stickyDebug tailcfg.Debug // accumulated opt.Bool values
lastTKAInfo *tailcfg.TKAInfo
// netMapBuilding is non-nil during a netmapForResponse call,
// containing the value to be returned, once fully populated.
@@ -115,6 +116,9 @@ func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.Netwo
if resp.Health != nil {
ms.lastHealth = resp.Health
}
if resp.TKAInfo != nil {
ms.lastTKAInfo = resp.TKAInfo
}
debug := resp.Debug
if debug != nil {
@@ -152,9 +156,17 @@ func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.Netwo
DERPMap: ms.lastDERPMap,
Debug: debug,
ControlHealth: ms.lastHealth,
TKAEnabled: ms.lastTKAInfo != nil && !ms.lastTKAInfo.Disabled,
}
ms.netMapBuilding = nm
if ms.lastTKAInfo != nil && ms.lastTKAInfo.Head != "" {
if err := nm.TKAHead.UnmarshalText([]byte(ms.lastTKAInfo.Head)); err != nil {
ms.logf("error unmarshalling TKAHead: %v", err)
nm.TKAEnabled = false
}
}
if resp.Node != nil {
ms.lastNode = resp.Node
}

View File

@@ -5,8 +5,10 @@
package controlclient
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"math"
"net"
"net/http"
@@ -53,6 +55,11 @@ type noiseClient struct {
httpPort string // the default port to call
httpsPort string // the fallback Noise-over-https port
// dialPlan optionally returns a ControlDialPlan previously received
// from the control server; either the function or the return value can
// be nil.
dialPlan func() *tailcfg.ControlDialPlan
// mu only protects the following variables.
mu sync.Mutex
nextID int
@@ -61,7 +68,9 @@ 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) {
//
// dialPlan may be nil
func newNoiseClient(priKey key.MachinePrivate, serverPubKey key.MachinePublic, serverURL string, dialer *tsdial.Dialer, dialPlan func() *tailcfg.ControlDialPlan) (*noiseClient, error) {
u, err := url.Parse(serverURL)
if err != nil {
return nil, err
@@ -89,6 +98,7 @@ func newNoiseClient(priKey key.MachinePrivate, serverPubKey key.MachinePublic, s
httpPort: httpPort,
httpsPort: httpsPort,
dialer: dialer,
dialPlan: dialPlan,
}
// Create the HTTP/2 Transport using a net/http.Transport
@@ -155,16 +165,51 @@ func (nc *noiseClient) dial(_, _ string, _ *tls.Config) (net.Conn, error) {
nc.nextID++
nc.mu.Unlock()
// Timeout is a little arbitrary, but plenty long enough for even the
// highest latency links.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if tailcfg.CurrentCapabilityVersion > math.MaxUint16 {
// Panic, because a test should have started failing several
// thousand version numbers before getting to this point.
panic("capability version is too high to fit in the wire protocol")
}
var dialPlan *tailcfg.ControlDialPlan
if nc.dialPlan != nil {
dialPlan = nc.dialPlan()
}
// If we have a dial plan, then set our timeout as slightly longer than
// the maximum amount of time contained therein; we assume that
// explicit instructions on timeouts are more useful than a single
// hard-coded timeout.
//
// The default value of 5 is chosen so that, when there's no dial plan,
// we retain the previous behaviour of 10 seconds end-to-end timeout.
timeoutSec := 5.0
if dialPlan != nil {
for _, c := range dialPlan.Candidates {
if v := c.DialStartDelaySec + c.DialTimeoutSec; v > timeoutSec {
timeoutSec = v
}
}
}
// After we establish a connection, we need some time to actually
// upgrade it into a Noise connection. With a ballpark worst-case RTT
// of 1000ms, give ourselves an extra 5 seconds to complete the
// handshake.
timeoutSec += 5
// Be extremely defensive and ensure that the timeout is in the range
// [5, 60] seconds (e.g. if we accidentally get a negative number).
if timeoutSec > 60 {
timeoutSec = 60
} else if timeoutSec < 5 {
timeoutSec = 5
}
timeout := time.Duration(timeoutSec * float64(time.Second))
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
conn, err := (&controlhttp.Dialer{
Hostname: nc.host,
HTTPPort: nc.httpPort,
@@ -173,6 +218,7 @@ func (nc *noiseClient) dial(_, _ string, _ *tls.Config) (net.Conn, error) {
ControlKey: nc.serverPubKey,
ProtocolVersion: uint16(tailcfg.CurrentCapabilityVersion),
Dialer: nc.dialer.SystemDial,
DialPlan: dialPlan,
}).Dial(ctx)
if err != nil {
return nil, err
@@ -184,3 +230,16 @@ func (nc *noiseClient) dial(_, _ string, _ *tls.Config) (net.Conn, error) {
mak.Set(&nc.connPool, ncc.id, ncc)
return ncc, nil
}
func (nc *noiseClient) post(ctx context.Context, path string, body any) (*http.Response, error) {
jbody, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+nc.host+path, bytes.NewReader(jbody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return nc.Do(req)
}

View File

@@ -28,18 +28,25 @@ import (
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/http/httptrace"
"net/netip"
"net/url"
"sort"
"sync/atomic"
"time"
"tailscale.com/control/controlbase"
"tailscale.com/envknob"
"tailscale.com/net/dnscache"
"tailscale.com/net/dnsfallback"
"tailscale.com/net/netutil"
"tailscale.com/net/tlsdial"
"tailscale.com/net/tshttpproxy"
"tailscale.com/tailcfg"
"tailscale.com/util/multierr"
)
var stdDialer net.Dialer
@@ -82,7 +89,170 @@ func (a *Dialer) httpsFallbackDelay() time.Duration {
return 500 * time.Millisecond
}
var _ = envknob.RegisterBool("TS_USE_CONTROL_DIAL_PLAN") // to record at init time whether it's in use
func (a *Dialer) dial(ctx context.Context) (*controlbase.Conn, error) {
// If we don't have a dial plan, just fall back to dialing the single
// host we know about.
useDialPlan := envknob.BoolDefaultTrue("TS_USE_CONTROL_DIAL_PLAN")
if !useDialPlan || a.DialPlan == nil || len(a.DialPlan.Candidates) == 0 {
return a.dialHost(ctx, netip.Addr{})
}
candidates := a.DialPlan.Candidates
// Otherwise, we try dialing per the plan. Store the highest priority
// in the list, so that if we get a connection to one of those
// candidates we can return quickly.
var highestPriority int = math.MinInt
for _, c := range candidates {
if c.Priority > highestPriority {
highestPriority = c.Priority
}
}
// This context allows us to cancel in-flight connections if we get a
// highest-priority connection before we're all done.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Now, for each candidate, kick off a dial in parallel.
type dialResult struct {
conn *controlbase.Conn
err error
addr netip.Addr
priority int
}
resultsCh := make(chan dialResult, len(candidates))
var pending atomic.Int32
pending.Store(int32(len(candidates)))
for _, c := range candidates {
go func(ctx context.Context, c tailcfg.ControlIPCandidate) {
var (
conn *controlbase.Conn
err error
)
// Always send results back to our channel.
defer func() {
resultsCh <- dialResult{conn, err, c.IP, c.Priority}
if pending.Add(-1) == 0 {
close(resultsCh)
}
}()
// If non-zero, wait the configured start timeout
// before we do anything.
if c.DialStartDelaySec > 0 {
a.logf("[v2] controlhttp: waiting %.2f seconds before dialing %q @ %v", c.DialStartDelaySec, a.Hostname, c.IP)
tmr := time.NewTimer(time.Duration(c.DialStartDelaySec * float64(time.Second)))
defer tmr.Stop()
select {
case <-ctx.Done():
err = ctx.Err()
return
case <-tmr.C:
}
}
// Now, create a sub-context with the given timeout and
// try dialing the provided host.
ctx, cancel := context.WithTimeout(ctx, time.Duration(c.DialTimeoutSec*float64(time.Second)))
defer cancel()
// This will dial, and the defer above sends it back to our parent.
a.logf("[v2] controlhttp: trying to dial %q @ %v", a.Hostname, c.IP)
conn, err = a.dialHost(ctx, c.IP)
}(ctx, c)
}
var results []dialResult
for res := range resultsCh {
// If we get a response that has the highest priority, we don't
// need to wait for any of the other connections to finish; we
// can just return this connection.
//
// TODO(andrew): we could make this better by keeping track of
// the highest remaining priority dynamically, instead of just
// checking for the highest total
if res.priority == highestPriority && res.conn != nil {
a.logf("[v1] controlhttp: high-priority success dialing %q @ %v from dial plan", a.Hostname, res.addr)
// Drain the channel and any existing connections in
// the background.
go func() {
for _, res := range results {
if res.conn != nil {
res.conn.Close()
}
}
for res := range resultsCh {
if res.conn != nil {
res.conn.Close()
}
}
if a.drainFinished != nil {
close(a.drainFinished)
}
}()
return res.conn, nil
}
// This isn't a highest-priority result, so just store it until
// we're done.
results = append(results, res)
}
// After we finish this function, close any remaining open connections.
defer func() {
for _, result := range results {
// Note: below, we nil out the returned connection (if
// any) in the slice so we don't close it.
if result.conn != nil {
result.conn.Close()
}
}
// We don't drain asynchronously after this point, so notify our
// channel when we return.
if a.drainFinished != nil {
close(a.drainFinished)
}
}()
// Sort by priority, then take the first non-error response.
sort.Slice(results, func(i, j int) bool {
// NOTE: intentionally inverted so that the highest priority
// item comes first
return results[i].priority > results[j].priority
})
var (
conn *controlbase.Conn
errs []error
)
for i, result := range results {
if result.err != nil {
errs = append(errs, result.err)
continue
}
a.logf("[v1] controlhttp: succeeded dialing %q @ %v from dial plan", a.Hostname, result.addr)
conn = result.conn
results[i].conn = nil // so we don't close it in the defer
return conn, nil
}
merr := multierr.New(errs...)
// If we get here, then we didn't get anywhere with our dial plan; fall back to just using DNS.
a.logf("controlhttp: failed dialing using DialPlan, falling back to DNS; errs=%s", merr.Error())
return a.dialHost(ctx, netip.Addr{})
}
// dialHost connects to the configured Dialer.Hostname and upgrades the
// connection into a controlbase.Conn. If addr is valid, then no DNS is used
// and the connection will be made to the provided address.
func (a *Dialer) dialHost(ctx context.Context, addr netip.Addr) (*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.
@@ -110,7 +280,7 @@ func (a *Dialer) dial(ctx context.Context) (*controlbase.Conn, error) {
}
ch := make(chan tryURLRes) // must be unbuffered
try := func(u *url.URL) {
cbConn, err := a.dialURL(ctx, u)
cbConn, err := a.dialURL(ctx, u, addr)
select {
case ch <- tryURLRes{u, cbConn, err}:
case <-ctx.Done():
@@ -161,12 +331,12 @@ func (a *Dialer) dial(ctx context.Context) (*controlbase.Conn, error) {
}
// dialURL attempts to connect to the given URL.
func (a *Dialer) dialURL(ctx context.Context, u *url.URL) (*controlbase.Conn, error) {
func (a *Dialer) dialURL(ctx context.Context, u *url.URL, addr netip.Addr) (*controlbase.Conn, error) {
init, cont, err := controlbase.ClientDeferred(a.MachineKey, a.ControlKey, a.ProtocolVersion)
if err != nil {
return nil, err
}
netConn, err := a.tryURLUpgrade(ctx, u, init)
netConn, err := a.tryURLUpgrade(ctx, u, addr, init)
if err != nil {
return nil, err
}
@@ -178,14 +348,27 @@ func (a *Dialer) dialURL(ctx context.Context, u *url.URL) (*controlbase.Conn, er
return cbConn, nil
}
// tryURLUpgrade connects to u, and tries to upgrade it to a net.Conn.
// tryURLUpgrade connects to u, and tries to upgrade it to a net.Conn. If addr
// is valid, then no DNS is used and the connection will be made to the
// provided address.
//
// Only the provided ctx is used, not a.ctx.
func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, init []byte) (net.Conn, error) {
dns := &dnscache.Resolver{
Forward: dnscache.Get().Forward,
LookupIPFallback: dnsfallback.Lookup,
UseLastGood: true,
func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr, init []byte) (net.Conn, error) {
var dns *dnscache.Resolver
// If we were provided an address to dial, then create a resolver that just
// returns that value; otherwise, fall back to DNS.
if addr.IsValid() {
dns = &dnscache.Resolver{
SingleHostStaticResult: []netip.Addr{addr},
SingleHost: u.Hostname(),
}
} else {
dns = &dnscache.Resolver{
Forward: dnscache.Get().Forward,
LookupIPFallback: dnsfallback.Lookup,
UseLastGood: true,
}
}
var dialer dnscache.DialContextFunc

View File

@@ -29,9 +29,11 @@ func (d *Dialer) Dial(ctx context.Context) (*controlbase.Conn, error) {
wsScheme := "wss"
host := d.Hostname
if host == "localhost" {
// If using a custom control server (on a non-standard port), prefer that.
// This mirrors the port selection in newNoiseClient from noise.go.
if d.HTTPPort != "" && d.HTTPPort != "80" && d.HTTPSPort == "443" {
wsScheme = "ws"
host = net.JoinHostPort(host, strDef(d.HTTPPort, "80"))
host = net.JoinHostPort(host, d.HTTPPort)
}
wsURL := &url.URL{
Scheme: wsScheme,

View File

@@ -10,6 +10,7 @@ import (
"time"
"tailscale.com/net/dnscache"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
@@ -70,9 +71,15 @@ type Dialer struct {
// dropped.
Logf logger.Logf
// DialPlan, if set, contains instructions from the control server on
// how to connect to it. If present, we will try the methods in this
// plan before falling back to DNS.
DialPlan *tailcfg.ControlDialPlan
proxyFunc func(*http.Request) (*url.URL, error) // or nil
// For tests only
drainFinished chan struct{}
insecureTLS bool
testFallbackDelay time.Duration
}

View File

@@ -13,16 +13,21 @@ import (
"net"
"net/http"
"net/http/httputil"
"net/netip"
"net/url"
"runtime"
"strconv"
"sync"
"testing"
"time"
"tailscale.com/control/controlbase"
"tailscale.com/net/dnscache"
"tailscale.com/net/socks5"
"tailscale.com/net/tsdial"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"tailscale.com/types/logger"
)
type httpTestParam struct {
@@ -444,3 +449,263 @@ func brokenMITMHandler(w http.ResponseWriter, r *http.Request) {
w.(http.Flusher).Flush()
<-r.Context().Done()
}
func TestDialPlan(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("only works on Linux due to multiple localhost addresses")
}
client, server := key.NewMachine(), key.NewMachine()
const (
testProtocolVersion = 1
// We need consistent ports for each address; these are chosen
// randomly and we hope that they won't conflict during this test.
httpPort = "40080"
httpsPort = "40443"
)
makeHandler := func(t *testing.T, name string, host netip.Addr, wrap func(http.Handler) http.Handler) {
done := make(chan struct{})
t.Cleanup(func() {
close(done)
})
var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := AcceptHTTP(context.Background(), w, r, server)
if err != nil {
log.Print(err)
} else {
defer conn.Close()
}
w.Header().Set("X-Handler-Name", name)
<-done
})
if wrap != nil {
handler = wrap(handler)
}
httpLn, err := net.Listen("tcp", host.String()+":"+httpPort)
if err != nil {
t.Fatalf("HTTP listen: %v", err)
}
httpsLn, err := net.Listen("tcp", host.String()+":"+httpsPort)
if err != nil {
t.Fatalf("HTTPS listen: %v", err)
}
httpServer := &http.Server{Handler: handler}
go httpServer.Serve(httpLn)
t.Cleanup(func() {
httpServer.Close()
})
httpsServer := &http.Server{
Handler: handler,
TLSConfig: tlsConfig(t),
ErrorLog: logger.StdLogger(logger.WithPrefix(t.Logf, "http.Server.ErrorLog: ")),
}
go httpsServer.ServeTLS(httpsLn, "", "")
t.Cleanup(func() {
httpsServer.Close()
})
return
}
fallbackAddr := netip.MustParseAddr("127.0.0.1")
goodAddr := netip.MustParseAddr("127.0.0.2")
otherAddr := netip.MustParseAddr("127.0.0.3")
other2Addr := netip.MustParseAddr("127.0.0.4")
brokenAddr := netip.MustParseAddr("127.0.0.10")
testCases := []struct {
name string
plan *tailcfg.ControlDialPlan
wrap func(http.Handler) http.Handler
want netip.Addr
allowFallback bool
}{
{
name: "single",
plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{
{IP: goodAddr, Priority: 1, DialTimeoutSec: 10},
}},
want: goodAddr,
},
{
name: "broken-then-good",
plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{
// Dials the broken one, which fails, and then
// eventually dials the good one and succeeds
{IP: brokenAddr, Priority: 2, DialTimeoutSec: 10},
{IP: goodAddr, Priority: 1, DialTimeoutSec: 10, DialStartDelaySec: 1},
}},
want: goodAddr,
},
{
name: "multiple-priority-fast-path",
plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{
// Dials some good IPs and our bad one (which
// hangs forever), which then hits the fast
// path where we bail without waiting.
{IP: brokenAddr, Priority: 1, DialTimeoutSec: 10},
{IP: goodAddr, Priority: 1, DialTimeoutSec: 10},
{IP: other2Addr, Priority: 1, DialTimeoutSec: 10},
{IP: otherAddr, Priority: 2, DialTimeoutSec: 10},
}},
want: otherAddr,
},
{
name: "multiple-priority-slow-path",
plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{
// Our broken address is the highest priority,
// so we don't hit our fast path.
{IP: brokenAddr, Priority: 10, DialTimeoutSec: 10},
{IP: otherAddr, Priority: 2, DialTimeoutSec: 10},
{IP: goodAddr, Priority: 1, DialTimeoutSec: 10},
}},
want: otherAddr,
},
{
name: "fallback",
plan: &tailcfg.ControlDialPlan{Candidates: []tailcfg.ControlIPCandidate{
{IP: brokenAddr, Priority: 1, DialTimeoutSec: 1},
}},
want: fallbackAddr,
allowFallback: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
makeHandler(t, "fallback", fallbackAddr, nil)
makeHandler(t, "good", goodAddr, nil)
makeHandler(t, "other", otherAddr, nil)
makeHandler(t, "other2", other2Addr, nil)
makeHandler(t, "broken", brokenAddr, func(h http.Handler) http.Handler {
return http.HandlerFunc(brokenMITMHandler)
})
dialer := closeTrackDialer{
t: t,
inner: new(tsdial.Dialer).SystemDial,
conns: make(map[*closeTrackConn]bool),
}
defer dialer.Done()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// By default, we intentionally point to something that
// we know won't connect, since we want a fallback to
// DNS to be an error.
host := "example.com"
if tt.allowFallback {
host = "localhost"
}
drained := make(chan struct{})
a := &Dialer{
Hostname: host,
HTTPPort: httpPort,
HTTPSPort: httpsPort,
MachineKey: client,
ControlKey: server.Public(),
ProtocolVersion: testProtocolVersion,
Dialer: dialer.Dial,
Logf: t.Logf,
DialPlan: tt.plan,
proxyFunc: func(*http.Request) (*url.URL, error) { return nil, nil },
drainFinished: drained,
insecureTLS: true,
testFallbackDelay: 50 * time.Millisecond,
}
conn, err := a.dial(ctx)
if err != nil {
t.Fatalf("dialing controlhttp: %v", err)
}
defer conn.Close()
raddr := conn.RemoteAddr().(*net.TCPAddr)
got, ok := netip.AddrFromSlice(raddr.IP)
if !ok {
t.Errorf("invalid remote IP: %v", raddr.IP)
} else if got != tt.want {
t.Errorf("got connection from %q; want %q", got, tt.want)
} else {
t.Logf("successfully connected to %q", raddr.String())
}
// Wait until our dialer drains so we can verify that
// all connections are closed.
<-drained
})
}
}
type closeTrackDialer struct {
t testing.TB
inner dnscache.DialContextFunc
mu sync.Mutex
conns map[*closeTrackConn]bool
}
func (d *closeTrackDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
c, err := d.inner(ctx, network, addr)
if err != nil {
return nil, err
}
ct := &closeTrackConn{Conn: c, d: d}
d.mu.Lock()
d.conns[ct] = true
d.mu.Unlock()
return ct, nil
}
func (d *closeTrackDialer) Done() {
// Unfortunately, tsdial.Dialer.SystemDial closes connections
// asynchronously in a goroutine, so we can't assume that everything is
// closed by the time we get here.
//
// Sleep/wait a few times on the assumption that things will close
// "eventually".
const iters = 100
for i := 0; i < iters; i++ {
d.mu.Lock()
if len(d.conns) == 0 {
d.mu.Unlock()
return
}
// Only error on last iteration
if i != iters-1 {
d.mu.Unlock()
time.Sleep(100 * time.Millisecond)
continue
}
for conn := range d.conns {
d.t.Errorf("expected close of conn %p; RemoteAddr=%q", conn, conn.RemoteAddr().String())
}
d.mu.Unlock()
}
}
func (d *closeTrackDialer) noteClose(c *closeTrackConn) {
d.mu.Lock()
delete(d.conns, c) // safe if already deleted
d.mu.Unlock()
}
type closeTrackConn struct {
net.Conn
d *closeTrackDialer
}
func (c *closeTrackConn) Close() error {
c.d.noteClose(c)
return c.Conn.Close()
}

View File

@@ -37,7 +37,7 @@ spec:
valueFrom:
secretKeyRef:
name: tailscale-auth
key: AUTH_KEY
key: TS_AUTH_KEY
optional: true
- name: TS_DEST_IP
value: "{{TS_DEST_IP}}"

View File

@@ -17,10 +17,11 @@ TS_KUBE_SECRET="${TS_KUBE_SECRET:-tailscale}"
TS_SOCKS5_SERVER="${TS_SOCKS5_SERVER:-}"
TS_OUTBOUND_HTTP_PROXY_LISTEN="${TS_OUTBOUND_HTTP_PROXY_LISTEN:-}"
TS_TAILSCALED_EXTRA_ARGS="${TS_TAILSCALED_EXTRA_ARGS:-}"
TS_SOCKET="${TS_SOCKET:-/tmp/tailscaled.sock}"
set -e
TAILSCALED_ARGS="--socket=/tmp/tailscaled.sock"
TAILSCALED_ARGS="--socket=${TS_SOCKET}"
if [[ ! -z "${KUBERNETES_SERVICE_HOST}" ]]; then
TAILSCALED_ARGS="${TAILSCALED_ARGS} --state=kube:${TS_KUBE_SECRET} --statedir=${TS_STATE_DIR:-/tmp}"
@@ -81,11 +82,11 @@ if [[ ! -z "${TS_EXTRA_ARGS}" ]]; then
fi
echo "Running tailscale up"
tailscale --socket=/tmp/tailscaled.sock up ${UP_ARGS}
tailscale --socket="${TS_SOCKET}" up ${UP_ARGS}
if [[ ! -z "${TS_DEST_IP}" ]]; then
echo "Adding iptables rule for DNAT"
iptables -t nat -I PREROUTING -d "$(tailscale --socket=/tmp/tailscaled.sock ip -4)" -j DNAT --to-destination "${TS_DEST_IP}"
iptables -t nat -I PREROUTING -d "$(tailscale --socket=${TS_SOCKET} ip -4)" -j DNAT --to-destination "${TS_DEST_IP}"
fi
echo "Waiting for tailscaled to exit"

View File

@@ -23,7 +23,7 @@ spec:
valueFrom:
secretKeyRef:
name: tailscale-auth
key: AUTH_KEY
key: TS_AUTH_KEY
optional: true
- name: TS_ROUTES
value: "{{TS_ROUTES}}"

View File

@@ -26,5 +26,5 @@ spec:
valueFrom:
secretKeyRef:
name: tailscale-auth
key: AUTH_KEY
key: TS_AUTH_KEY
optional: true

80
doctor/doctor.go Normal file
View File

@@ -0,0 +1,80 @@
// 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 doctor contains more in-depth healthchecks that can be run to aid in
// diagnosing Tailscale issues.
package doctor
import (
"context"
"sync"
"tailscale.com/types/logger"
)
// Check is the interface defining a singular check.
//
// A check should log information that it gathers using the provided log
// function, and should attempt to make as much progress as possible in error
// conditions.
type Check interface {
// Name should return a name describing this check, in lower-kebab-case
// (i.e. "my-check", not "MyCheck" or "my_check").
Name() string
// Run executes the check, logging diagnostic information to the
// provided logger function.
Run(context.Context, logger.Logf) error
}
// RunChecks runs a list of checks in parallel, and logs any returned errors
// after all checks have returned.
func RunChecks(ctx context.Context, log logger.Logf, checks ...Check) {
if len(checks) == 0 {
return
}
type namedErr struct {
name string
err error
}
errs := make(chan namedErr, len(checks))
var wg sync.WaitGroup
wg.Add(len(checks))
for _, check := range checks {
go func(c Check) {
defer wg.Done()
plog := logger.WithPrefix(log, c.Name()+": ")
errs <- namedErr{
name: c.Name(),
err: c.Run(ctx, plog),
}
}(check)
}
wg.Wait()
close(errs)
for n := range errs {
if n.err == nil {
continue
}
log("check %s: %v", n.name, n.err)
}
}
// CheckFunc creates a Check from a name and a function.
func CheckFunc(name string, run func(context.Context, logger.Logf) error) Check {
return checkFunc{name, run}
}
type checkFunc struct {
name string
run func(context.Context, logger.Logf) error
}
func (c checkFunc) Name() string { return c.name }
func (c checkFunc) Run(ctx context.Context, log logger.Logf) error { return c.run(ctx, log) }

50
doctor/doctor_test.go Normal file
View File

@@ -0,0 +1,50 @@
// 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 doctor
import (
"context"
"fmt"
"sync"
"testing"
qt "github.com/frankban/quicktest"
"tailscale.com/types/logger"
)
func TestRunChecks(t *testing.T) {
c := qt.New(t)
var (
mu sync.Mutex
lines []string
)
logf := func(format string, args ...any) {
mu.Lock()
defer mu.Unlock()
lines = append(lines, fmt.Sprintf(format, args...))
}
ctx := context.Background()
RunChecks(ctx, logf,
testCheck1{},
CheckFunc("testcheck2", func(_ context.Context, log logger.Logf) error {
log("check 2")
return nil
}),
)
mu.Lock()
defer mu.Unlock()
c.Assert(lines, qt.Contains, "testcheck1: check 1")
c.Assert(lines, qt.Contains, "testcheck2: check 2")
}
type testCheck1 struct{}
func (t testCheck1) Name() string { return "testcheck1" }
func (t testCheck1) Run(_ context.Context, log logger.Logf) error {
log("check 1")
return nil
}

View File

@@ -0,0 +1,35 @@
// 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 routetable provides a doctor.Check that dumps the current system's
// route table to the log.
package routetable
import (
"context"
"tailscale.com/net/routetable"
"tailscale.com/types/logger"
)
// MaxRoutes is the maximum number of routes that will be displayed.
const MaxRoutes = 1000
// Check implements the doctor.Check interface.
type Check struct{}
func (Check) Name() string {
return "routetable"
}
func (Check) Run(_ context.Context, logf logger.Logf) error {
rs, err := routetable.Get(MaxRoutes)
if err != nil {
return err
}
for _, r := range rs {
logf("%s", r)
}
return nil
}

View File

@@ -10,14 +10,28 @@ import (
"net/http"
"tailscale.com/tailcfg"
"tailscale.com/util/clientmetric"
"tailscale.com/util/goroutines"
)
func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
writeJSON := func(v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
switch r.URL.Path {
case "/echo":
// Test handler.
body, _ := io.ReadAll(r.Body)
w.Write(body)
case "/debug/goroutines":
w.Header().Set("Content-Type", "text/plain")
w.Write(goroutines.ScrubbedGoroutineDump())
case "/debug/prefs":
writeJSON(b.Prefs())
case "/debug/metrics":
w.Header().Set("Content-Type", "text/plain")
clientmetric.WritePrometheusExpositionFormat(w)
case "/ssh/usernames":
var req tailcfg.C2NSSHUsernamesRequest
if r.Method == "POST" {
@@ -31,8 +45,7 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
writeJSON(res)
default:
http.Error(w, "unknown c2n path", http.StatusBadRequest)
}

View File

@@ -26,6 +26,8 @@ import (
"go4.org/netipx"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/control/controlclient"
"tailscale.com/doctor"
"tailscale.com/doctor/routetable"
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/hostinfo"
@@ -189,6 +191,10 @@ type LocalBackend struct {
// statusChanged.Broadcast().
statusLock sync.Mutex
statusChanged *sync.Cond
// dialPlan is any dial plan that we've received from the control
// server during a previous connection; it is cleared on logout.
dialPlan atomic.Pointer[tailcfg.ControlDialPlan]
}
// clientGen is a func that creates a control plane client.
@@ -684,6 +690,9 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) {
}
}
if st.NetMap != nil {
if err := b.tkaSyncIfNeededLocked(st.NetMap); err != nil {
b.logf("[v1] TKA sync error: %v", err)
}
if b.findExitNodeIDLocked(st.NetMap) {
prefsChanged = true
}
@@ -1084,6 +1093,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
Dialer: b.Dialer(),
Status: b.setClientStatus,
C2NHandler: http.HandlerFunc(b.handleC2N),
DialPlan: &b.dialPlan, // pointer because it can't be copied
// Don't warn about broken Linux IP forwarding when
// netstack is being used.
@@ -3109,6 +3119,9 @@ func (b *LocalBackend) logout(ctx context.Context, sync bool) error {
Prefs: ipn.Prefs{WantRunning: false, LoggedOut: true},
})
// Clear any previous dial plan(s), if set.
b.dialPlan.Store(nil)
if cc == nil {
// Double Logout can happen via repeated IPN
// connections to ipnserver making it repeatedly
@@ -3225,6 +3238,17 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
}
}
// operatorUserName returns the current pref's OperatorUser's name, or the
// empty string if none.
func (b *LocalBackend) operatorUserName() string {
b.mu.Lock()
defer b.mu.Unlock()
if b.prefs == nil {
return ""
}
return b.prefs.OperatorUser
}
// OperatorUserID returns the current pref's OperatorUser's ID (in
// os/user.User.Uid string form), or the empty string if none.
func (b *LocalBackend) OperatorUserID() string {
@@ -3593,6 +3617,17 @@ func (b *LocalBackend) DoNoiseRequest(req *http.Request) (*http.Response, error)
return cc.DoNoiseRequest(req)
}
// tailscaleSSHEnabled reports whether Tailscale SSH is currently enabled based
// on prefs. It returns false if there are no prefs set.
func (b *LocalBackend) tailscaleSSHEnabled() bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.prefs == nil {
return false
}
return b.prefs.RunSSH
}
func (b *LocalBackend) sshServerOrInit() (_ SSHServer, err error) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -3651,3 +3686,19 @@ func (b *LocalBackend) handleQuad100Port80Conn(w http.ResponseWriter, r *http.Re
}
io.WriteString(w, "</ul>\n")
}
func (b *LocalBackend) Doctor(ctx context.Context, logf logger.Logf) {
var checks []doctor.Check
checks = append(checks, routetable.Check{})
// TODO(andrew): more
numChecks := len(checks)
checks = append(checks, doctor.CheckFunc("numchecks", func(_ context.Context, log logger.Logf) error {
log("%d checks", numChecks)
return nil
}))
doctor.RunChecks(ctx, logf, checks...)
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
@@ -12,6 +12,8 @@ import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"tailscale.com/envknob"
@@ -31,6 +33,116 @@ type tkaState struct {
storage *tka.FS
}
// tkaSyncIfNeededLocked examines TKA info reported from the control plane,
// performing the steps necessary to synchronize local tka state.
//
// There are 4 scenarios handled here:
// - Enablement: nm.TKAEnabled but b.tka == nil
// ∴ reach out to /machine/tka/boostrap to get the genesis AUM, then
// initialize TKA.
// - Disablement: !nm.TKAEnabled but b.tka != nil
// ∴ reach out to /machine/tka/boostrap to read the disablement secret,
// then verify and clear tka local state.
// - Sync needed: b.tka.Head != nm.TKAHead
// ∴ complete multi-step synchronization flow.
// - Everything up to date: All other cases.
// ∴ no action necessary.
//
// b.mu must be held. b.mu will be stepped out of (and back in) during network
// RPCs.
func (b *LocalBackend) tkaSyncIfNeededLocked(nm *netmap.NetworkMap) error {
if !networkLockAvailable() {
// If the feature flag is not enabled, pretend we don't exist.
return nil
}
isEnabled := b.tka != nil
wantEnabled := nm.TKAEnabled
if isEnabled != wantEnabled {
var ourHead tka.AUMHash
if b.tka != nil {
ourHead = b.tka.authority.Head()
}
// Regardless of whether we are moving to disabled or enabled, we
// need information from the tka bootstrap endpoint.
ourNodeKey := b.prefs.Persist.PrivateNodeKey.Public()
b.mu.Unlock()
bs, err := b.tkaFetchBootstrap(ourNodeKey, ourHead)
b.mu.Lock()
if err != nil {
return fmt.Errorf("fetching bootstrap: %v", err)
}
if wantEnabled && !isEnabled {
if err := b.tkaBootstrapFromGenesisLocked(bs.GenesisAUM); err != nil {
return fmt.Errorf("bootstrap: %v", err)
}
isEnabled = true
} else if !wantEnabled && isEnabled {
if b.tka.authority.ValidDisablement(bs.DisablementSecret) {
b.tka = nil
isEnabled = false
if err := os.RemoveAll(b.chonkPath()); err != nil {
return fmt.Errorf("os.RemoveAll: %v", err)
}
} else {
b.logf("Disablement secret did not verify, leaving TKA enabled.")
}
} else {
return fmt.Errorf("[bug] unreachable invariant of wantEnabled /w isEnabled")
}
}
if isEnabled && b.tka.authority.Head() != nm.TKAHead {
// TODO(tom): Implement sync
}
return nil
}
// chonkPath returns the absolute path to the directory in which TKA
// state (the 'tailchonk') is stored.
func (b *LocalBackend) chonkPath() string {
return filepath.Join(b.TailscaleVarRoot(), "tka")
}
// tkaBootstrapFromGenesisLocked initializes the local (on-disk) state of the
// tailnet key authority, based on the given genesis AUM.
//
// b.mu must be held.
func (b *LocalBackend) tkaBootstrapFromGenesisLocked(g tkatype.MarshaledAUM) error {
if !b.CanSupportNetworkLock() {
return errors.New("network lock not supported in this configuration")
}
var genesis tka.AUM
if err := genesis.Unserialize(g); err != nil {
return fmt.Errorf("reading genesis: %v", err)
}
chonkDir := b.chonkPath()
if err := os.Mkdir(chonkDir, 0755); err != nil && !os.IsExist(err) {
return fmt.Errorf("mkdir: %v", err)
}
chonk, err := tka.ChonkDir(chonkDir)
if err != nil {
return fmt.Errorf("chonk: %v", err)
}
authority, err := tka.Bootstrap(chonk, genesis)
if err != nil {
return fmt.Errorf("tka bootstrap: %v", err)
}
b.tka = &tkaState{
authority: authority,
storage: chonk,
}
return nil
}
// CanSupportNetworkLock returns true if tailscaled is able to operate
// a local tailnet key authority (and hence enforce network lock).
func (b *LocalBackend) CanSupportNetworkLock() bool {
@@ -88,9 +200,15 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key) error {
if !b.CanSupportNetworkLock() {
return errors.New("network-lock is not supported in this configuration. Did you supply a --statedir?")
}
nm := b.NetMap()
if nm == nil {
return errors.New("no netmap: are you logged into tailscale?")
var ourNodeKey key.NodePublic
b.mu.Lock()
if b.prefs != nil {
ourNodeKey = b.prefs.Persist.PrivateNodeKey.Public()
}
b.mu.Unlock()
if ourNodeKey.IsZero() {
return errors.New("no node-key: is tailscale logged in?")
}
// Generates a genesis AUM representing trust in the provided keys.
@@ -112,7 +230,7 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key) error {
}
// Phase 1/2 of initialization: Transmit the genesis AUM to Control.
initResp, err := b.tkaInitBegin(nm, genesisAUM)
initResp, err := b.tkaInitBegin(ourNodeKey, genesisAUM)
if err != nil {
return fmt.Errorf("tka init-begin RPC: %w", err)
}
@@ -133,7 +251,7 @@ func (b *LocalBackend) NetworkLockInit(keys []tka.Key) error {
}
// Finalize enablement by transmitting signature for all nodes to Control.
_, err = b.tkaInitFinish(nm, sigs)
_, err = b.tkaInitFinish(ourNodeKey, sigs)
return err
}
@@ -156,10 +274,11 @@ func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeK
return &sig, nil
}
func (b *LocalBackend) tkaInitBegin(nm *netmap.NetworkMap, aum tka.AUM) (*tailcfg.TKAInitBeginResponse, error) {
func (b *LocalBackend) tkaInitBegin(ourNodeKey key.NodePublic, aum tka.AUM) (*tailcfg.TKAInitBeginResponse, error) {
var req bytes.Buffer
if err := json.NewEncoder(&req).Encode(tailcfg.TKAInitBeginRequest{
NodeID: nm.SelfNode.ID,
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: ourNodeKey,
GenesisAUM: aum.Serialize(),
}); err != nil {
return nil, fmt.Errorf("encoding request: %v", err)
@@ -197,10 +316,11 @@ func (b *LocalBackend) tkaInitBegin(nm *netmap.NetworkMap, aum tka.AUM) (*tailcf
}
}
func (b *LocalBackend) tkaInitFinish(nm *netmap.NetworkMap, nks map[tailcfg.NodeID]tkatype.MarshaledSignature) (*tailcfg.TKAInitFinishResponse, error) {
func (b *LocalBackend) tkaInitFinish(ourNodeKey key.NodePublic, nks map[tailcfg.NodeID]tkatype.MarshaledSignature) (*tailcfg.TKAInitFinishResponse, error) {
var req bytes.Buffer
if err := json.NewEncoder(&req).Encode(tailcfg.TKAInitFinishRequest{
NodeID: nm.SelfNode.ID,
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: ourNodeKey,
Signatures: nks,
}); err != nil {
return nil, fmt.Errorf("encoding request: %v", err)
@@ -237,3 +357,51 @@ func (b *LocalBackend) tkaInitFinish(nm *netmap.NetworkMap, nks map[tailcfg.Node
return a, nil
}
}
// tkaFetchBootstrap sends a /machine/tka/bootstrap RPC to the control plane
// over noise. This is used to get values necessary to enable or disable TKA.
func (b *LocalBackend) tkaFetchBootstrap(ourNodeKey key.NodePublic, head tka.AUMHash) (*tailcfg.TKABootstrapResponse, error) {
bootstrapReq := tailcfg.TKABootstrapRequest{
Version: tailcfg.CurrentCapabilityVersion,
NodeKey: ourNodeKey,
}
if !head.IsZero() {
head, err := head.MarshalText()
if err != nil {
return nil, fmt.Errorf("head.MarshalText failed: %v", err)
}
bootstrapReq.Head = string(head)
}
var req bytes.Buffer
if err := json.NewEncoder(&req).Encode(bootstrapReq); err != nil {
return nil, fmt.Errorf("encoding request: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("ctx: %w", err)
}
req2, err := http.NewRequestWithContext(ctx, "GET", "https://unused/machine/tka/bootstrap", &req)
if err != nil {
return nil, fmt.Errorf("req: %w", err)
}
res, err := b.DoNoiseRequest(req2)
if err != nil {
return nil, fmt.Errorf("resp: %w", err)
}
if res.StatusCode != 200 {
body, _ := io.ReadAll(res.Body)
res.Body.Close()
return nil, fmt.Errorf("request returned (%d): %s", res.StatusCode, string(body))
}
a := new(tailcfg.TKABootstrapResponse)
err = json.NewDecoder(res.Body).Decode(a)
res.Body.Close()
if err != nil {
return nil, fmt.Errorf("decoding JSON: %w", err)
}
return a, nil
}

View File

@@ -0,0 +1,258 @@
// 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 ipnlocal
import (
"bytes"
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"tailscale.com/control/controlclient"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/types/netmap"
"tailscale.com/types/persist"
)
func fakeControlClient(t *testing.T, c *http.Client) *controlclient.Auto {
hi := hostinfo.New()
ni := tailcfg.NetInfo{LinkType: "wired"}
hi.NetInfo = &ni
k := key.NewMachine()
opts := controlclient.Options{
ServerURL: "https://example.com",
Hostinfo: hi,
GetMachinePrivateKey: func() (key.MachinePrivate, error) {
return k, nil
},
HTTPTestClient: c,
NoiseTestClient: c,
Status: func(controlclient.Status) {},
}
cc, err := controlclient.NewNoStart(opts)
if err != nil {
t.Fatal(err)
}
return cc
}
// NOTE: URLs must have a https scheme and example.com domain to work with the underlying
// httptest plumbing, despite the domain being unused in the actual noise request transport.
func fakeNoiseServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *http.Client) {
ts := httptest.NewUnstartedServer(handler)
ts.StartTLS()
client := ts.Client()
client.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify = true
client.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, ts.Listener.Addr().String())
}
return ts, client
}
func TestTKAEnablementFlow(t *testing.T) {
networkLockAvailable = func() bool { return true } // Enable the feature flag
nodePriv := key.NewNode()
// Make a fake TKA authority, getting a usable genesis AUM which
// our mock server can communicate.
nlPriv := key.NewNLPrivate()
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
a1, genesisAUM, err := tka.Create(&tka.Mem{}, tka.State{
Keys: []tka.Key{key},
DisablementSecrets: [][]byte{bytes.Repeat([]byte{0xa5}, 32)},
}, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/bootstrap":
body := new(tailcfg.TKABootstrapRequest)
if err := json.NewDecoder(r.Body).Decode(body); err != nil {
t.Fatal(err)
}
if body.Version != tailcfg.CurrentCapabilityVersion {
t.Errorf("bootstrap CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion)
}
if body.NodeKey != nodePriv.Public() {
t.Errorf("bootstrap nodeKey=%v, want %v", body.NodeKey, nodePriv.Public())
}
if body.Head != "" {
t.Errorf("bootstrap head=%s, want empty hash", body.Head)
}
w.WriteHeader(200)
out := tailcfg.TKABootstrapResponse{
GenesisAUM: genesisAUM.Serialize(),
}
if err := json.NewEncoder(w).Encode(out); err != nil {
t.Fatal(err)
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
temp := t.TempDir()
cc := fakeControlClient(t, client)
b := LocalBackend{
varRoot: temp,
cc: cc,
ccAuto: cc,
logf: t.Logf,
prefs: &ipn.Prefs{
Persist: &persist.Persist{PrivateNodeKey: nodePriv},
},
}
b.mu.Lock()
err = b.tkaSyncIfNeededLocked(&netmap.NetworkMap{
TKAEnabled: true,
TKAHead: tka.AUMHash{},
})
b.mu.Unlock()
if err != nil {
t.Errorf("tkaSyncIfNeededLocked() failed: %v", err)
}
if b.tka == nil {
t.Fatal("tka was not initialized")
}
if b.tka.authority.Head() != a1.Head() {
t.Errorf("authority.Head() = %x, want %x", b.tka.authority.Head(), a1.Head())
}
}
func TestTKADisablementFlow(t *testing.T) {
networkLockAvailable = func() bool { return true } // Enable the feature flag
temp := t.TempDir()
os.Mkdir(filepath.Join(temp, "tka"), 0755)
nodePriv := key.NewNode()
// Make a fake TKA authority, to seed local state.
disablementSecret := bytes.Repeat([]byte{0xa5}, 32)
nlPriv := key.NewNLPrivate()
key := tka.Key{Kind: tka.Key25519, Public: nlPriv.Public().Verifier(), Votes: 2}
chonk, err := tka.ChonkDir(filepath.Join(temp, "tka"))
if err != nil {
t.Fatal(err)
}
authority, _, err := tka.Create(chonk, tka.State{
Keys: []tka.Key{key},
DisablementSecrets: [][]byte{tka.DisablementKDF(disablementSecret)},
}, nlPriv)
if err != nil {
t.Fatalf("tka.Create() failed: %v", err)
}
returnWrongSecret := false
ts, client := fakeNoiseServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
switch r.URL.Path {
case "/machine/tka/bootstrap":
body := new(tailcfg.TKABootstrapRequest)
if err := json.NewDecoder(r.Body).Decode(body); err != nil {
t.Fatal(err)
}
if body.Version != tailcfg.CurrentCapabilityVersion {
t.Errorf("bootstrap CapVer = %v, want %v", body.Version, tailcfg.CurrentCapabilityVersion)
}
if body.NodeKey != nodePriv.Public() {
t.Errorf("nodeKey=%v, want %v", body.NodeKey, nodePriv.Public())
}
var head tka.AUMHash
if err := head.UnmarshalText([]byte(body.Head)); err != nil {
t.Fatalf("failed unmarshal of body.Head: %v", err)
}
if head != authority.Head() {
t.Errorf("reported head = %x, want %x", head, authority.Head())
}
var disablement []byte
if returnWrongSecret {
disablement = bytes.Repeat([]byte{0x42}, 32) // wrong secret
} else {
disablement = disablementSecret
}
w.WriteHeader(200)
out := tailcfg.TKABootstrapResponse{
DisablementSecret: disablement,
}
if err := json.NewEncoder(w).Encode(out); err != nil {
t.Fatal(err)
}
default:
t.Errorf("unhandled endpoint path: %v", r.URL.Path)
w.WriteHeader(404)
}
}))
defer ts.Close()
cc := fakeControlClient(t, client)
b := LocalBackend{
varRoot: temp,
cc: cc,
ccAuto: cc,
logf: t.Logf,
tka: &tkaState{
authority: authority,
storage: chonk,
},
prefs: &ipn.Prefs{
Persist: &persist.Persist{PrivateNodeKey: nodePriv},
},
}
// Test that the wrong disablement secret does not shut down the authority.
returnWrongSecret = true
b.mu.Lock()
err = b.tkaSyncIfNeededLocked(&netmap.NetworkMap{
TKAEnabled: false,
TKAHead: authority.Head(),
})
b.mu.Unlock()
if err != nil {
t.Errorf("tkaSyncIfNeededLocked() failed: %v", err)
}
if b.tka == nil {
t.Error("TKA was disabled despite incorrect disablement secret")
}
// Test the correct disablement secret shuts down the authority.
returnWrongSecret = false
b.mu.Lock()
err = b.tkaSyncIfNeededLocked(&netmap.NetworkMap{
TKAEnabled: false,
TKAHead: authority.Head(),
})
b.mu.Unlock()
if err != nil {
t.Errorf("tkaSyncIfNeededLocked() failed: %v", err)
}
if b.tka != nil {
t.Fatal("tka was not shut down")
}
if _, err := os.Stat(b.chonkPath()); err == nil || !os.IsNotExist(err) {
t.Errorf("os.Stat(chonkDir) = %v, want ErrNotExist", err)
}
}

View File

@@ -38,15 +38,16 @@ import (
// running as root.
var keyTypes = []string{"rsa", "ecdsa", "ed25519"}
// getSSHUsernames discovers and returns the list of usernames that are
// potential Tailscale SSH user targets.
//
// Invariant: must not be called with b.mu held.
func (b *LocalBackend) getSSHUsernames(req *tailcfg.C2NSSHUsernamesRequest) (*tailcfg.C2NSSHUsernamesResponse, error) {
res := new(tailcfg.C2NSSHUsernamesResponse)
b.mu.Lock()
defer b.mu.Unlock()
if b.sshServer == nil {
if !b.tailscaleSSHEnabled() {
return res, nil
}
max := 10
if req != nil && req.Max != 0 {
max = req.Max
@@ -70,8 +71,8 @@ func (b *LocalBackend) getSSHUsernames(req *tailcfg.C2NSSHUsernamesRequest) (*ta
res.Usernames = append(res.Usernames, u)
}
if b.prefs != nil && b.prefs.OperatorUser != "" {
add(b.prefs.OperatorUser)
if opUser := b.operatorUserName(); opUser != "" {
add(opUser)
}
// Check popular usernames and see if they exist with a real shell.

View File

@@ -772,7 +772,7 @@ func New(logf logger.Logf, logid string, store ipn.StateStore, eng wgengine.Engi
})
if root := b.TailscaleVarRoot(); root != "" {
chonkDir := filepath.Join(root, "chonk")
chonkDir := filepath.Join(root, "tka")
if _, err := os.Stat(chonkDir); err == nil {
// The directory exists, which means network-lock has been initialized.
storage, err := tka.ChonkDir(chonkDir)

View File

@@ -221,6 +221,9 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
if note := r.FormValue("note"); len(note) > 0 {
h.logf("user bugreport note: %s", note)
}
if defBool(r.FormValue("diagnose"), false) {
h.b.Doctor(r.Context(), logger.WithPrefix(h.logf, "diag: "))
}
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintln(w, logMarker)
}

View File

@@ -1,47 +0,0 @@
# Tailscale for Windows dependencies
The following open source dependencies are used to build the [Tailscale client
for windows][]. See also the dependencies in the [Tailscale CLI][].
[Tailscale client for windows]: https://tailscale.com/kb/1022/install-windows/
[Tailscale CLI]: ./tailscale.md
## Go Packages
- [filippo.io/edwards25519](https://pkg.go.dev/filippo.io/edwards25519) ([BSD-3-Clause](https://github.com/FiloSottile/edwards25519/blob/v1.0.0-rc.1/LICENSE))
- [github.com/alexbrainman/sspi](https://pkg.go.dev/github.com/alexbrainman/sspi) ([BSD-3-Clause](https://github.com/alexbrainman/sspi/blob/909beea2cc74/LICENSE))
- [github.com/apenwarr/fixconsole](https://pkg.go.dev/github.com/apenwarr/fixconsole) ([Apache-2.0](https://github.com/apenwarr/fixconsole/blob/5a9f6489cc29/LICENSE))
- [github.com/apenwarr/w32](https://pkg.go.dev/github.com/apenwarr/w32) ([BSD-3-Clause](https://github.com/apenwarr/w32/blob/aa00fece76ab/LICENSE))
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.4.0/LICENSE))
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/c00d1f31bab3/LICENSE))
- [github.com/josharian/native](https://pkg.go.dev/github.com/josharian/native) ([MIT](https://github.com/josharian/native/blob/v1.0.0/license))
- [github.com/jsimonetti/rtnetlink](https://pkg.go.dev/github.com/jsimonetti/rtnetlink) ([MIT](https://github.com/jsimonetti/rtnetlink/blob/d380b505068b/LICENSE.md))
- [github.com/klauspost/compress](https://pkg.go.dev/github.com/klauspost/compress) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.15.5/LICENSE))
- [github.com/klauspost/compress/internal/snapref](https://pkg.go.dev/github.com/klauspost/compress/internal/snapref) ([BSD-3-Clause](https://github.com/klauspost/compress/blob/v1.15.5/internal/snapref/LICENSE))
- [github.com/klauspost/compress/zstd/internal/xxhash](https://pkg.go.dev/github.com/klauspost/compress/zstd/internal/xxhash) ([MIT](https://github.com/klauspost/compress/blob/v1.15.5/zstd/internal/xxhash/LICENSE.txt))
- [github.com/lxn/walk](https://pkg.go.dev/github.com/lxn/walk) ([BSD-3-Clause](https://github.com/tailscale/walk/blob/ed127cfb919a/LICENSE))
- [github.com/lxn/win](https://pkg.go.dev/github.com/lxn/win) ([BSD-3-Clause](https://github.com/tailscale/win/blob/c3f813abca9f/LICENSE))
- [github.com/mdlayher/netlink](https://pkg.go.dev/github.com/mdlayher/netlink) ([MIT](https://github.com/mdlayher/netlink/blob/v1.6.0/LICENSE.md))
- [github.com/mdlayher/socket](https://pkg.go.dev/github.com/mdlayher/socket) ([MIT](https://github.com/mdlayher/socket/blob/v0.2.3/LICENSE.md))
- [github.com/mitchellh/go-ps](https://pkg.go.dev/github.com/mitchellh/go-ps) ([MIT](https://github.com/mitchellh/go-ps/blob/v1.0.0/LICENSE.md))
- [github.com/skip2/go-qrcode](https://pkg.go.dev/github.com/skip2/go-qrcode) ([MIT](https://github.com/skip2/go-qrcode/blob/da1b6568686e/LICENSE))
- [github.com/x448/float16](https://pkg.go.dev/github.com/x448/float16) ([MIT](https://github.com/x448/float16/blob/v0.8.4/LICENSE))
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/4f986261bf13/LICENSE))
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/7e7bdc8411bf/LICENSE))
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/6f7dac96:LICENSE))
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/c690dde0:LICENSE))
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/0de741cf:LICENSE))
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/c0bba94a:LICENSE))
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/03fcf44c:LICENSE))
- [golang.zx2c4.com/wintun](https://pkg.go.dev/golang.zx2c4.com/wintun) ([MIT](https://git.zx2c4.com/wintun-go/tree/LICENSE?id=415007cec224))
- [golang.zx2c4.com/wireguard/windows/tunnel/winipcfg](https://pkg.go.dev/golang.zx2c4.com/wireguard/windows/tunnel/winipcfg) ([MIT](https://git.zx2c4.com/wireguard-windows/tree/COPYING?h=v0.4.10))
- [gopkg.in/Knetic/govaluate.v3](https://pkg.go.dev/gopkg.in/Knetic/govaluate.v3) ([MIT](https://github.com/Knetic/govaluate/blob/v3.0.0/LICENSE))
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/f81723ceac3f/LICENSE))
## Additional Dependencies
- [Nullsoft Scriptable Install System](https://nsis.sourceforge.io/) ([zlib/libpng](https://nsis.sourceforge.io/License))
- [Wintun](https://www.wintun.net/) ([Prebuilt Binaries License](https://git.zx2c4.com/wintun/tree/prebuilt-binaries-license.txt))
- [wireguard-windows](https://git.zx2c4.com/wireguard-windows/) ([MIT](https://git.zx2c4.com/wireguard-windows/tree/COPYING))

View File

@@ -5,9 +5,12 @@
package dns
import (
"bufio"
"errors"
"fmt"
"net/netip"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
)
@@ -97,6 +100,44 @@ func (a OSConfig) Equal(b OSConfig) bool {
return true
}
// Format implements the fmt.Formatter interface to ensure that Hosts is
// printed correctly (i.e. not as a bunch of pointers).
//
// Fixes https://github.com/tailscale/tailscale/issues/5669
func (a OSConfig) Format(f fmt.State, verb rune) {
logger.ArgWriter(func(w *bufio.Writer) {
w.WriteString(`{Nameservers:[`)
for i, ns := range a.Nameservers {
if i != 0 {
w.WriteString(" ")
}
fmt.Fprintf(w, "%+v", ns)
}
w.WriteString(`] SearchDomains:[`)
for i, domain := range a.SearchDomains {
if i != 0 {
w.WriteString(" ")
}
fmt.Fprintf(w, "%+v", domain)
}
w.WriteString(`] MatchDomains:[`)
for i, domain := range a.MatchDomains {
if i != 0 {
w.WriteString(" ")
}
fmt.Fprintf(w, "%+v", domain)
}
w.WriteString(`] Hosts:[`)
for i, host := range a.Hosts {
if i != 0 {
w.WriteString(" ")
}
fmt.Fprintf(w, "%+v", host)
}
w.WriteString(`]}`)
}).Format(f, verb)
}
// ErrGetBaseConfigNotSupported is the error
// OSConfigurator.GetBaseConfig returns when the OSConfigurator
// doesn't support reading the underlying configuration out of the OS.

44
net/dns/osconfig_test.go Normal file
View File

@@ -0,0 +1,44 @@
// 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 dns
import (
"fmt"
"net/netip"
"testing"
"tailscale.com/util/dnsname"
)
func TestOSConfigPrintable(t *testing.T) {
ocfg := OSConfig{
Hosts: []*HostEntry{
{
Addr: netip.AddrFrom4([4]byte{100, 1, 2, 3}),
Hosts: []string{"server", "client"},
},
{
Addr: netip.AddrFrom4([4]byte{100, 1, 2, 4}),
Hosts: []string{"otherhost"},
},
},
Nameservers: []netip.Addr{
netip.AddrFrom4([4]byte{8, 8, 8, 8}),
},
SearchDomains: []dnsname.FQDN{
dnsname.FQDN("foo.beta.tailscale.net."),
dnsname.FQDN("bar.beta.tailscale.net."),
},
MatchDomains: []dnsname.FQDN{
dnsname.FQDN("ts.com."),
},
}
s := fmt.Sprintf("%+v", ocfg)
const expected = `{Nameservers:[8.8.8.8] SearchDomains:[foo.beta.tailscale.net. bar.beta.tailscale.net.] MatchDomains:[ts.com.] Hosts:[&{Addr:100.1.2.3 Hosts:[server client]} &{Addr:100.1.2.4 Hosts:[otherhost]}]}`
if s != expected {
t.Errorf("format mismatch:\n got: %s\n want: %s", s, expected)
}
}

View File

@@ -13,6 +13,7 @@ import (
"fmt"
"io"
"log"
"math/rand"
"net"
"net/http"
"net/netip"
@@ -112,6 +113,10 @@ type Report struct {
GlobalV4 string // ip:port of global IPv4
GlobalV6 string // [ip]:port of global IPv6
// CaptivePortal is set when we think there's a captive portal that is
// intercepting HTTP traffic.
CaptivePortal opt.Bool
// TODO: update Clone when adding new fields
}
@@ -175,6 +180,10 @@ type Client struct {
// If nil, portmap discovery is not done.
PortMapper *portmapper.Client // lazily initialized on first use
// For tests
testEnoughRegions int
testCaptivePortalDelay time.Duration
mu sync.Mutex // guards following
nextFull bool // do a full region scan, even if last != nil
prev map[time.Time]*Report // some previous reports
@@ -192,6 +201,9 @@ type STUNConn interface {
}
func (c *Client) enoughRegions() int {
if c.testEnoughRegions > 0 {
return c.testEnoughRegions
}
if c.Verbose {
// Abuse verbose a bit here so netcheck can show all region latencies
// in verbose mode.
@@ -200,6 +212,14 @@ func (c *Client) enoughRegions() int {
return 3
}
func (c *Client) captivePortalDelay() time.Duration {
if c.testCaptivePortalDelay > 0 {
return c.testCaptivePortalDelay
}
// Chosen semi-arbitrarily
return 200 * time.Millisecond
}
func (c *Client) logf(format string, a ...any) {
if c.Logf != nil {
c.Logf(format, a...)
@@ -783,13 +803,35 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap) (_ *Report,
}
c.curState = rs
last := c.last
// Even if we're doing a non-incremental update, we may want to try our
// preferred DERP region for captive portal detection. Save that, if we
// have it.
var preferredDERP int
if last != nil {
preferredDERP = last.PreferredDERP
}
now := c.timeNow()
doFull := false
if c.nextFull || now.Sub(c.lastFull) > 5*time.Minute {
doFull = true
}
// If the last report had a captive portal and reported no UDP access,
// it's possible that we didn't get a useful netcheck due to the
// captive portal blocking us. If so, make this report a full
// (non-incremental) one.
if !doFull && last != nil {
doFull = !last.UDP && last.CaptivePortal.EqualBool(true)
}
if doFull {
last = nil // causes makeProbePlan below to do a full (initial) plan
c.nextFull = false
c.lastFull = now
metricNumGetReportFull.Add(1)
}
rs.incremental = last != nil
c.mu.Unlock()
@@ -874,6 +916,48 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap) (_ *Report,
plan := makeProbePlan(dm, ifState, last)
// If we're doing a full probe, also check for a captive portal. We
// delay by a bit to wait for UDP STUN to finish, to avoid the probe if
// it's unnecessary.
captivePortalDone := syncs.ClosedChan()
captivePortalStop := func() {}
if !rs.incremental {
// NOTE(andrew): we can't simply add this goroutine to the
// `NewWaitGroupChan` below, since we don't wait for that
// waitgroup to finish when exiting this function and thus get
// a data race.
ch := make(chan struct{})
captivePortalDone = ch
tmr := time.AfterFunc(c.captivePortalDelay(), func() {
defer close(ch)
found, err := c.checkCaptivePortal(ctx, dm, preferredDERP)
if err != nil {
c.logf("[v1] checkCaptivePortal: %v", err)
return
}
rs.report.CaptivePortal.Set(found)
})
captivePortalStop = func() {
// Don't cancel our captive portal check if we're
// explicitly doing a verbose netcheck.
if c.Verbose {
return
}
if tmr.Stop() {
// Stopped successfully; need to close the
// signal channel ourselves.
close(ch)
return
}
// Did not stop; do nothing and it'll finish by itself
// and close the signal channel.
}
}
wg := syncs.NewWaitGroupChan()
wg.Add(len(plan))
for _, probeSet := range plan {
@@ -894,9 +978,17 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap) (_ *Report,
case <-stunTimer.C:
case <-ctx.Done():
case <-wg.DoneChan():
// All of our probes finished, so if we have >0 responses, we
// stop our captive portal check.
if rs.anyUDP() {
captivePortalStop()
}
case <-rs.stopProbeCh:
// Saw enough regions.
c.vlogf("saw enough regions; not waiting for rest")
// We can stop the captive portal check since we know that we
// got a bunch of STUN responses.
captivePortalStop()
}
rs.waitHairCheck(ctx)
@@ -965,6 +1057,9 @@ func (c *Client) GetReport(ctx context.Context, dm *tailcfg.DERPMap) (_ *Report,
wg.Wait()
}
// Wait for captive portal check before finishing the report.
<-captivePortalDone
return c.finishAndStoreReport(rs, dm), nil
}
@@ -979,6 +1074,54 @@ func (c *Client) finishAndStoreReport(rs *reportState, dm *tailcfg.DERPMap) *Rep
return report
}
var noRedirectClient = &http.Client{
// No redirects allowed
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
// Remaining fields are the same as the default client.
Transport: http.DefaultClient.Transport,
Jar: http.DefaultClient.Jar,
Timeout: http.DefaultClient.Timeout,
}
// checkCaptivePortal reports whether or not we think the system is behind a
// captive portal, detected by making a request to a URL that we know should
// return a "204 No Content" response and checking if that's what we get.
//
// The boolean return is whether we think we have a captive portal.
func (c *Client) checkCaptivePortal(ctx context.Context, dm *tailcfg.DERPMap, preferredDERP int) (bool, error) {
defer noRedirectClient.CloseIdleConnections()
// If we have a preferred DERP region with more than one node, try
// that; otherwise, pick a random one not marked as "Avoid".
if preferredDERP == 0 || dm.Regions[preferredDERP] == nil ||
(preferredDERP != 0 && len(dm.Regions[preferredDERP].Nodes) == 0) {
rids := make([]int, 0, len(dm.Regions))
for id, reg := range dm.Regions {
if reg == nil || reg.Avoid || len(reg.Nodes) == 0 {
continue
}
rids = append(rids, id)
}
preferredDERP = rids[rand.Intn(len(rids))]
}
node := dm.Regions[preferredDERP].Nodes[0]
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+node.HostName+"/generate_204", nil)
if err != nil {
return false, err
}
r, err := noRedirectClient.Do(req)
if err != nil {
return false, err
}
c.logf("[v2] checkCaptivePortal url=%q status_code=%d", req.URL.String(), r.StatusCode)
return r.StatusCode != 204, nil
}
// runHTTPOnlyChecks is the netcheck done by environments that can
// only do HTTP requests, such as ws/wasm.
func (c *Client) runHTTPOnlyChecks(ctx context.Context, last *Report, rs *reportState, dm *tailcfg.DERPMap) error {
@@ -1045,6 +1188,8 @@ func (c *Client) measureHTTPSLatency(ctx context.Context, reg *tailcfg.DERPRegio
var ip netip.Addr
dc := derphttp.NewNetcheckClient(c.logf)
defer dc.Close()
tlsConn, tcpConn, node, err := dc.DialRegionTLS(ctx, reg)
if err != nil {
return 0, ip, err
@@ -1200,6 +1345,9 @@ func (c *Client) logConciseReport(r *Report, dm *tailcfg.DERPMap) {
if r.GlobalV6 != "" {
fmt.Fprintf(w, " v6a=%v", r.GlobalV6)
}
if r.CaptivePortal != "" {
fmt.Fprintf(w, " captiveportal=%v", r.CaptivePortal)
}
fmt.Fprintf(w, " derp=%v", r.PreferredDERP)
if r.PreferredDERP != 0 {
fmt.Fprintf(w, " derpdist=")

View File

@@ -9,11 +9,13 @@ import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"reflect"
"sort"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
@@ -115,6 +117,9 @@ func TestWorksWhenUDPBlocked(t *testing.T) {
// OS IPv6 test is irrelevant here, accept whatever the current
// machine has.
want.OSHasIPv6 = r.OSHasIPv6
// Captive portal test is irrelevant; accept what the current report
// has.
want.CaptivePortal = r.CaptivePortal
if !reflect.DeepEqual(r, want) {
t.Errorf("mismatch\n got: %+v\nwant: %+v\n", r, want)
@@ -661,3 +666,57 @@ func TestSortRegions(t *testing.T) {
t.Errorf("got %v; want %v", got, want)
}
}
func TestNoCaptivePortalWhenUDP(t *testing.T) {
// Override noRedirectClient to handle the /generate_204 endpoint
var generate204Called atomic.Bool
tr := RoundTripFunc(func(req *http.Request) *http.Response {
if !strings.HasSuffix(req.URL.String(), "/generate_204") {
panic("bad URL: " + req.URL.String())
}
generate204Called.Store(true)
return &http.Response{
StatusCode: http.StatusNoContent,
Header: make(http.Header),
}
})
oldTransport := noRedirectClient.Transport
t.Cleanup(func() { noRedirectClient.Transport = oldTransport })
noRedirectClient.Transport = tr
stunAddr, cleanup := stuntest.Serve(t)
defer cleanup()
c := &Client{
Logf: t.Logf,
UDPBindAddr: "127.0.0.1:0",
testEnoughRegions: 1,
// Set the delay long enough that we have time to cancel it
// when our STUN probe succeeds.
testCaptivePortalDelay: 10 * time.Second,
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
r, err := c.GetReport(ctx, stuntest.DERPMapOf(stunAddr.String()))
if err != nil {
t.Fatal(err)
}
// Should not have called our captive portal function.
if generate204Called.Load() {
t.Errorf("captive portal check called; expected no call")
}
if r.CaptivePortal != "" {
t.Errorf("got CaptivePortal=%q, want empty", r.CaptivePortal)
}
}
type RoundTripFunc func(req *http.Request) *http.Response
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}

View File

@@ -61,7 +61,7 @@ func (l *Listener) Accept() (net.Conn, error) {
// The provided Context must be non-nil. If the context expires before the
// connection is complete, an error is returned. Once successfully connected
// any expiration of the context will not affect the connection.
func (l *Listener) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
func (l *Listener) Dial(ctx context.Context, network, addr string) (_ net.Conn, err error) {
if !strings.HasSuffix(network, "tcp") {
return nil, net.UnknownNetworkError(network)
}
@@ -72,6 +72,13 @@ func (l *Listener) Dial(ctx context.Context, network, addr string) (net.Conn, er
}
}
c, s := NewConn(addr, bufferSize)
defer func() {
if err != nil {
c.Close()
s.Close()
}
}()
select {
case <-ctx.Done():
return nil, ctx.Err()

View File

@@ -18,7 +18,7 @@ import (
const unknown = ipproto.Unknown
// RFC1858: prevent overlapping fragment attacks.
const minFrag = 60 + 20 // max IPv4 header + basic TCP header
const minFragBlks = (60 + 20) / 8 // max IPv4 header + basic TCP header in fragment blocks (8 bytes each)
type TCPFlag uint8
@@ -152,11 +152,12 @@ func (q *Parsed) decode4(b []byte) {
// it as Unknown. We can also treat any subsequent fragment that starts
// at such a low offset as Unknown.
fragFlags := binary.BigEndian.Uint16(b[6:8])
moreFrags := (fragFlags & 0x20) != 0
moreFrags := (fragFlags & 0x2000) != 0
fragOfs := fragFlags & 0x1FFF
if fragOfs == 0 {
// This is the first fragment
if moreFrags && len(sub) < minFrag {
if moreFrags && len(sub) < minFragBlks {
// Suspiciously short first fragment, dump it.
q.IPProto = unknown
return
@@ -216,7 +217,7 @@ func (q *Parsed) decode4(b []byte) {
}
} else {
// This is a fragment other than the first one.
if fragOfs < minFrag {
if fragOfs < minFragBlks {
// First frag was suspiciously short, so we can't
// trust the followup either.
q.IPProto = unknown

View File

@@ -37,9 +37,9 @@ func mustIPPort(s string) netip.AddrPort {
var icmp4RequestBuffer = []byte{
// IP header up to checksum
0x45, 0x00, 0x00, 0x27, 0xde, 0xad, 0x00, 0x00, 0x40, 0x01, 0x8c, 0x15,
// source ip
// source IP
0x01, 0x02, 0x03, 0x04,
// destination ip
// destination IP
0x05, 0x06, 0x07, 0x08,
// ICMP header
0x08, 0x00, 0x7d, 0x22,
@@ -61,9 +61,9 @@ var icmp4RequestDecode = Parsed{
var icmp4ReplyBuffer = []byte{
0x45, 0x00, 0x00, 0x25, 0x21, 0x52, 0x00, 0x00, 0x40, 0x01, 0x49, 0x73,
// source ip
// source IP
0x05, 0x06, 0x07, 0x08,
// destination ip
// destination IP
0x01, 0x02, 0x03, 0x04,
// ICMP header
0x00, 0x00, 0xe6, 0x9e,
@@ -119,9 +119,9 @@ var unknownPacketDecode = Parsed{
var tcp4PacketBuffer = []byte{
// IP header up to checksum
0x45, 0x00, 0x00, 0x37, 0xde, 0xad, 0x00, 0x00, 0x40, 0x06, 0x49, 0x5f,
// source ip
// source IP
0x01, 0x02, 0x03, 0x04,
// destination ip
// destination IP
0x05, 0x06, 0x07, 0x08,
// TCP header with SYN, ACK set
0x00, 0x7b, 0x02, 0x37, 0x00, 0x00, 0x12, 0x34, 0x00, 0x00, 0x00, 0x00,
@@ -172,9 +172,9 @@ var tcp6RequestDecode = Parsed{
var udp4RequestBuffer = []byte{
// IP header up to checksum
0x45, 0x00, 0x00, 0x2b, 0xde, 0xad, 0x00, 0x00, 0x40, 0x11, 0x8c, 0x01,
// source ip
// source IP
0x01, 0x02, 0x03, 0x04,
// destination ip
// destination IP
0x05, 0x06, 0x07, 0x08,
// UDP header
0x00, 0x7b, 0x02, 0x37, 0x00, 0x17, 0x72, 0x1d,
@@ -197,9 +197,9 @@ var udp4RequestDecode = Parsed{
var invalid4RequestBuffer = []byte{
// IP header up to checksum. IHL field points beyond end of packet.
0x4a, 0x00, 0x00, 0x14, 0xde, 0xad, 0x00, 0x00, 0x40, 0x11, 0x8c, 0x01,
// source ip
// source IP
0x01, 0x02, 0x03, 0x04,
// destination ip
// destination IP
0x05, 0x06, 0x07, 0x08,
}
@@ -244,9 +244,9 @@ var udp6RequestDecode = Parsed{
var udp4ReplyBuffer = []byte{
// IP header up to checksum
0x45, 0x00, 0x00, 0x29, 0x21, 0x52, 0x00, 0x00, 0x40, 0x11, 0x49, 0x5f,
// source ip
// source IP
0x05, 0x06, 0x07, 0x08,
// destination ip
// destination IP
0x01, 0x02, 0x03, 0x04,
// UDP header
0x02, 0x37, 0x00, 0x7b, 0x00, 0x15, 0xd3, 0x9d,
@@ -265,6 +265,59 @@ var udp4ReplyDecode = Parsed{
Dst: mustIPPort("5.6.7.8:123"),
}
// First TCP fragment of a packet with leading 24 bytes of 'a's
var tcp4MediumFragmentBuffer = []byte{
// IP header up to checksum
0x45, 0x20, 0x00, 0x4c, 0x2c, 0x62, 0x20, 0x00, 0x22, 0x06, 0x3a, 0x0f,
// source IP
0x01, 0x02, 0x03, 0x04,
// destination IP
0x05, 0x06, 0x07, 0x08,
// TCP header
0x00, 0x50, 0xf3, 0x8c, 0x58, 0xad, 0x60, 0x94, 0x25, 0xe4, 0x23, 0xa8, 0x80,
0x10, 0x01, 0xfd, 0xc6, 0x6e, 0x00, 0x00, 0x01, 0x01, 0x08, 0x0a, 0xff, 0x60,
0xfb, 0xfe, 0xba, 0x31, 0x78, 0x6a,
// data
0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61,
0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61, 0x61,
}
var tcp4MediumFragmentDecode = Parsed{
b: tcp4MediumFragmentBuffer,
subofs: 20,
dataofs: 52,
length: len(tcp4MediumFragmentBuffer),
IPVersion: 4,
IPProto: TCP,
Src: mustIPPort("1.2.3.4:80"),
Dst: mustIPPort("5.6.7.8:62348"),
TCPFlags: 0x10,
}
var tcp4ShortFragmentBuffer = []byte{
// IP header up to checksum
0x45, 0x20, 0x00, 0x1e, 0x2c, 0x62, 0x20, 0x00, 0x22, 0x06, 0x3c, 0x4f,
// source IP
0x01, 0x02, 0x03, 0x04,
// destination IP
0x05, 0x06, 0x07, 0x08,
// partial TCP header
0x00, 0x50, 0xf3, 0x8c, 0x58, 0xad, 0x60, 0x94, 0x00, 0x00,
}
var tcp4ShortFragmentDecode = Parsed{
b: tcp4ShortFragmentBuffer,
subofs: 20,
dataofs: 0,
length: len(tcp4ShortFragmentBuffer),
// short fragments are rejected (marked unknown) to avoid header attacks as described in RFC 1858
IPProto: ipproto.Unknown,
IPVersion: 4,
Src: mustIPPort("1.2.3.4:0"),
Dst: mustIPPort("5.6.7.8:0"),
}
var igmpPacketBuffer = []byte{
// IP header up to checksum
0x46, 0xc0, 0x00, 0x20, 0x00, 0x00, 0x40, 0x00, 0x01, 0x02, 0x41, 0x22,
@@ -404,6 +457,8 @@ func TestDecode(t *testing.T) {
{"invalid4", invalid4RequestBuffer, invalid4RequestDecode},
{"ipv4_tsmp", ipv4TSMPBuffer, ipv4TSMPDecode},
{"ipv4_sctp", sctpBuffer, sctpDecode},
{"ipv4_frag", tcp4MediumFragmentBuffer, tcp4MediumFragmentDecode},
{"ipv4_fragtooshort", tcp4ShortFragmentBuffer, tcp4ShortFragmentDecode},
}
for _, tt := range tests {

View File

@@ -0,0 +1,151 @@
// 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 routetable provides functions that operate on the system's route
// table.
package routetable
import (
"bufio"
"fmt"
"net/netip"
"strconv"
"tailscale.com/types/logger"
)
var (
defaultRouteIPv4 = RouteDestination{Prefix: netip.PrefixFrom(netip.IPv4Unspecified(), 0)}
defaultRouteIPv6 = RouteDestination{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)}
)
// RouteEntry contains common cross-platform fields describing an entry in the
// system route table.
type RouteEntry struct {
// Family is the IP family of the route; it will be either 4 or 6.
Family int
// Type is the type of this route.
Type RouteType
// Dst is the destination of the route.
Dst RouteDestination
// Gatewayis the gateway address specified for this route.
// This value will be invalid (where !r.Gateway.IsValid()) in cases
// where there is no gateway address for this route.
Gateway netip.Addr
// Interface is the name of the network interface to use when sending
// packets that match this route. This field can be empty.
Interface string
// Sys contains platform-specific information about this route.
Sys any
}
// Format implements the fmt.Formatter interface.
func (r RouteEntry) Format(f fmt.State, verb rune) {
logger.ArgWriter(func(w *bufio.Writer) {
switch r.Family {
case 4:
fmt.Fprintf(w, "{Family: IPv4")
case 6:
fmt.Fprintf(w, "{Family: IPv6")
default:
fmt.Fprintf(w, "{Family: unknown(%d)", r.Family)
}
// Match 'ip route' and other tools by not printing the route
// type if it's a unicast route.
if r.Type != RouteTypeUnicast {
fmt.Fprintf(w, ", Type: %s", r.Type)
}
if r.Dst.IsValid() {
fmt.Fprintf(w, ", Dst: %s", r.Dst)
} else {
w.WriteString(", Dst: invalid")
}
if r.Gateway.IsValid() {
fmt.Fprintf(w, ", Gateway: %s", r.Gateway)
}
if r.Interface != "" {
fmt.Fprintf(w, ", Interface: %s", r.Interface)
}
if r.Sys != nil {
var formatVerb string
switch {
case f.Flag('#'):
formatVerb = "%#v"
case f.Flag('+'):
formatVerb = "%+v"
default:
formatVerb = "%v"
}
fmt.Fprintf(w, ", Sys: "+formatVerb, r.Sys)
}
w.WriteString("}")
}).Format(f, verb)
}
// RouteDestination is the destination of a route.
//
// This is similar to net/netip.Prefix, but also contains an optional IPv6
// zone.
type RouteDestination struct {
netip.Prefix
Zone string
}
func (r RouteDestination) String() string {
ip := r.Prefix.Addr()
if r.Zone != "" {
ip = ip.WithZone(r.Zone)
}
return ip.String() + "/" + strconv.Itoa(r.Prefix.Bits())
}
// RouteType describes the type of a route.
type RouteType int
const (
// RouteTypeUnspecified is the unspecified route type.
RouteTypeUnspecified RouteType = iota
// RouteTypeLocal indicates that the destination of this route is an
// address that belongs to this system.
RouteTypeLocal
// RouteTypeUnicast indicates that the destination of this route is a
// "regular" address--one that neither belongs to this host, nor is a
// broadcast/multicast/etc. address.
RouteTypeUnicast
// RouteTypeBroadcast indicates that the destination of this route is a
// broadcast address.
RouteTypeBroadcast
// RouteTypeMulticast indicates that the destination of this route is a
// multicast address.
RouteTypeMulticast
// RouteTypeOther indicates that the route is of some other valid type;
// see the Sys field for the OS-provided route information to determine
// the exact type.
RouteTypeOther
)
func (r RouteType) String() string {
switch r {
case RouteTypeUnspecified:
return "unspecified"
case RouteTypeLocal:
return "local"
case RouteTypeUnicast:
return "unicast"
case RouteTypeBroadcast:
return "broadcast"
case RouteTypeMulticast:
return "multicast"
case RouteTypeOther:
return "other"
default:
return "invalid"
}
}

View File

@@ -0,0 +1,285 @@
// 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 darwin || freebsd
// +build darwin freebsd
package routetable
import (
"bufio"
"fmt"
"net"
"net/netip"
"runtime"
"sort"
"strings"
"syscall"
"golang.org/x/net/route"
"golang.org/x/sys/unix"
"tailscale.com/net/interfaces"
"tailscale.com/types/logger"
)
type RouteEntryBSD struct {
// GatewayInterface is the name of the interface specified as a gateway
// for this route, if any.
GatewayInterface string
// GatewayIdx is the index of the interface specified as a gateway for
// this route, if any.
GatewayIdx int
// GatewayAddr is the link-layer address of the gateway for this route,
// if any.
GatewayAddr string
// Flags contains a string representation of common flags for this
// route.
Flags []string
// RawFlags contains the raw flags that were returned by the operating
// system for this route.
RawFlags int
}
// Format implements the fmt.Formatter interface.
func (r RouteEntryBSD) Format(f fmt.State, verb rune) {
logger.ArgWriter(func(w *bufio.Writer) {
var pstart bool
pr := func(format string, args ...any) {
if pstart {
fmt.Fprintf(w, ", "+format, args...)
} else {
fmt.Fprintf(w, format, args...)
pstart = true
}
}
w.WriteString("{")
if r.GatewayInterface != "" {
pr("GatewayInterface: %s", r.GatewayInterface)
}
if r.GatewayIdx > 0 {
pr("GatewayIdx: %d", r.GatewayIdx)
}
if r.GatewayAddr != "" {
pr("GatewayAddr: %s", r.GatewayAddr)
}
pr("Flags: %v", r.Flags)
w.WriteString("}")
}).Format(f, verb)
}
// ipFromRMAddr returns a netip.Addr converted from one of the
// route.Inet{4,6}Addr types.
func ipFromRMAddr(ifs map[int]interfaces.Interface, addr any) netip.Addr {
switch v := addr.(type) {
case *route.Inet4Addr:
return netip.AddrFrom4(v.IP)
case *route.Inet6Addr:
ip := netip.AddrFrom16(v.IP)
if v.ZoneID != 0 {
if iif, ok := ifs[v.ZoneID]; ok {
ip = ip.WithZone(iif.Name)
} else {
ip = ip.WithZone(fmt.Sprint(v.ZoneID))
}
}
return ip
}
return netip.Addr{}
}
// populateGateway populates gateway fields on a RouteEntry/RouteEntryBSD.
func populateGateway(re *RouteEntry, reSys *RouteEntryBSD, ifs map[int]interfaces.Interface, addr any) {
// If the address type has a valid IP, use that.
if ip := ipFromRMAddr(ifs, addr); ip.IsValid() {
re.Gateway = ip
return
}
switch v := addr.(type) {
case *route.LinkAddr:
reSys.GatewayIdx = v.Index
if iif, ok := ifs[v.Index]; ok {
reSys.GatewayInterface = iif.Name
}
var sb strings.Builder
for i, x := range v.Addr {
if i != 0 {
sb.WriteByte(':')
}
fmt.Fprintf(&sb, "%02x", x)
}
reSys.GatewayAddr = sb.String()
}
}
// populateDestination populates the 'Dst' field on a RouteEntry based on the
// RouteMessage's destination and netmask fields.
func populateDestination(re *RouteEntry, ifs map[int]interfaces.Interface, rm *route.RouteMessage) {
dst := rm.Addrs[unix.RTAX_DST]
if dst == nil {
return
}
ip := ipFromRMAddr(ifs, dst)
if !ip.IsValid() {
return
}
if ip.Is4() {
re.Family = 4
} else {
re.Family = 6
}
re.Dst = RouteDestination{
Prefix: netip.PrefixFrom(ip, 32), // default if nothing more specific
}
// If the RTF_HOST flag is set, then this is a host route and there's
// no netmask in this RouteMessage.
if rm.Flags&unix.RTF_HOST != 0 {
return
}
// As above if there's no netmask in the list of addrs
if len(rm.Addrs) < unix.RTAX_NETMASK || rm.Addrs[unix.RTAX_NETMASK] == nil {
return
}
nm := ipFromRMAddr(ifs, rm.Addrs[unix.RTAX_NETMASK])
if !ip.IsValid() {
return
}
// Count the number of bits in the netmask IP and use that to make our prefix.
ones, _ /* bits */ := net.IPMask(nm.AsSlice()).Size()
// Print this ourselves instead of using netip.Prefix so that we don't
// lose the zone (since netip.Prefix strips that).
//
// NOTE(andrew): this doesn't print the same values as the 'netstat' tool
// for some addresses on macOS, and I have no idea why. Specifically,
// 'netstat -rn' will show something like:
// ff00::/8 ::1 UmCI lo0
//
// But we will get:
// destination=ff00::/40 [...]
//
// The netmask that we get back from FetchRIB has 32 more bits in it
// than netstat prints, but only for multicast routes.
//
// For consistency's sake, we're going to do the same here so that we
// get the same values as netstat returns.
if runtime.GOOS == "darwin" && ip.Is6() && ip.IsMulticast() && ones > 32 {
ones -= 32
}
re.Dst = RouteDestination{
Prefix: netip.PrefixFrom(ip, ones),
Zone: ip.Zone(),
}
}
// routeEntryFromMsg returns a RouteEntry from a single route.Message
// returned by the operating system.
func routeEntryFromMsg(ifsByIdx map[int]interfaces.Interface, msg route.Message) (RouteEntry, bool) {
rm, ok := msg.(*route.RouteMessage)
if !ok {
return RouteEntry{}, false
}
// Ignore things that we don't understand
if rm.Version < 3 || rm.Version > 5 {
return RouteEntry{}, false
}
if rm.Type != rmExpectedType {
return RouteEntry{}, false
}
if len(rm.Addrs) < unix.RTAX_GATEWAY {
return RouteEntry{}, false
}
if rm.Flags&skipFlags != 0 {
return RouteEntry{}, false
}
reSys := RouteEntryBSD{
RawFlags: rm.Flags,
}
for fv, fs := range flags {
if rm.Flags&fv == fv {
reSys.Flags = append(reSys.Flags, fs)
}
}
sort.Strings(reSys.Flags)
re := RouteEntry{}
hasFlag := func(f int) bool { return rm.Flags&f != 0 }
switch {
case hasFlag(unix.RTF_LOCAL):
re.Type = RouteTypeLocal
case hasFlag(unix.RTF_BROADCAST):
re.Type = RouteTypeBroadcast
case hasFlag(unix.RTF_MULTICAST):
re.Type = RouteTypeMulticast
// From the manpage: "host entry (net otherwise)"
case !hasFlag(unix.RTF_HOST):
re.Type = RouteTypeUnicast
default:
re.Type = RouteTypeOther
}
populateDestination(&re, ifsByIdx, rm)
if unix.RTAX_GATEWAY < len(rm.Addrs) {
populateGateway(&re, &reSys, ifsByIdx, rm.Addrs[unix.RTAX_GATEWAY])
}
if outif, ok := ifsByIdx[rm.Index]; ok {
re.Interface = outif.Name
}
re.Sys = reSys
return re, true
}
// Get returns route entries from the system route table, limited to at most
// 'max' results.
func Get(max int) ([]RouteEntry, error) {
// Fetching the list of interfaces can race with fetching our route
// table, but we do it anyway since it's helpful for debugging.
ifs, err := interfaces.GetList()
if err != nil {
return nil, err
}
ifsByIdx := make(map[int]interfaces.Interface)
for _, iif := range ifs {
ifsByIdx[iif.Index] = iif
}
rib, err := route.FetchRIB(syscall.AF_UNSPEC, ribType, 0)
if err != nil {
return nil, err
}
msgs, err := route.ParseRIB(parseType, rib)
if err != nil {
return nil, err
}
var ret []RouteEntry
for _, m := range msgs {
re, ok := routeEntryFromMsg(ifsByIdx, m)
if ok {
ret = append(ret, re)
if len(ret) == max {
break
}
}
}
return ret, nil
}

View File

@@ -0,0 +1,435 @@
// 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 darwin || freebsd
// +build darwin freebsd
package routetable
import (
"fmt"
"net"
"net/netip"
"reflect"
"runtime"
"testing"
"golang.org/x/net/route"
"golang.org/x/sys/unix"
"tailscale.com/net/interfaces"
)
func TestRouteEntryFromMsg(t *testing.T) {
ifs := map[int]interfaces.Interface{
1: {
Interface: &net.Interface{
Name: "iface0",
},
},
2: {
Interface: &net.Interface{
Name: "tailscale0",
},
},
}
ip4 := func(s string) *route.Inet4Addr {
ip := netip.MustParseAddr(s)
return &route.Inet4Addr{IP: ip.As4()}
}
ip6 := func(s string) *route.Inet6Addr {
ip := netip.MustParseAddr(s)
return &route.Inet6Addr{IP: ip.As16()}
}
ip6zone := func(s string, idx int) *route.Inet6Addr {
ip := netip.MustParseAddr(s)
return &route.Inet6Addr{IP: ip.As16(), ZoneID: idx}
}
link := func(idx int, addr string) *route.LinkAddr {
if _, found := ifs[idx]; !found {
panic("index not found")
}
ret := &route.LinkAddr{
Index: idx,
}
if addr != "" {
ret.Addr = make([]byte, 6)
fmt.Sscanf(addr, "%02x:%02x:%02x:%02x:%02x:%02x",
&ret.Addr[0],
&ret.Addr[1],
&ret.Addr[2],
&ret.Addr[3],
&ret.Addr[4],
&ret.Addr[5],
)
}
return ret
}
type testCase struct {
name string
msg *route.RouteMessage
want RouteEntry
fail bool
}
testCases := []testCase{
{
name: "BasicIPv4",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{
ip4("1.2.3.4"), // dst
ip4("1.2.3.1"), // gateway
ip4("255.255.255.0"), // netmask
},
},
want: RouteEntry{
Family: 4,
Type: RouteTypeUnicast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.4/24")},
Gateway: netip.MustParseAddr("1.2.3.1"),
Sys: RouteEntryBSD{},
},
},
{
name: "BasicIPv6",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{
ip6("fd7a:115c:a1e0::"), // dst
ip6("1234::"), // gateway
ip6("ffff:ffff:ffff::"), // netmask
},
},
want: RouteEntry{
Family: 6,
Type: RouteTypeUnicast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("fd7a:115c:a1e0::/48")},
Gateway: netip.MustParseAddr("1234::"),
Sys: RouteEntryBSD{},
},
},
{
name: "IPv6WithZone",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{
ip6zone("fe80::", 2), // dst
ip6("1234::"), // gateway
ip6("ffff:ffff:ffff:ffff::"), // netmask
},
},
want: RouteEntry{
Family: 6,
Type: RouteTypeUnicast, // TODO
Dst: RouteDestination{Prefix: netip.MustParsePrefix("fe80::/64"), Zone: "tailscale0"},
Gateway: netip.MustParseAddr("1234::"),
Sys: RouteEntryBSD{},
},
},
{
name: "IPv6WithUnknownZone",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{
ip6zone("fe80::", 4), // dst
ip6("1234::"), // gateway
ip6("ffff:ffff:ffff:ffff::"), // netmask
},
},
want: RouteEntry{
Family: 6,
Type: RouteTypeUnicast, // TODO
Dst: RouteDestination{Prefix: netip.MustParsePrefix("fe80::/64"), Zone: "4"},
Gateway: netip.MustParseAddr("1234::"),
Sys: RouteEntryBSD{},
},
},
{
name: "DefaultIPv4",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{
ip4("0.0.0.0"), // dst
ip4("1.2.3.4"), // gateway
ip4("0.0.0.0"), // netmask
},
},
want: RouteEntry{
Family: 4,
Type: RouteTypeUnicast,
Dst: defaultRouteIPv4,
Gateway: netip.MustParseAddr("1.2.3.4"),
Sys: RouteEntryBSD{},
},
},
{
name: "DefaultIPv6",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{
ip6("0::"), // dst
ip6("1234::"), // gateway
ip6("0::"), // netmask
},
},
want: RouteEntry{
Family: 6,
Type: RouteTypeUnicast,
Dst: defaultRouteIPv6,
Gateway: netip.MustParseAddr("1234::"),
Sys: RouteEntryBSD{},
},
},
{
name: "ShortAddrs",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{
ip4("1.2.3.4"), // dst
},
},
want: RouteEntry{
Family: 4,
Type: RouteTypeUnicast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.4/32")},
Sys: RouteEntryBSD{},
},
},
{
name: "TailscaleIPv4",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{
ip4("100.64.0.0"), // dst
link(2, ""),
ip4("255.192.0.0"), // netmask
},
},
want: RouteEntry{
Family: 4,
Type: RouteTypeUnicast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("100.64.0.0/10")},
Sys: RouteEntryBSD{
GatewayInterface: "tailscale0",
GatewayIdx: 2,
},
},
},
{
name: "Flags",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{
ip4("1.2.3.4"), // dst
ip4("1.2.3.1"), // gateway
ip4("255.255.255.0"), // netmask
},
Flags: unix.RTF_STATIC | unix.RTF_GATEWAY | unix.RTF_UP,
},
want: RouteEntry{
Family: 4,
Type: RouteTypeUnicast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.4/24")},
Gateway: netip.MustParseAddr("1.2.3.1"),
Sys: RouteEntryBSD{
Flags: []string{"gateway", "static", "up"},
RawFlags: unix.RTF_STATIC | unix.RTF_GATEWAY | unix.RTF_UP,
},
},
},
{
name: "SkipNoAddrs",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{},
},
fail: true,
},
{
name: "SkipBadVersion",
msg: &route.RouteMessage{
Version: 1,
},
fail: true,
},
{
name: "SkipBadType",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType + 1,
},
fail: true,
},
{
name: "OutputIface",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Index: 1,
Addrs: []route.Addr{
ip4("1.2.3.4"), // dst
},
},
want: RouteEntry{
Family: 4,
Type: RouteTypeUnicast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.4/32")},
Interface: "iface0",
Sys: RouteEntryBSD{},
},
},
{
name: "GatewayMAC",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{
ip4("100.64.0.0"), // dst
link(1, "01:02:03:04:05:06"),
ip4("255.192.0.0"), // netmask
},
},
want: RouteEntry{
Family: 4,
Type: RouteTypeUnicast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("100.64.0.0/10")},
Sys: RouteEntryBSD{
GatewayAddr: "01:02:03:04:05:06",
GatewayInterface: "iface0",
GatewayIdx: 1,
},
},
},
}
if runtime.GOOS == "darwin" {
testCases = append(testCases,
testCase{
name: "SkipFlags",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Addrs: []route.Addr{
ip4("1.2.3.4"), // dst
ip4("1.2.3.1"), // gateway
ip4("255.255.255.0"), // netmask
},
Flags: unix.RTF_UP | skipFlags,
},
fail: true,
},
testCase{
name: "NetmaskAdjust",
msg: &route.RouteMessage{
Version: 3,
Type: rmExpectedType,
Flags: unix.RTF_MULTICAST,
Addrs: []route.Addr{
ip6("ff00::"), // dst
ip6("1234::"), // gateway
ip6("ffff:ffff:ff00::"), // netmask
},
},
want: RouteEntry{
Family: 6,
Type: RouteTypeMulticast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("ff00::/8")},
Gateway: netip.MustParseAddr("1234::"),
Sys: RouteEntryBSD{
Flags: []string{"multicast"},
RawFlags: unix.RTF_MULTICAST,
},
},
},
)
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
re, ok := routeEntryFromMsg(ifs, tc.msg)
if wantOk := !tc.fail; ok != wantOk {
t.Fatalf("ok = %v; want %v", ok, wantOk)
}
if !reflect.DeepEqual(re, tc.want) {
t.Fatalf("RouteEntry mismatch:\n got: %+v\nwant: %+v", re, tc.want)
}
})
}
}
func TestRouteEntryFormatting(t *testing.T) {
testCases := []struct {
re RouteEntry
want string
}{
{
re: RouteEntry{
Family: 4,
Type: RouteTypeUnicast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.0/24")},
Interface: "en0",
Sys: RouteEntryBSD{
GatewayInterface: "en0",
Flags: []string{"static", "up"},
},
},
want: `{Family: IPv4, Dst: 1.2.3.0/24, Interface: en0, Sys: {GatewayInterface: en0, Flags: [static up]}}`,
},
{
re: RouteEntry{
Family: 6,
Type: RouteTypeUnicast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("fd7a:115c:a1e0::/24")},
Interface: "en0",
Sys: RouteEntryBSD{
GatewayIdx: 3,
Flags: []string{"static", "up"},
},
},
want: `{Family: IPv6, Dst: fd7a:115c:a1e0::/24, Interface: en0, Sys: {GatewayIdx: 3, Flags: [static up]}}`,
},
}
for _, tc := range testCases {
t.Run("", func(t *testing.T) {
got := fmt.Sprint(tc.re)
if got != tc.want {
t.Fatalf("RouteEntry.String() mismatch\n got: %q\nwant: %q", got, tc.want)
}
})
}
}
func TestGetRouteTable(t *testing.T) {
routes, err := Get(1000)
if err != nil {
t.Fatal(err)
}
// Basic assertion: we have at least one 'default' route
var (
hasDefault bool
)
for _, route := range routes {
if route.Dst == defaultRouteIPv4 || route.Dst == defaultRouteIPv6 {
hasDefault = true
}
}
if !hasDefault {
t.Errorf("expected at least one default route; routes=%v", routes)
}
}

View File

@@ -0,0 +1,33 @@
// 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 darwin
// +build darwin
package routetable
import "golang.org/x/sys/unix"
const (
ribType = unix.NET_RT_DUMP2
parseType = unix.NET_RT_IFLIST2
rmExpectedType = unix.RTM_GET2
// Skip routes that were cloned from a parent
skipFlags = unix.RTF_WASCLONED
)
var flags = map[int]string{
unix.RTF_BLACKHOLE: "blackhole",
unix.RTF_BROADCAST: "broadcast",
unix.RTF_GATEWAY: "gateway",
unix.RTF_GLOBAL: "global",
unix.RTF_HOST: "host",
unix.RTF_IFSCOPE: "ifscope",
unix.RTF_MULTICAST: "multicast",
unix.RTF_REJECT: "reject",
unix.RTF_ROUTER: "router",
unix.RTF_STATIC: "static",
unix.RTF_UP: "up",
}

View File

@@ -0,0 +1,30 @@
// 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 freebsd
// +build freebsd
package routetable
import "golang.org/x/sys/unix"
const (
ribType = unix.NET_RT_DUMP
parseType = unix.NET_RT_IFLIST
rmExpectedType = unix.RTM_GET
// Nothing to skip
skipFlags = 0
)
var flags = map[int]string{
unix.RTF_BLACKHOLE: "blackhole",
unix.RTF_BROADCAST: "broadcast",
unix.RTF_GATEWAY: "gateway",
unix.RTF_HOST: "host",
unix.RTF_MULTICAST: "multicast",
unix.RTF_REJECT: "reject",
unix.RTF_STATIC: "static",
unix.RTF_UP: "up",
}

View File

@@ -0,0 +1,231 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build linux
// +build linux
package routetable
import (
"bufio"
"fmt"
"net/netip"
"strconv"
"github.com/tailscale/netlink"
"golang.org/x/sys/unix"
"tailscale.com/net/interfaces"
"tailscale.com/net/netaddr"
"tailscale.com/types/logger"
)
// RouteEntryLinux is the structure that makes up the Sys field of the
// RouteEntry structure.
type RouteEntryLinux struct {
// Type is the raw type of the route.
Type int
// Table is the routing table index of this route.
Table int
// Src is the source of the route (if any).
Src netip.Addr
// Proto describes the source of the route--i.e. what caused this route
// to be added to the route table.
Proto netlink.RouteProtocol
// Priority is the route's priority.
Priority int
// Scope is the route's scope.
Scope int
// InputInterfaceIdx is the input interface index.
InputInterfaceIdx int
// InputInterfaceName is the input interface name (if available).
InputInterfaceName string
}
// Format implements the fmt.Formatter interface.
func (r RouteEntryLinux) Format(f fmt.State, verb rune) {
logger.ArgWriter(func(w *bufio.Writer) {
// TODO(andrew): should we skip printing anything if type is unicast?
fmt.Fprintf(w, "{Type: %s", r.TypeName())
// Match 'ip route' behaviour when printing these fields
if r.Table != unix.RT_TABLE_MAIN {
fmt.Fprintf(w, ", Table: %s", r.TableName())
}
if r.Proto != unix.RTPROT_BOOT {
fmt.Fprintf(w, ", Proto: %s", r.Proto)
}
if r.Src.IsValid() {
fmt.Fprintf(w, ", Src: %s", r.Src)
}
if r.Priority != 0 {
fmt.Fprintf(w, ", Priority: %d", r.Priority)
}
if r.Scope != unix.RT_SCOPE_UNIVERSE {
fmt.Fprintf(w, ", Scope: %s", r.ScopeName())
}
if r.InputInterfaceName != "" {
fmt.Fprintf(w, ", InputInterfaceName: %s", r.InputInterfaceName)
} else if r.InputInterfaceIdx != 0 {
fmt.Fprintf(w, ", InputInterfaceIdx: %d", r.InputInterfaceIdx)
}
w.WriteString("}")
}).Format(f, verb)
}
// TypeName returns the string representation of this route's Type.
func (r RouteEntryLinux) TypeName() string {
switch r.Type {
case unix.RTN_UNSPEC:
return "none"
case unix.RTN_UNICAST:
return "unicast"
case unix.RTN_LOCAL:
return "local"
case unix.RTN_BROADCAST:
return "broadcast"
case unix.RTN_ANYCAST:
return "anycast"
case unix.RTN_MULTICAST:
return "multicast"
case unix.RTN_BLACKHOLE:
return "blackhole"
case unix.RTN_UNREACHABLE:
return "unreachable"
case unix.RTN_PROHIBIT:
return "prohibit"
case unix.RTN_THROW:
return "throw"
case unix.RTN_NAT:
return "nat"
case unix.RTN_XRESOLVE:
return "xresolve"
default:
return strconv.Itoa(r.Type)
}
}
// TableName returns the string representation of this route's Table.
func (r RouteEntryLinux) TableName() string {
switch r.Table {
case unix.RT_TABLE_DEFAULT:
return "default"
case unix.RT_TABLE_MAIN:
return "main"
case unix.RT_TABLE_LOCAL:
return "local"
default:
return strconv.Itoa(r.Table)
}
}
// ScopeName returns the string representation of this route's Scope.
func (r RouteEntryLinux) ScopeName() string {
switch r.Scope {
case unix.RT_SCOPE_UNIVERSE:
return "global"
case unix.RT_SCOPE_NOWHERE:
return "nowhere"
case unix.RT_SCOPE_HOST:
return "host"
case unix.RT_SCOPE_LINK:
return "link"
case unix.RT_SCOPE_SITE:
return "site"
default:
return strconv.Itoa(r.Scope)
}
}
// Get returns route entries from the system route table, limited to at most
// max results.
func Get(max int) ([]RouteEntry, error) {
// Fetching the list of interfaces can race with fetching our route
// table, but we do it anyway since it's helpful for debugging.
ifs, err := interfaces.GetList()
if err != nil {
return nil, err
}
ifsByIdx := make(map[int]interfaces.Interface)
for _, iif := range ifs {
ifsByIdx[iif.Index] = iif
}
filter := &netlink.Route{}
routes, err := netlink.RouteListFiltered(netlink.FAMILY_ALL, filter, netlink.RT_FILTER_TABLE)
if err != nil {
return nil, err
}
var ret []RouteEntry
for _, route := range routes {
if route.Family != netlink.FAMILY_V4 && route.Family != netlink.FAMILY_V6 {
continue
}
re := RouteEntry{}
if route.Family == netlink.FAMILY_V4 {
re.Family = 4
} else {
re.Family = 6
}
switch route.Type {
case unix.RTN_UNSPEC:
re.Type = RouteTypeUnspecified
case unix.RTN_UNICAST:
re.Type = RouteTypeUnicast
case unix.RTN_LOCAL:
re.Type = RouteTypeLocal
case unix.RTN_BROADCAST:
re.Type = RouteTypeBroadcast
case unix.RTN_MULTICAST:
re.Type = RouteTypeMulticast
default:
re.Type = RouteTypeOther
}
if route.Dst != nil {
if d, ok := netaddr.FromStdIPNet(route.Dst); ok {
re.Dst = RouteDestination{Prefix: d}
}
} else if route.Family == netlink.FAMILY_V4 {
re.Dst = RouteDestination{Prefix: netip.PrefixFrom(netip.IPv4Unspecified(), 0)}
} else {
re.Dst = RouteDestination{Prefix: netip.PrefixFrom(netip.IPv6Unspecified(), 0)}
}
if gw := route.Gw; gw != nil {
if gwa, ok := netip.AddrFromSlice(gw); ok {
re.Gateway = gwa
}
}
if outif, ok := ifsByIdx[route.LinkIndex]; ok {
re.Interface = outif.Name
} else if route.LinkIndex > 0 {
re.Interface = fmt.Sprintf("link#%d", route.LinkIndex)
}
reSys := RouteEntryLinux{
Type: route.Type,
Table: route.Table,
Proto: route.Protocol,
Priority: route.Priority,
Scope: int(route.Scope),
InputInterfaceIdx: route.ILinkIndex,
}
if src, ok := netip.AddrFromSlice(route.Src); ok {
reSys.Src = src
}
if iif, ok := ifsByIdx[route.ILinkIndex]; ok {
reSys.InputInterfaceName = iif.Name
}
re.Sys = reSys
ret = append(ret, re)
// Stop after we've reached the maximum number of routes
if len(ret) == max {
break
}
}
return ret, nil
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build linux
// +build linux
package routetable
import (
"fmt"
"net/netip"
"testing"
"golang.org/x/sys/unix"
)
func TestGetRouteTable(t *testing.T) {
routes, err := Get(1000)
if err != nil {
t.Fatal(err)
}
// Basic assertion: we have at least one 'default' route in the main table
var (
hasDefault bool
)
for _, route := range routes {
if route.Dst == defaultRouteIPv4 && route.Sys.(RouteEntryLinux).Table == unix.RT_TABLE_MAIN {
hasDefault = true
}
}
if !hasDefault {
t.Errorf("expected at least one default route; routes=%v", routes)
}
}
func TestRouteEntryFormatting(t *testing.T) {
testCases := []struct {
re RouteEntry
want string
}{
{
re: RouteEntry{
Family: 4,
Type: RouteTypeMulticast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("100.64.0.0/10")},
Gateway: netip.MustParseAddr("1.2.3.1"),
Interface: "tailscale0",
Sys: RouteEntryLinux{
Type: unix.RTN_UNICAST,
Table: 52,
Proto: unix.RTPROT_STATIC,
Src: netip.MustParseAddr("1.2.3.4"),
Priority: 555,
},
},
want: `{Family: IPv4, Type: multicast, Dst: 100.64.0.0/10, Gateway: 1.2.3.1, Interface: tailscale0, Sys: {Type: unicast, Table: 52, Proto: static, Src: 1.2.3.4, Priority: 555}}`,
},
{
re: RouteEntry{
Family: 4,
Type: RouteTypeUnicast,
Dst: RouteDestination{Prefix: netip.MustParsePrefix("1.2.3.0/24")},
Gateway: netip.MustParseAddr("1.2.3.1"),
Sys: RouteEntryLinux{
Type: unix.RTN_UNICAST,
Table: unix.RT_TABLE_MAIN,
Proto: unix.RTPROT_BOOT,
},
},
want: `{Family: IPv4, Dst: 1.2.3.0/24, Gateway: 1.2.3.1, Sys: {Type: unicast}}`,
},
}
for _, tc := range testCases {
t.Run("", func(t *testing.T) {
got := fmt.Sprint(tc.re)
if got != tc.want {
t.Fatalf("RouteEntry.String() = %q; want %q", got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !linux && !darwin && !freebsd
package routetable
import (
"errors"
"runtime"
)
var errUnsupported = errors.New("cannot get route table on platform " + runtime.GOOS)
func Get(max int) ([]RouteEntry, error) {
return nil, errUnsupported
}

View File

@@ -11,11 +11,11 @@ import (
)
const (
blockSize = 32000 // size of the block of data to send
blockSize = 2 * 1024 * 1024 // size of the block of data to send
MinDuration = 5 * time.Second // minimum duration for a test
DefaultDuration = MinDuration // default duration for a test
MaxDuration = 30 * time.Second // maximum duration for a test
version = 1 // value used when comparing client and server versions
version = 2 // value used when comparing client and server versions
increment = time.Second // increment to display results for, in seconds
minInterval = 10 * time.Millisecond // minimum interval length for a result to be included
DefaultPort = 20333
@@ -37,14 +37,14 @@ type configResponse struct {
// This represents the Result of a speedtest within a specific interval
type Result struct {
Bytes int // number of bytes sent/received during the interval
IntervalStart time.Duration // duration between the start of the interval and the start of the test
IntervalEnd time.Duration // duration between the end of the interval and the start of the test
Total bool // if true, this result struct represents the entire test, rather than a segment of the test
Bytes int // number of bytes sent/received during the interval
IntervalStart time.Time // start of the interval
IntervalEnd time.Time // end of the interval
Total bool // if true, this result struct represents the entire test, rather than a segment of the test
}
func (r Result) MBitsPerSecond() float64 {
return r.MegaBits() / (r.IntervalEnd - r.IntervalStart).Seconds()
return r.MegaBits() / r.IntervalEnd.Sub(r.IntervalStart).Seconds()
}
func (r Result) MegaBytes() float64 {
@@ -56,7 +56,7 @@ func (r Result) MegaBits() float64 {
}
func (r Result) Interval() time.Duration {
return r.IntervalEnd - r.IntervalStart
return r.IntervalEnd.Sub(r.IntervalStart)
}
type Direction int

View File

@@ -81,9 +81,6 @@ func doTest(conn net.Conn, conf config) ([]Result, error) {
var currentTime time.Time
var results []Result
startTime := time.Now()
lastCalculated := startTime
if conf.Direction == Download {
conn.SetReadDeadline(time.Now().Add(conf.TestDuration).Add(5 * time.Second))
} else {
@@ -94,6 +91,9 @@ func doTest(conn net.Conn, conf config) ([]Result, error) {
}
startTime := time.Now()
lastCalculated := startTime
SpeedTestLoop:
for {
var n int
@@ -110,48 +110,37 @@ SpeedTestLoop:
return nil, fmt.Errorf("unexpected error has occurred: %w", err)
}
} else {
// Need to change the data a little bit, to avoid any compression.
for i := range bufferData {
bufferData[i]++
}
n, err = conn.Write(bufferData)
if err != nil {
// If the write failed, there is most likely something wrong with the connection.
return nil, fmt.Errorf("upload failed: %w", err)
}
}
currentTime = time.Now()
intervalBytes += n
currentTime = time.Now()
// checks if the current time is more or equal to the lastCalculated time plus the increment
if currentTime.After(lastCalculated.Add(increment)) {
intervalStart := lastCalculated.Sub(startTime)
intervalEnd := currentTime.Sub(startTime)
if (intervalEnd - intervalStart) > minInterval {
results = append(results, Result{Bytes: intervalBytes, IntervalStart: intervalStart, IntervalEnd: intervalEnd, Total: false})
}
if currentTime.Sub(lastCalculated) >= increment {
results = append(results, Result{Bytes: intervalBytes, IntervalStart: lastCalculated, IntervalEnd: currentTime, Total: false})
lastCalculated = currentTime
totalBytes += intervalBytes
intervalBytes = 0
}
if conf.Direction == Upload && time.Since(startTime) > conf.TestDuration {
if conf.Direction == Upload && currentTime.Sub(startTime) > conf.TestDuration {
break SpeedTestLoop
}
}
// get last segment
intervalStart := lastCalculated.Sub(startTime)
intervalEnd := currentTime.Sub(startTime)
if (intervalEnd - intervalStart) > minInterval {
results = append(results, Result{Bytes: intervalBytes, IntervalStart: intervalStart, IntervalEnd: intervalEnd, Total: false})
if currentTime.Sub(lastCalculated) > minInterval {
results = append(results, Result{Bytes: intervalBytes, IntervalStart: lastCalculated, IntervalEnd: currentTime, Total: false})
}
// get total
totalBytes += intervalBytes
intervalEnd = currentTime.Sub(startTime)
if intervalEnd > minInterval {
results = append(results, Result{Bytes: totalBytes, IntervalStart: 0, IntervalEnd: intervalEnd, Total: true})
if currentTime.Sub(startTime) > minInterval {
results = append(results, Result{Bytes: totalBytes, IntervalStart: startTime, IntervalEnd: currentTime, Total: true})
}
return results, nil

View File

@@ -7,6 +7,7 @@ package speedtest
import (
"net"
"testing"
"time"
)
func TestDownload(t *testing.T) {
@@ -23,9 +24,9 @@ func TestDownload(t *testing.T) {
type state struct {
err error
}
displayResult := func(t *testing.T, r Result) {
displayResult := func(t *testing.T, r Result, start time.Time) {
t.Helper()
t.Logf("{ Megabytes: %.2f, Start: %.1f, End: %.1f, Total: %t }", r.MegaBytes(), r.IntervalStart.Seconds(), r.IntervalEnd.Seconds(), r.Total)
t.Logf("{ Megabytes: %.2f, Start: %.1f, End: %.1f, Total: %t }", r.MegaBytes(), r.IntervalStart.Sub(start).Seconds(), r.IntervalEnd.Sub(start).Seconds(), r.Total)
}
stateChan := make(chan state, 1)
@@ -49,8 +50,9 @@ func TestDownload(t *testing.T) {
t.Fatalf("download results: expected length: %d, actual length: %d", expectedLen, len(results))
}
start := results[0].IntervalStart
for _, result := range results {
displayResult(t, result)
displayResult(t, result, start)
}
})
@@ -66,8 +68,9 @@ func TestDownload(t *testing.T) {
t.Fatalf("upload results: expected length: %d, actual length: %d", expectedLen, len(results))
}
start := results[0].IntervalStart
for _, result := range results {
displayResult(t, result)
displayResult(t, result, start)
}
})

View File

@@ -79,7 +79,10 @@ type CapabilityVersion int
// - 40: 2022-08-22: added Node.KeySignature, PeersChangedPatch.KeySignature
// - 41: 2022-08-30: uses 100.100.100.100 for route-less ExtraRecords if global nameservers is set
// - 42: 2022-09-06: NextDNS DoH support; see https://github.com/tailscale/tailscale/pull/5556
const CurrentCapabilityVersion CapabilityVersion = 42
// - 43: 2022-09-21: clients can return usernames for SSH
// - 44: 2022-09-22: MapResponse.ControlDialPlan
// - 45: 2022-09-26: c2n /debug/{goroutines,prefs,metrics}
const CurrentCapabilityVersion CapabilityVersion = 45
type StableID string
@@ -234,6 +237,9 @@ type Node struct {
ComputedName string `json:",omitempty"` // MagicDNS base name (for normal non-shared-in nodes), FQDN (without trailing dot, for shared-in nodes), or Hostname (if no MagicDNS)
computedHostIfDifferent string // hostname, if different than ComputedName, otherwise empty
ComputedNameWithHost string `json:",omitempty"` // either "ComputedName" or "ComputedName (computedHostIfDifferent)", if computedHostIfDifferent is set
// DataPlaneAuditLogID is the per-node logtail ID used for data plane audit logging.
DataPlaneAuditLogID string `json:",omitempty"`
}
// DisplayName returns the user-facing name for a node which should
@@ -1372,9 +1378,47 @@ type MapResponse struct {
// indicates no change from the value sent earlier.
TKAInfo *TKAInfo `json:",omitempty"`
// DomainDataPlaneAuditLogID, if non-empty, is the per-tailnet log ID to be
// used when writing data plane audit logs.
DomainDataPlaneAuditLogID string `json:",omitempty"`
// Debug is normally nil, except for when the control server
// is setting debug settings on a node.
Debug *Debug `json:",omitempty"`
// ControlDialPlan tells the client how to connect to the control
// server. An initial nil is equivalent to new(ControlDialPlan).
// A subsequent streamed nil means no change.
ControlDialPlan *ControlDialPlan `json:",omitempty"`
}
// ControlDialPlan is instructions from the control server to the client on how
// to connect to the control server; this is useful for maintaining connection
// if the client's network state changes after the initial connection, or due
// to the configuration that the control server pushes.
type ControlDialPlan struct {
// An empty list means the default: use DNS (unspecified which DNS).
Candidates []ControlIPCandidate
}
// ControlIPCandidate represents a single candidate address to use when
// connecting to the control server.
type ControlIPCandidate struct {
// IP is the address to attempt connecting to.
IP netip.Addr
// DialStartSec is the number of seconds after the beginning of the
// connection process to wait before trying this candidate.
DialStartDelaySec float64 `json:",omitempty"`
// DialTimeoutSec is the timeout for a connection to this candidate,
// starting after DialStartDelaySec.
DialTimeoutSec float64 `json:",omitempty"`
// Priority is the relative priority of this candidate; candidates with
// a higher priority are preferred over candidates with a lower
// priority.
Priority int `json:",omitempty"`
}
// Debug are instructions from the control server to the client
@@ -1641,6 +1685,13 @@ type SetDNSRequest struct {
// SetDNSResponse is the response to a SetDNSRequest.
type SetDNSResponse struct{}
// HealthChangeRequest is the JSON request body type used to report
// node health changes to https://<control>/machine/<mkey hex>/update-health.
type HealthChangeRequest struct {
Subsys string // a health.Subsystem value in string form
Error string // or empty if cleared
}
// SSHPolicy is the policy for how to handle incoming SSH connections
// over Tailscale.
type SSHPolicy struct {

View File

@@ -95,6 +95,7 @@ var _NodeCloneNeedsRegeneration = Node(struct {
ComputedName string
computedHostIfDifferent string
ComputedNameWithHost string
DataPlaneAuditLogID string
}{})
// Clone makes a deep copy of Hostinfo.

View File

@@ -332,6 +332,7 @@ func TestNodeEqual(t *testing.T) {
"LastSeen", "Online", "KeepAlive", "MachineAuthorized",
"Capabilities",
"ComputedName", "computedHostIfDifferent", "ComputedNameWithHost",
"DataPlaneAuditLogID",
}
if have := fieldsOf(reflect.TypeOf(Node{})); !reflect.DeepEqual(have, nodeHandles) {
t.Errorf("Node.Equal check might be out of sync\nfields: %q\nhandled: %q\n",

View File

@@ -173,6 +173,7 @@ func (v NodeView) MachineAuthorized() bool { return v.ж.MachineAuthor
func (v NodeView) Capabilities() views.Slice[string] { return views.SliceOf(v.ж.Capabilities) }
func (v NodeView) ComputedName() string { return v.ж.ComputedName }
func (v NodeView) ComputedNameWithHost() string { return v.ж.ComputedNameWithHost }
func (v NodeView) DataPlaneAuditLogID() string { return v.ж.DataPlaneAuditLogID }
func (v NodeView) Equal(v2 NodeView) bool { return v.ж.Equal(v2.ж) }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
@@ -203,6 +204,7 @@ var _NodeViewNeedsRegeneration = Node(struct {
ComputedName string
computedHostIfDifferent string
ComputedNameWithHost string
DataPlaneAuditLogID string
}{})
// View returns a readonly view of Hostinfo.

View File

@@ -12,9 +12,11 @@ import (
// TKAInitBeginRequest submits a genesis AUM to seed the creation of the
// tailnet's key authority.
type TKAInitBeginRequest struct {
// NodeID is the node of the initiating client.
// It must match the machine key being used to communicate over noise.
NodeID NodeID
// Version is the client's capabilities.
Version CapabilityVersion
// NodeKey is the client's current node key.
NodeKey key.NodePublic
// GenesisAUM is the initial (genesis) AUM that the node generated
// to bootstrap tailnet key authority state.
@@ -58,8 +60,11 @@ type TKAInitBeginResponse struct {
// This RPC finalizes initialization of the tailnet key authority
// by submitting node-key signatures for all existing nodes.
type TKAInitFinishRequest struct {
// NodeID is the node ID of the initiating client.
NodeID NodeID
// Version is the client's capabilities.
Version CapabilityVersion
// NodeKey is the client's current node key.
NodeKey key.NodePublic
// Signatures are serialized tka.NodeKeySignatures for all nodes
// in the tailnet.
@@ -101,6 +106,12 @@ type TKAInfo struct {
// TKABootstrapRequest is sent by a node to get information necessary for
// enabling or disabling the tailnet key authority.
type TKABootstrapRequest struct {
// Version is the client's capabilities.
Version CapabilityVersion
// NodeKey is the client's current node key.
NodeKey key.NodePublic
// Head represents the node's head AUMHash (tka.Authority.Head), if
// network lock is enabled.
Head string
@@ -120,6 +131,12 @@ type TKABootstrapResponse struct {
// state (TKA). Values of type tka.AUMHash are encoded as strings in their
// MarshalText form.
type TKASyncOfferRequest struct {
// Version is the client's capabilities.
Version CapabilityVersion
// NodeKey is the client's current node key.
NodeKey key.NodePublic
// Head represents the node's head AUMHash (tka.Authority.Head). This
// corresponds to tka.SyncOffer.Head.
Head string
@@ -147,6 +164,12 @@ type TKASyncOfferResponse struct {
// TKASyncSendRequest encodes AUMs that a node believes the control plane
// is missing.
type TKASyncSendRequest struct {
// Version is the client's capabilities.
Version CapabilityVersion
// NodeKey is the client's current node key.
NodeKey key.NodePublic
// MissingAUMs encodes AUMs that the node believes the control plane
// is missing.
MissingAUMs []tkatype.MarshaledAUM

View File

@@ -45,6 +45,11 @@ func (h AUMHash) MarshalText() ([]byte, error) {
return b, nil
}
// IsZero returns true if the hash is the empty value.
func (h AUMHash) IsZero() bool {
return h == (AUMHash{})
}
// AUMKind describes valid AUM types.
type AUMKind uint8

View File

@@ -6,6 +6,7 @@ package tka
import (
"fmt"
"os"
"tailscale.com/types/tkatype"
)
@@ -92,8 +93,62 @@ func (b *UpdateBuilder) SetKeyMeta(keyID tkatype.KeyID, meta map[string]string)
return b.mkUpdate(AUM{MessageKind: AUMUpdateKey, Meta: meta, KeyID: keyID})
}
func (b *UpdateBuilder) generateCheckpoint() error {
// Compute the checkpoint state.
state := b.a.state
for i, update := range b.out {
var err error
if state, err = state.applyVerifiedAUM(update); err != nil {
return fmt.Errorf("applying update %d: %v", i, err)
}
}
// Checkpoints cant specify a parent AUM.
state.LastAUMHash = nil
return b.mkUpdate(AUM{MessageKind: AUMCheckpoint, State: &state})
}
// checkpointEvery sets how often a checkpoint AUM should be generated.
const checkpointEvery = 50
// Finalize returns the set of update message to actuate the update.
func (b *UpdateBuilder) Finalize() ([]AUM, error) {
func (b *UpdateBuilder) Finalize(storage Chonk) ([]AUM, error) {
var (
needCheckpoint bool = true
cursor AUMHash = b.a.Head()
)
for i := len(b.out); i < checkpointEvery; i++ {
aum, err := storage.AUM(cursor)
if err != nil {
if err == os.ErrNotExist {
// The available chain is shorter than the interval to checkpoint at.
needCheckpoint = false
break
}
return nil, fmt.Errorf("reading AUM: %v", err)
}
if aum.MessageKind == AUMCheckpoint {
needCheckpoint = false
break
}
parent, hasParent := aum.Parent()
if !hasParent {
// We've hit the genesis update, so the chain is shorter than the interval to checkpoint at.
needCheckpoint = false
break
}
cursor = parent
}
if needCheckpoint {
if err := b.generateCheckpoint(); err != nil {
return nil, fmt.Errorf("generating checkpoint: %v", err)
}
}
// Check no AUMs were applied in the meantime
if len(b.out) > 0 {
if parent, _ := b.out[0].Parent(); parent != b.a.Head() {
return nil, fmt.Errorf("updates no longer apply to head: based on %x but head is %x", parent, b.a.Head())

View File

@@ -31,7 +31,7 @@ func TestAuthorityBuilderAddKey(t *testing.T) {
storage := &Mem{}
a, _, err := Create(storage, State{
Keys: []Key{key},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}, signer25519(priv))
if err != nil {
t.Fatalf("Create() failed: %v", err)
@@ -44,7 +44,7 @@ func TestAuthorityBuilderAddKey(t *testing.T) {
if err := b.AddKey(key2); err != nil {
t.Fatalf("AddKey(%v) failed: %v", key2, err)
}
updates, err := b.Finalize()
updates, err := b.Finalize(storage)
if err != nil {
t.Fatalf("Finalize() failed: %v", err)
}
@@ -68,7 +68,7 @@ func TestAuthorityBuilderRemoveKey(t *testing.T) {
storage := &Mem{}
a, _, err := Create(storage, State{
Keys: []Key{key, key2},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}, signer25519(priv))
if err != nil {
t.Fatalf("Create() failed: %v", err)
@@ -78,7 +78,7 @@ func TestAuthorityBuilderRemoveKey(t *testing.T) {
if err := b.RemoveKey(key2.ID()); err != nil {
t.Fatalf("RemoveKey(%v) failed: %v", key2, err)
}
updates, err := b.Finalize()
updates, err := b.Finalize(storage)
if err != nil {
t.Fatalf("Finalize() failed: %v", err)
}
@@ -100,7 +100,7 @@ func TestAuthorityBuilderSetKeyVote(t *testing.T) {
storage := &Mem{}
a, _, err := Create(storage, State{
Keys: []Key{key},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}, signer25519(priv))
if err != nil {
t.Fatalf("Create() failed: %v", err)
@@ -110,7 +110,7 @@ func TestAuthorityBuilderSetKeyVote(t *testing.T) {
if err := b.SetKeyVote(key.ID(), 5); err != nil {
t.Fatalf("SetKeyVote(%v) failed: %v", key.ID(), err)
}
updates, err := b.Finalize()
updates, err := b.Finalize(storage)
if err != nil {
t.Fatalf("Finalize() failed: %v", err)
}
@@ -136,7 +136,7 @@ func TestAuthorityBuilderSetKeyMeta(t *testing.T) {
storage := &Mem{}
a, _, err := Create(storage, State{
Keys: []Key{key},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}, signer25519(priv))
if err != nil {
t.Fatalf("Create() failed: %v", err)
@@ -146,7 +146,7 @@ func TestAuthorityBuilderSetKeyMeta(t *testing.T) {
if err := b.SetKeyMeta(key.ID(), map[string]string{"b": "c"}); err != nil {
t.Fatalf("SetKeyMeta(%v) failed: %v", key, err)
}
updates, err := b.Finalize()
updates, err := b.Finalize(storage)
if err != nil {
t.Fatalf("Finalize() failed: %v", err)
}
@@ -172,7 +172,7 @@ func TestAuthorityBuilderMultiple(t *testing.T) {
storage := &Mem{}
a, _, err := Create(storage, State{
Keys: []Key{key},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}, signer25519(priv))
if err != nil {
t.Fatalf("Create() failed: %v", err)
@@ -191,7 +191,7 @@ func TestAuthorityBuilderMultiple(t *testing.T) {
if err := b.RemoveKey(key.ID()); err != nil {
t.Fatalf("RemoveKey(%v) failed: %v", key, err)
}
updates, err := b.Finalize()
updates, err := b.Finalize(storage)
if err != nil {
t.Fatalf("Finalize() failed: %v", err)
}
@@ -212,3 +212,60 @@ func TestAuthorityBuilderMultiple(t *testing.T) {
t.Errorf("GetKey(key).err = %v, want %v", err, ErrNoSuchKey)
}
}
func TestAuthorityBuilderCheckpointsAfterXUpdates(t *testing.T) {
pub, priv := testingKey25519(t, 1)
key := Key{Kind: Key25519, Public: pub, Votes: 2}
storage := &Mem{}
a, _, err := Create(storage, State{
Keys: []Key{key},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}, signer25519(priv))
if err != nil {
t.Fatalf("Create() failed: %v", err)
}
for i := 0; i <= checkpointEvery; i++ {
pub2, _ := testingKey25519(t, int64(i+2))
key2 := Key{Kind: Key25519, Public: pub2, Votes: 1}
b := a.NewUpdater(signer25519(priv))
if err := b.AddKey(key2); err != nil {
t.Fatalf("AddKey(%v) failed: %v", key2, err)
}
updates, err := b.Finalize(storage)
if err != nil {
t.Fatalf("Finalize() failed: %v", err)
}
// See if the update is valid by applying it to the authority
// + checking if the new key is there.
if err := a.Inform(storage, updates); err != nil {
t.Fatalf("could not apply generated updates: %v", err)
}
if _, err := a.state.GetKey(key2.ID()); err != nil {
t.Fatal(err)
}
wantKind := AUMAddKey
if i == checkpointEvery-1 { // Genesis + 49 updates == 50 (the value of checkpointEvery)
wantKind = AUMCheckpoint
}
lastAUM, err := storage.AUM(a.Head())
if err != nil {
t.Fatal(err)
}
if lastAUM.MessageKind != wantKind {
t.Errorf("[%d] HeadAUM.MessageKind = %v, want %v", i, lastAUM.MessageKind, wantKind)
}
}
// Try starting an authority just based on storage.
a2, err := Open(storage)
if err != nil {
t.Fatalf("Failed to open from stored AUMs: %v", err)
}
if a.Head() != a2.Head() {
t.Errorf("stored and computed HEAD differ: got %v, want %v", a2.Head(), a.Head())
}
}

View File

@@ -169,7 +169,7 @@ func testScenario(t *testing.T, sharedChain string, sharedOptions ...testchainOp
sharedOptions = append(sharedOptions,
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
Keys: []Key{key},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}}),
optKey("key", key, priv),
optSignAllUsing("key"))

View File

@@ -226,7 +226,7 @@ func TestSigCredential(t *testing.T) {
a, _ := Open(newTestchain(t, "G1\nG1.template = genesis",
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
Keys: []Key{k},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}})).Chonk())
if err := a.NodeKeyAuthorized(node.Public(), nestedSig.Serialize()); err == nil {
t.Error("NodeKeyAuthorized(SigCredential, node) did not fail")

View File

@@ -93,7 +93,13 @@ const disablementLength = 32
var disablementSalt = []byte("tailscale network-lock disablement salt")
func disablementKDF(secret []byte) []byte {
// DisablementKDF computes a public value which can be stored in a
// key authority, but cannot be reversed to find the input secret.
//
// When the output of this function is stored in tka state (i.e. in
// tka.State.DisablementSecrets) a call to Authority.ValidDisablement()
// with the input of this function as the argument will return true.
func DisablementKDF(secret []byte) []byte {
// time = 4 (3 recommended, booped to 4 to compensate for less memory)
// memory = 16 (32 recommended)
// threads = 4
@@ -103,7 +109,7 @@ func disablementKDF(secret []byte) []byte {
// checkDisablement returns true for a valid disablement secret.
func (s State) checkDisablement(secret []byte) bool {
derived := disablementKDF(secret)
derived := DisablementKDF(secret)
for _, candidate := range s.DisablementSecrets {
if bytes.Equal(derived, candidate) {
return true

View File

@@ -342,7 +342,7 @@ func TestSyncSimpleE2E(t *testing.T) {
`,
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
Keys: []Key{key},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}}),
optKey("key", key, priv),
optSignAllUsing("key"))

View File

@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"os"
"reflect"
"sort"
"github.com/fxamacker/cbor/v2"
@@ -181,6 +182,18 @@ func advanceByPrimary(state State, candidates []AUM) (next *AUM, out State, err
}
aum := pickNextAUM(state, candidates)
// TODO(tom): Remove this before GA, this is just a correctness check during implementation.
// Post-GA, we want clients to not error if they dont recognize additional fields in State.
if aum.MessageKind == AUMCheckpoint {
dupe := state
dupe.LastAUMHash = nil
// aum.State is non-nil (see aum.StaticValidate).
if !reflect.DeepEqual(dupe, *aum.State) {
return nil, State{}, errors.New("checkpoint includes changes not represented in earlier AUMs")
}
}
if state, err = state.applyVerifiedAUM(aum); err != nil {
return nil, State{}, fmt.Errorf("advancing state: %v", err)
}
@@ -244,10 +257,6 @@ func fastForward(storage Chonk, maxIter int, startState State, done func(curAUM
// computeStateAt returns the State at wantHash.
func computeStateAt(storage Chonk, maxIter int, wantHash AUMHash) (State, error) {
// TODO(tom): This is going to get expensive for really long
// chains. We should make nodes emit a checkpoint every
// X updates or something.
topAUM, err := storage.AUM(wantHash)
if err != nil {
return State{}, err

View File

@@ -305,7 +305,7 @@ func TestAuthorityValidDisablement(t *testing.T) {
`,
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
Keys: []Key{key},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}}),
)
@@ -321,7 +321,7 @@ func TestCreateBootstrapAuthority(t *testing.T) {
a1, genesisAUM, err := Create(&Mem{}, State{
Keys: []Key{key},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}, signer25519(priv))
if err != nil {
t.Fatalf("Create() failed: %v", err)
@@ -361,7 +361,7 @@ func TestAuthorityInformNonLinear(t *testing.T) {
`,
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
Keys: []Key{key},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}}),
optKey("key", key, priv),
optSignAllUsing("key"))
@@ -406,7 +406,7 @@ func TestAuthorityInformLinear(t *testing.T) {
`,
optTemplate("genesis", AUM{MessageKind: AUMCheckpoint, State: &State{
Keys: []Key{key},
DisablementSecrets: [][]byte{disablementKDF([]byte{1, 2, 3})},
DisablementSecrets: [][]byte{DisablementKDF([]byte{1, 2, 3})},
}}),
optKey("key", key, priv),
optSignAllUsing("key"))

View File

@@ -810,6 +810,7 @@ func (n *testNode) StartDaemonAsIPNGOOS(ipnGOOS string) *Daemon {
"HTTPS_PROXY="+n.env.TrafficTrapServer.URL,
"TS_DEBUG_TAILSCALED_IPN_GOOS="+ipnGOOS,
"TS_LOGS_DIR="+t.TempDir(),
"TS_NETCHECK_GENERATE_204_URL="+n.env.ControlServer.URL+"/generate_204",
)
cmd.Stderr = &nodeOutputParser{n: n}
if *verboseTailscaled {

View File

@@ -200,6 +200,9 @@ func (s *Server) logf(format string, a ...any) {
func (s *Server) initMux() {
s.mux = http.NewServeMux()
s.mux.HandleFunc("/", s.serveUnhandled)
s.mux.HandleFunc("/generate_204", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
s.mux.HandleFunc("/key", s.serveKey)
s.mux.HandleFunc("/machine/", s.serveMachine)
}

View File

@@ -37,6 +37,7 @@ import (
func init() {
expvar.Publish("process_start_unix_time", expvar.Func(func() any { return timeStart.Unix() }))
expvar.Publish("version", expvar.Func(func() any { return version.Long }))
expvar.Publish("go_version", expvar.Func(func() any { return runtime.Version() }))
expvar.Publish("counter_uptime_sec", expvar.Func(func() any { return int64(Uptime().Seconds()) }))
expvar.Publish("gauge_goroutines", expvar.Func(func() any { return runtime.NumGoroutine() }))
}
@@ -184,9 +185,9 @@ type ReturnHandler interface {
}
type HandlerOptions struct {
Quiet200s bool // if set, do not log successfully handled HTTP requests
Logf logger.Logf
Now func() time.Time // if nil, defaults to time.Now
QuietLoggingIfSuccessful bool // if set, do not log successfully handled HTTP requests (200 and 304 status codes)
Logf logger.Logf
Now func() time.Time // if nil, defaults to time.Now
// If non-nil, StatusCodeCounters maintains counters
// of status codes for handled responses.
@@ -316,7 +317,7 @@ func (h retHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
if msg.Code != 200 || !h.opts.Quiet200s {
if !h.opts.QuietLoggingIfSuccessful || (msg.Code != http.StatusOK && msg.Code != http.StatusNotModified) {
h.opts.Logf("%s", msg)
}

View File

@@ -303,7 +303,7 @@ func BenchmarkLogNot200(b *testing.B) {
// Implicit 200 OK.
return nil
})
h := StdHandler(rh, HandlerOptions{Quiet200s: true})
h := StdHandler(rh, HandlerOptions{QuietLoggingIfSuccessful: true})
req := httptest.NewRequest("GET", "/", nil)
rw := new(httptest.ResponseRecorder)
for i := 0; i < b.N; i++ {

View File

@@ -138,6 +138,8 @@ var rateFree = []string{
"SetPrefs: %v",
"peer keys: %s",
"v%v peers: %v",
// debug messages printed by 'tailscale bugreport'
"diag: ",
}
// RateLimitedFn is a wrapper for RateLimitedFnWithClock that includes the

View File

@@ -14,6 +14,7 @@ import (
"time"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/types/key"
"tailscale.com/wgengine/filter"
)
@@ -61,6 +62,13 @@ type NetworkMap struct {
// check problems.
ControlHealth []string
// TKAEnabled indicates whether the tailnet key authority should be
// enabled, from the perspective of the control plane.
TKAEnabled bool
// TKAHead indicates the control plane's understanding of 'head' (the
// hash of the latest update message to tick through TKA).
TKAHead tka.AUMHash
// ACLs
User tailcfg.UserID

View File

@@ -106,9 +106,20 @@ func (s1 *Sum) xor(s2 Sum) {
}
func (s Sum) String() string {
// Note: if we change this, keep in sync with AppendTo
return hex.EncodeToString(s.sum[:])
}
// AppendTo appends the string encoding of this sum (as returned by the String
// method) to the provided byte slice and returns the extended buffer.
func (s Sum) AppendTo(b []byte) []byte {
// TODO: switch to upstream implementation if accepted:
// https://github.com/golang/go/issues/53693
var lb [len(s.sum) * 2]byte
hex.Encode(lb[:], s.sum[:])
return append(b, lb[:]...)
}
var (
seedOnce sync.Once
seed uint64

View File

@@ -575,7 +575,7 @@ func TestGetTypeHasher(t *testing.T) {
{
name: "tailcfg.Node",
val: &tailcfg.Node{},
out: "\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
out: "\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\tn\x88\xf1\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
},
}
for _, tt := range tests {
@@ -1050,3 +1050,25 @@ func FuzzAddr(f *testing.F) {
}
})
}
func TestAppendTo(t *testing.T) {
v := getVal()
h := Hash(v)
sum := h.AppendTo(nil)
if s := h.String(); s != string(sum) {
t.Errorf("hash sum mismatch; h.String()=%q h.AppendTo()=%q", s, string(sum))
}
}
func BenchmarkAppendTo(b *testing.B) {
b.ReportAllocs()
v := getVal()
h := Hash(v)
hashBuf := make([]byte, 0, 100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
hashBuf = h.AppendTo(hashBuf[:0])
}
}

View File

@@ -0,0 +1,93 @@
// 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.
// The goroutines package contains utilities for getting active goroutines.
package goroutines
import (
"bytes"
"fmt"
"runtime"
"strconv"
)
// ScrubbedGoroutineDump returns the list of all current goroutines, but with the actual
// values of arguments scrubbed out, lest it contain some private key material.
func ScrubbedGoroutineDump() []byte {
var buf []byte
// Grab stacks multiple times into increasingly larger buffer sizes
// to minimize the risk that we blow past our iOS memory limit.
for size := 1 << 10; size <= 1<<20; size += 1 << 10 {
buf = make([]byte, size)
buf = buf[:runtime.Stack(buf, true)]
if len(buf) < size {
// It fit.
break
}
}
return scrubHex(buf)
}
func scrubHex(buf []byte) []byte {
saw := map[string][]byte{} // "0x123" => "v1%3" (unique value 1 and its value mod 8)
foreachHexAddress(buf, func(in []byte) {
if string(in) == "0x0" {
return
}
if v, ok := saw[string(in)]; ok {
for i := range in {
in[i] = '_'
}
copy(in, v)
return
}
inStr := string(in)
u64, err := strconv.ParseUint(string(in[2:]), 16, 64)
for i := range in {
in[i] = '_'
}
if err != nil {
in[0] = '?'
return
}
v := []byte(fmt.Sprintf("v%d%%%d", len(saw)+1, u64%8))
saw[inStr] = v
copy(in, v)
})
return buf
}
var ohx = []byte("0x")
// foreachHexAddress calls f with each subslice of b that matches
// regexp `0x[0-9a-f]*`.
func foreachHexAddress(b []byte, f func([]byte)) {
for len(b) > 0 {
i := bytes.Index(b, ohx)
if i == -1 {
return
}
b = b[i:]
hx := hexPrefix(b)
f(hx)
b = b[len(hx):]
}
}
func hexPrefix(b []byte) []byte {
for i, c := range b {
if i < 2 {
continue
}
if !isHexByte(c) {
return b[:i]
}
}
return b
}
func isHexByte(b byte) bool {
return '0' <= b && b <= '9' || 'a' <= b && b <= 'f' || 'A' <= b && b <= 'F'
}

View File

@@ -2,12 +2,12 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package controlclient
package goroutines
import "testing"
func TestScrubbedGoroutineDump(t *testing.T) {
t.Logf("Got:\n%s\n", scrubbedGoroutineDump())
t.Logf("Got:\n%s\n", ScrubbedGoroutineDump())
}
func TestScrubHex(t *testing.T) {

View File

@@ -1318,6 +1318,9 @@ func (c *Conn) derpWriteChanOfAddr(addr netip.AddrPort, peer key.NodePublic) cha
if !c.wantDerpLocked() || c.closed {
return nil
}
if c.derpMap == nil || c.derpMap.Regions[regionID] == nil {
return nil
}
if c.privateKey.IsZero() {
c.logf("magicsock: DERP lookup of %v with no private key; ignoring", addr)
return nil
@@ -1362,9 +1365,6 @@ func (c *Conn) derpWriteChanOfAddr(addr netip.AddrPort, peer key.NodePublic) cha
c.activeDerp = make(map[int]activeDerp)
c.prevDerp = make(map[int]*syncs.WaitGroupChan)
}
if c.derpMap == nil || c.derpMap.Regions[regionID] == nil {
return nil
}
// Note that derphttp.NewRegionClient does not dial the server
// (it doesn't block) so it is safe to do under the c.mu lock.

View File

@@ -664,7 +664,11 @@ func (ns *Impl) injectInbound(p *packet.Parsed, t *tstun.Wrapper) filter.Respons
}
destIP := p.Dst.Addr()
if p.IsEchoRequest() && ns.ProcessSubnets && !tsaddr.IsTailscaleIP(destIP) {
// If this is an echo request and we're a subnet router, handle pings
// ourselves instead of forwarding the packet on.
pingIP, handlePing := ns.shouldHandlePing(p)
if handlePing {
var pong []byte // the reply to the ping, if our relayed ping works
if destIP.Is4() {
h := p.ICMP4Header()
@@ -675,7 +679,7 @@ func (ns *Impl) injectInbound(p *packet.Parsed, t *tstun.Wrapper) filter.Respons
h.ToResponse()
pong = packet.Generate(&h, p.Payload())
}
go ns.userPing(destIP, pong)
go ns.userPing(pingIP, pong)
return filter.DropSilently
}
@@ -704,6 +708,59 @@ func (ns *Impl) injectInbound(p *packet.Parsed, t *tstun.Wrapper) filter.Respons
return filter.DropSilently
}
// shouldHandlePing returns whether or not netstack should handle an incoming
// ICMP echo request packet, and the IP address that should be pinged from this
// process. The IP address can be different from the destination in the packet
// if the destination is a 4via6 address.
func (ns *Impl) shouldHandlePing(p *packet.Parsed) (_ netip.Addr, ok bool) {
if !p.IsEchoRequest() {
return netip.Addr{}, false
}
destIP := p.Dst.Addr()
// We need to handle pings for all 4via6 addresses, even if this
// netstack instance normally isn't responsible for processing subnets.
//
// For example, on Linux, subnet router traffic could be handled via
// tun+iptables rules for most packets, but we still need to handle
// ICMP echo requests over 4via6 since the host networking stack
// doesn't know what to do with a 4via6 address.
//
// shouldProcessInbound returns 'true' to say that we should process
// all IPv6 packets with a destination address in the 'via' range, so
// check before we check the "ProcessSubnets" boolean below.
if viaRange.Contains(destIP) {
// The input echo request was to a 4via6 address, which we cannot
// simply ping as-is from this process. Translate the destination to an
// IPv4 address, so that our relayed ping (in userPing) is pinging the
// underlying destination IP.
//
// ICMPv4 and ICMPv6 are different protocols with different on-the-wire
// representations, so normally you can't send an ICMPv6 message over
// IPv4 and expect to get a useful result. However, in this specific
// case things are safe because the 'userPing' function doesn't make
// use of the input packet.
return tsaddr.UnmapVia(destIP), true
}
// If we get here, we don't do anything unless this netstack instance
// is responsible for processing subnet traffic.
if !ns.ProcessSubnets {
return netip.Addr{}, false
}
// For non-4via6 addresses, we don't handle pings if they're destined
// for a Tailscale IP.
if tsaddr.IsTailscaleIP(destIP) {
return netip.Addr{}, false
}
// This netstack instance is processing subnet traffic, so handle the
// ping ourselves.
return destIP, true
}
func netaddrIPFromNetstackIP(s tcpip.Address) netip.Addr {
switch len(s) {
case 4:
@@ -930,16 +987,19 @@ func (ns *Impl) acceptUDP(r *udp.ForwarderRequest) {
}
dstAddr, ok := ipPortOfNetstackAddr(sess.LocalAddress, sess.LocalPort)
if !ok {
ep.Close()
return
}
srcAddr, ok := ipPortOfNetstackAddr(sess.RemoteAddress, sess.RemotePort)
if !ok {
ep.Close()
return
}
// Handle magicDNS traffic (via UDP) here.
if dst := dstAddr.Addr(); dst == magicDNSIP || dst == magicDNSIPv6 {
if dstAddr.Port() != 53 {
ep.Close()
return // Only MagicDNS traffic runs on the service IPs for now.
}

View File

@@ -5,6 +5,7 @@
package netstack
import (
"fmt"
"net/netip"
"runtime"
"testing"
@@ -13,6 +14,7 @@ import (
"tailscale.com/net/packet"
"tailscale.com/net/tsdial"
"tailscale.com/net/tstun"
"tailscale.com/types/ipproto"
"tailscale.com/wgengine"
"tailscale.com/wgengine/filter"
)
@@ -84,3 +86,169 @@ func TestNetstackLeakMode(t *testing.T) {
t.Fatalf("refs.leakMode is 0, want a non-zero value")
}
}
func makeNetstack(t *testing.T, config func(*Impl)) *Impl {
tunDev := tstun.NewFake()
dialer := new(tsdial.Dialer)
logf := func(format string, args ...any) {
if !t.Failed() {
t.Helper()
t.Logf(format, args...)
}
}
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Tun: tunDev,
Dialer: dialer,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { eng.Close() })
ig, ok := eng.(wgengine.InternalsGetter)
if !ok {
t.Fatal("not an InternalsGetter")
}
tunWrap, magicSock, dns, ok := ig.GetInternals()
if !ok {
t.Fatal("failed to get internals")
}
ns, err := Create(logf, tunWrap, eng, magicSock, dialer, dns)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ns.Close() })
ns.atomicIsLocalIPFunc.Store(func(netip.Addr) bool { return true })
config(ns)
if err := ns.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
return ns
}
func TestShouldHandlePing(t *testing.T) {
srcIP := netip.AddrFrom4([4]byte{1, 2, 3, 4})
t.Run("ICMP4", func(t *testing.T) {
dst := netip.MustParseAddr("5.6.7.8")
icmph := packet.ICMP4Header{
IP4Header: packet.IP4Header{
IPProto: ipproto.ICMPv4,
Src: srcIP,
Dst: dst,
},
Type: packet.ICMP4EchoRequest,
Code: packet.ICMP4NoCode,
}
_, payload := packet.ICMPEchoPayload(nil)
icmpPing := packet.Generate(icmph, payload)
pkt := &packet.Parsed{}
pkt.Decode(icmpPing)
impl := makeNetstack(t, func(impl *Impl) {
impl.ProcessSubnets = true
})
pingDst, ok := impl.shouldHandlePing(pkt)
if !ok {
t.Errorf("expected shouldHandlePing==true")
}
if pingDst != dst {
t.Errorf("got dst %s; want %s", pingDst, dst)
}
})
t.Run("ICMP6-no-via", func(t *testing.T) {
dst := netip.MustParseAddr("2a09:8280:1::4169")
icmph := packet.ICMP6Header{
IP6Header: packet.IP6Header{
IPProto: ipproto.ICMPv6,
Src: srcIP,
Dst: dst,
},
Type: packet.ICMP6EchoRequest,
Code: packet.ICMP6NoCode,
}
_, payload := packet.ICMPEchoPayload(nil)
icmpPing := packet.Generate(icmph, payload)
pkt := &packet.Parsed{}
pkt.Decode(icmpPing)
impl := makeNetstack(t, func(impl *Impl) {
impl.ProcessSubnets = true
})
pingDst, ok := impl.shouldHandlePing(pkt)
// Expect that we handle this since it's going out onto the
// network.
if !ok {
t.Errorf("expected shouldHandlePing==true")
}
if pingDst != dst {
t.Errorf("got dst %s; want %s", pingDst, dst)
}
})
t.Run("ICMP6-tailscale-addr", func(t *testing.T) {
dst := netip.MustParseAddr("fd7a:115c:a1e0:ab12::1")
icmph := packet.ICMP6Header{
IP6Header: packet.IP6Header{
IPProto: ipproto.ICMPv6,
Src: srcIP,
Dst: dst,
},
Type: packet.ICMP6EchoRequest,
Code: packet.ICMP6NoCode,
}
_, payload := packet.ICMPEchoPayload(nil)
icmpPing := packet.Generate(icmph, payload)
pkt := &packet.Parsed{}
pkt.Decode(icmpPing)
impl := makeNetstack(t, func(impl *Impl) {
impl.ProcessSubnets = true
})
_, ok := impl.shouldHandlePing(pkt)
// We don't handle this because it's a Tailscale IP and not 4via6
if ok {
t.Errorf("expected shouldHandlePing==false")
}
})
// Handle pings for 4via6 addresses regardless of ProcessSubnets
for _, subnets := range []bool{true, false} {
t.Run("ICMP6-4via6-ProcessSubnets-"+fmt.Sprint(subnets), func(t *testing.T) {
// The 4via6 route 10.1.1.0/24 siteid 7, and then the IP
// 10.1.1.9 within that route.
dst := netip.MustParseAddr("fd7a:115c:a1e0:b1a:0:7:a01:109")
expectedPingDst := netip.MustParseAddr("10.1.1.9")
icmph := packet.ICMP6Header{
IP6Header: packet.IP6Header{
IPProto: ipproto.ICMPv6,
Src: srcIP,
Dst: dst,
},
Type: packet.ICMP6EchoRequest,
Code: packet.ICMP6NoCode,
}
_, payload := packet.ICMPEchoPayload(nil)
icmpPing := packet.Generate(icmph, payload)
pkt := &packet.Parsed{}
pkt.Decode(icmpPing)
impl := makeNetstack(t, func(impl *Impl) {
impl.ProcessSubnets = subnets
})
pingDst, ok := impl.shouldHandlePing(pkt)
// Handled due to being 4via6
if !ok {
t.Errorf("expected shouldHandlePing==true")
} else if pingDst != expectedPingDst {
t.Errorf("got dst %s; want %s", pingDst, expectedPingDst)
}
})
}
}