Compare commits

...

69 Commits

Author SHA1 Message Date
Tom DNetto
ea6c4d4fe1 cmd/derper,derp: implement per-client rate limits
Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-09-16 09:53:36 -07:00
Andrew Dunham
cf61070e26 net/dnscache: add better logging to bootstrap DNS path (#5640)
Change-Id: I4cde3a72e06dac18df856a0cfeac10ab7e3a9108
Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2022-09-15 10:41:45 -04:00
Kristoffer Dalby
81574a5c8d portlist: normalise space delimited process names (#5634) 2022-09-15 12:17:31 +02:00
Mihai Parparita
9c6bdae556 cmd/tsconnect: use the parent window for beforeunload event listener
The SSH session may be rendered in a different window that the one that
is executing the script.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-14 11:35:13 -07:00
Mihai Parparita
82e82d9b7a net/dns/resolver: remove unused responseTimeout constant
Timeout is now enforced elsewhere, see discussion in https://github.com/tailscale/tailscale/pull/4408#discussion_r970092333.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-13 18:12:11 -07:00
nyghtowl
0f16640546 net/dns: fix fmt error on Revert print
Fixes #5619

Signed-off-by: nyghtowl <warrick@tailscale.com>
2022-09-13 16:36:15 -07:00
Joe Tsai
aa0064db4d logpolicy: add NewWithConfigPath (#5625)
The version.CmdName implementation is buggy such that it does not correctly
identify the binary name if it embeds other go binaries.
For now, add a NewWithConfigPath API that allows the caller to explicitly
specify this information.

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2022-09-13 16:30:40 -07:00
Brad Fitzpatrick
45a3de14a6 cmd/tailscaled, tailcfg, hostinfo: add flag to disable logging + support
As noted in #5617, our documented method of blocking log.tailscale.io
DNS no longer works due to bootstrap DNS.

Instead, provide an explicit flag (--no-logs-no-support) and/or env
variable (TS_NO_LOGS_NO_SUPPORT=true) to explicitly disable logcatcher
uploads. It also sets a bit on Hostinfo to say that the node is in that
mode so we can end any support tickets from such nodes more quickly.

This does not yet provide an easy mechanism for users on some
platforms (such as Windows, macOS, Synology) to set flags/env. On
Linux you'd used /etc/default/tailscaled typically. Making it easier
to set flags for other platforms is tracked in #5114.

Fixes #5617
Fixes tailscale/corp#1475

Change-Id: I72404e1789f9e56ec47f9b7021b44c025f7a373a
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-13 11:47:36 -07:00
Tom DNetto
f6da2220d3 wgengine: set fwmark masks in netfilter & ip rules
This change masks the bitspace used when setting and querying the fwmark on packets. This allows
tailscaled to play nicer with other networking software on the host, assuming the other networking
software is also using fwmarks & a different mask.

IPTables / mark module has always supported masks, so this is safe on the netfilter front.

However, busybox only gained support for parsing + setting masks in 1.33.0, so we make sure we
arent such a version before we add the "/<mask>" syntax to an ip rule command.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-09-13 09:52:26 -07:00
Mihai Parparita
b22b565947 cmd/tsconnect: allow xterm.js terminal options to be passed in
Allows clients to use a custom theme and other xterm.js customization
options.

Fixes #5610

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-12 16:39:02 -07:00
David Anderson
7c49db02a2 wgengine/magicsock: don't use BPF receive when SO_MARK doesn't work.
Fixes #5607

Signed-off-by: David Anderson <danderson@tailscale.com>
2022-09-12 15:05:44 -07:00
Mihai Parparita
c312e0d264 cmd/tsconnect: allow hostname to be specified
The auto-generated hostname is nice as a default, but there are cases
where the client has a more specific name that it can generate.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-12 14:26:50 -07:00
Mihai Parparita
11fcc3a7b0 cmd/tsconnect: fix xterm.js link opening not working when rendered into another window
The default WebLinksAddon handler uses window.open(), but that gets blocked
by the popup blocker when the event being handled is another window. We
instead need to invoke open() on the window that the event was triggered
in.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-12 13:54:27 -07:00
Will Norris
f03a63910d cmd/tailscale: add licenses link to web UI
The `tailscale web` UI is the primary interface for Synology and Home
Assistant users (and perhaps others), so is the logical place to put our
open source license notices.  I don't love adding things to what is
currently a very minimal UI, but I'm not sure of a better option.

Updates tailscale/corp#5780

Signed-off-by: Will Norris <will@tailscale.com>
2022-09-12 12:06:44 -07:00
Brad Fitzpatrick
024257ef5a net/stun: unmap IPv4 addresses in 16 byte STUN replies
Updates #5602

Change-Id: I2276ad2bfb415b9ff52f37444f2a1d74b38543b1
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-12 12:03:27 -07:00
Andrew Dunham
eb5939289c cmd/derper: add /generate_204 endpoint (#5601)
For captive portal detection.

Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2022-09-12 13:43:50 -04:00
Brad Fitzpatrick
16939f0d56 hostinfo: detect being run in a container in more ways
Change-Id: I038ff7705ba232e6cf8dcc9775357ef708d43762
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-12 07:02:01 -07:00
Brad Fitzpatrick
d5e7e3093d hostinfo, tailcfg: split Hostinfo.OSVersion into separate fields
Stop jamming everything into one string.

Fixes #5578

Change-Id: I7dec8d6c073bddc7dc5f653e3baf2b4bf6b68378
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-11 21:40:28 -07:00
Brad Fitzpatrick
708b7bff3d net/dns/publicdns: also support NextDNS DoH query parameters
The plan has changed. Doing query parameters rather than path +
heades. NextDNS added support for query parameters.

Updates #2452

Change-Id: I4783c0a06d6af90756d9c80a7512644ba702388c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-11 09:01:03 -07:00
Brad Fitzpatrick
81bc4992f2 net/netns: add TS_FORCE_LINUX_BIND_TO_DEVICE for Linux
For debugging a macOS-specific magicsock issue. macOS runs in
bind-to-interface mode always. This lets me force Linux into the same
mode as macOS, even if the Linux kernel supports SO_MARK, as it
usually does.

Updates #2331 etc

Change-Id: Iac9e4a7429c1781337e716ffc914443b7aa2869d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-10 18:33:30 -07:00
Brad Fitzpatrick
f3ce1e2536 util/mak: deprecate NonNil, add type-safe NonNilSliceForJSON, NonNilMapForJSON
And put the rationale in the name too to save the callers the need for a comment.

Change-Id: I090f51b749a5a0641897ee89a8fb2e2080c8b782
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-10 12:19:22 -07:00
Brad Fitzpatrick
e7376aca25 net/dns/resolver: set DNS-over-HTTPS Accept and User-Agent header on requests
Change-Id: I14b821771681e70405a507f43229c694159265ff
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-10 08:57:26 -07:00
Tom DNetto
ed2b8b3e1d wgengine/router: reduce routing rule priority for openWRT + mwan3
Fixes #3659

Signed-off-by: Tom DNetto <tom@tailscale.com>
Co-authored-by: Ian Foster <ian@vorsk.com>
2022-09-09 18:21:24 -07:00
Brad Fitzpatrick
c14361e70e net/dns/publicdns: support NextDNS DoH URLs with path parameters
Updates #2452

Change-Id: I0f1c34cc1672e87e7efd0adfe4088724dd0de3ed
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-09 14:16:23 -07:00
Mihai Parparita
b302742137 cmd/tsconnect: enable web links addon in the terminal
More user friendly, and as a side-effect we handle SSH check mode better,
since the URL that's output is now clickable.

Fixes #5247

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-09 11:05:01 -07:00
Mihai Parparita
62035d6485 cmd/tsconnect: switch back to public version of xterm npm package
xtermjs/xterm.js#4069 was merged and published (in 5.0.0-beta.58),
no need for the fork added by 01e6565e8a.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-09 10:50:43 -07:00
Brad Fitzpatrick
89fee056d3 cmd/derper: add robots.txt to disallow all
Fixes #5565

Change-Id: I5626ec2116d9be451caef651dc301b7a82e35550
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-09 10:29:46 -07:00
License Updater
3ed366ee1e licenses: update android licenses
Signed-off-by: License Updater <noreply@tailscale.com>
2022-09-09 09:14:22 -07:00
Brad Fitzpatrick
2aade349fc net/dns, types/dnstypes: update some comments, tests for DoH
Clarify & verify that some DoH URLs can be sent over tailcfg
in some limited cases.

Updates #2452

Change-Id: Ibb25db77788629c315dc26285a1059a763989e24
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-08 17:16:13 -07:00
Brad Fitzpatrick
58abae1f83 net/dns/{publicdns,resolver}: add NextDNS DoH support
NextDNS is unique in that users create accounts and then get
user-specific DNS IPs & DoH URLs.

For DoH, the customer ID is in the URL path.

For IPv6, the IP address includes the customer ID in the lower bits.

For IPv4, there's a fragile "IP linking" mechanism to associate your
public IPv4 with an assigned NextDNS IPv4 and that tuple maps to your
customer ID.

We don't use the IP linking mechanism.

Instead, NextDNS is DoH-only. Which means using NextDNS necessarily
shunts all DNS traffic through 100.100.100.100 (programming the OS to
use 100.100.100.100 as the global resolver) because operating systems
can't usually do DoH themselves.

Once it's in Tailscale's DoH client, we then connect out to the known
NextDNS IPv4/IPv6 anycast addresses.

If the control plane sends the client a NextDNS IPv6 address, we then
map it to the corresponding NextDNS DoH with the same client ID, and
we dial that DoH server using the combination of v4/v6 anycast IPs.

Updates #2452

Change-Id: I3439d798d21d5fc9df5a2701839910f5bef85463
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-08 12:50:32 -07:00
Mihai Parparita
01e6565e8a cmd/tsconnect: temporarily switch to xterm.js fork that handles popup windows
Allows other work to be unblocked while xtermjs/xterm.js#4069 is worked
through.

To enable testing the popup window handling, the standalone app allows
opening of SSH sessions in new windows by holding down the alt key
while pressing the SSH button.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-08 09:30:52 -07:00
Mihai Parparita
2400ba28b1 cmd/tsconnect: handle terminal resizes before the SSH session is created
Store the requested size is a struct field, and use that when actually
creating the SSH session.

Fixes #5567

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-08 09:30:52 -07:00
Brad Fitzpatrick
2266b59446 go.toolchain.rev: bump to Go 1.19.1
See https://github.com/tailscale/go/pull/34

Change-Id: I56806358cd1be4a2b8f509883e47c93083d82bdf
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-07 22:13:01 -07:00
Brad Fitzpatrick
ad7546fb9f tailcfg: fix broken test from comment change
Fix broken build from 255c0472fb

"Oh, that's safe to commit because most tests are passing and it's
just a comment change!", I thought, forgetting I'd added a test that
parses its comments.

Change-Id: Iae93d595e06fec48831215a98adbb270f3bfda05
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-07 22:03:38 -07:00
Brad Fitzpatrick
255c0472fb tailcfg: reformat CurrentCapabilityVersion to be a bulleted list
gofmt in 1.19 is now opinionated about structured text formatting in
comments. It did not like our style and kept fighting us whenever we
changed these lines. Give up the fight and be a bulleted list for it.

See:

* https://go.dev/doc/go1.19#go-doc and
* https://go.dev/doc/comment

Updates #4872

Change-Id: Ifae431218471217168c003ab3b4e03c394ca8105
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-07 21:53:16 -07:00
License Updater
c5adc5243c licenses: update win/apple licenses
Signed-off-by: GitHub <noreply@github.com>
2022-09-07 14:28:24 -07:00
Andrew Dunham
c9961b8b95 cmd/derper: filter out useless HTTP error logs (#5563)
These errors aren't actionable and just fill up logs with useless data.
See the following Go issue for more details:
  https://golang.org/issue/26918

Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2022-09-07 16:31:06 -04:00
License Updater
8fdf137571 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply@tailscale.com>
2022-09-06 19:19:15 -07:00
Colin Adler
9c8bbc7888 wgengine/magicsock: fix panic in http debug server
Fixes an panic in `(*magicsock.Conn).ServeHTTPDebug` when the
`recentPongs` ring buffer for an endpoint wraps around.

Signed-off-by: Colin Adler <colin1adler@gmail.com>
2022-09-06 15:02:07 -07:00
Andrew Dunham
9240f5c1e2 wgengine/netstack: only accept connection after dialing (#5503)
If we accept a forwarded TCP connection before dialing, we can
erroneously signal to a client that we support IPv6 (or IPv4) without
that actually being possible. Instead, we only complete the client's TCP
handshake after we've dialed the outbound connection; if that fails, we
respond with a RST.

Updates #5425 (maybe fixes!)

Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2022-09-06 16:04:10 -04:00
Mihai Parparita
2f702b150e cmd/tsconnect: add dev-pkg command for two-sided development
Allows imports of the NPM package added by 1a093ef482
to be replaced with import("http://localhost:9090/pkg/pkg.js"), so that
changes can be made in parallel to both the module and code that uses
it (without any need for NPM publishing or even building of the package).

Updates #5415

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-09-06 12:42:58 -07:00
James Tucker
672c2c8de8 wgengine/magicsock: add filter to ignore disco to old/other ports
Incoming disco packets are now dropped unless they match one of the
current bound ports, or have a zero port*.

The BPF filter passes all packets with a disco header to the raw packet
sockets regardless of destination port (in order to avoid needing to
reconfigure BPF on rebind).

If a BPF enabled node has just rebound, due to restart or rebind, it may
receive and reply to disco ping packets destined for ports other than
those which are presently bound. If the pong is accepted, the pinging
node will now assume that it can send WireGuard traffic to the pinged
port - such traffic will not reach the node as it is not destined for a
bound port.

*The zero port is ignored, if received. This is a speculative defense
and would indicate a problem in the receive path, or the BPF filter.
This condition is allowed to pass as it may enable traffic to flow,
however it will also enable problems with the same symptoms this patch
otherwise fixes.

Fixes #5536

Signed-off-by: James Tucker <james@tailscale.com>
2022-09-06 12:25:04 -07:00
James Tucker
be140add75 wgengine/magicsock: fix regression in initial bind for js
1f959edeb0 introduced a regression for JS
where the initial bind no longer occurred at all for JS.

The condition is moved deeper in the call tree to avoid proliferation of
higher level conditions.

Updates #5537

Signed-off-by: James Tucker <james@tailscale.com>
2022-09-06 12:23:44 -07:00
James Tucker
1f959edeb0 wgengine/magicksock: remove nullability of RebindingUDPConns
Both RebindingUDPConns now always exist. the initial bind (which now
just calls rebind) now ensures that bind is called for both, such that
they both at least contain a blockForeverConn. Calling code no longer
needs to assert their state.

Signed-off-by: James Tucker <james@tailscale.com>
2022-09-06 12:08:31 -07:00
Brad Fitzpatrick
56f6fe204b go.mod, wgengine/wgint: bump wireguard-go
For b51010ba13

Change-Id: Ibf767dfad98aef7e9f0505d91c0d26f924e046d5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-06 11:34:30 -07:00
Andrew Dunham
f52a659076 net/dnsfallback: allow setting log function (#5550)
This broke a test in corp that enforces we don't use the log package.

Signed-off-by: Andrew Dunham <andrew@tailscale.com>
2022-09-06 11:19:50 -04:00
Andrew Dunham
b8596f2a2f net/dnsfallback: cache most recent DERP map on disk (#5545)
This is especially helpful as we launch newer DERPs over time, and older
clients have progressively out-of-date static DERP maps baked in. After
this, as long as the client has successfully connected once, it'll cache
the most recent DERP map it knows about.

Resolves an in-code comment from @bradfitz

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2022-09-05 14:36:30 -04:00
Maisem Ali
060ecb010f docs/k8s: make run.sh handle SIGINT
It was previously using jobcontrol to achieve this, but that apparently
doesn't work when there is no tty. This makes it so that it directly
handles SIGINT and SIGTERM and passes it on to tailscaled. I tested this
works on a Digital Ocean K8s cluster.

Fixes #5512

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2022-09-04 15:50:02 -07:00
Brad Fitzpatrick
02de34fb10 cmd/derper: add flag to run derper in bootstrap-dns-only mode
Change-Id: Iba128e94464afa605bc9df1f06a91d296380eed0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-03 19:51:00 -07:00
Will Norris
3344c3b89b tsnet: add Server method to listener
Allow callers to verify that a net.Listener is a tsnet.listener by type
asserting against this Server method, as well as providing access to the
underlying Server.

This is initially being added to support the caddy integration in
caddyserver/caddy#5002.

Signed-off-by: Will Norris <will@tailscale.com>
2022-09-02 16:29:49 -07:00
Andrew Dunham
a0bae4dac8 cmd/derper: add support for unpublished bootstrap DNS entries (#5529)
Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2022-09-02 14:48:30 -04:00
Tom DNetto
9132b31e43 tailcfg: refactor/implement wire structs for TKA
Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-09-02 10:29:46 -07:00
Kris Brandow
19008a3023 net/dnscache: use net/netip
Removes usage of net.IP and net.IPAddr where possible from net/dnscache.

Fixes #5282

Signed-off-by: Kris Brandow <kris.brandow@gmail.com>
2022-09-01 17:54:19 -04:00
Brad Fitzpatrick
ba3cc08b62 cmd/tailscale/cli: add backwards compatibility 'up' processing for legacy client
Updates tailscale/corp#6781

Change-Id: I843fc810cbec0140d423d65db81e90179d6e0fa5
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-09-01 14:21:48 -07:00
License Updater
d8bfb7543e licenses: update win/apple licenses
Signed-off-by: GitHub <noreply@github.com>
2022-09-01 14:10:55 -07:00
James Tucker
265b008e49 wgengine: fix race on endpoints in getStatus
Signed-off-by: James Tucker <james@tailscale.com>
2022-09-01 10:58:04 -07:00
Bertrand Lorentz
a5ad57472a cli/cert: Fix help message for --key-file
Signed-off-by: Bertrand Lorentz <bertrand.lorentz@gmail.com>
2022-09-01 10:57:00 -07:00
Xe Iaso
3564fd61b5 cmd/gitops-pusher: standardize hujson before posting to validate (#5525)
Apparently the validate route doesn't check content-types or handle
hujson with comments correctly. This patch makes gitops-pusher convert
the hujson to normal json.

Signed-off-by: Xe <xe@tailscale.com>

Signed-off-by: Xe <xe@tailscale.com>
2022-09-01 13:38:32 -04:00
nyghtowl
cfbbcf6d07 cmd/nginx-auth/nginx-auth: update auth to allow for new domains
With MagicDNS GA, we are giving every tailnet a tailnet-<hex>.ts.net name.
We will only parse out if legacy domains include beta.tailscale.net; otherwise,
set tailnet to the full domain format going forward.

Signed-off-by: nyghtowl <warrick@tailscale.com>
2022-08-31 20:18:13 -07:00
License Updater
9c66dce8e0 licenses: update win/apple licenses
Signed-off-by: GitHub <noreply@github.com>
2022-08-31 15:38:41 -07:00
Brad Fitzpatrick
e470893ba0 wgengine/magicsock: use mak in another spot
Change-Id: I0a46d6243371ae6d126005a2bd63820cb2d1db6b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-08-31 15:30:26 -07:00
Andrew Dunham
c72caa6672 wgengine/magicsock: use AF_PACKET socket + BPF to read disco messages
This is entirely optional (i.e. failing in this code is non-fatal) and
only enabled on Linux for now. Additionally, this new behaviour can be
disabled by setting the TS_DEBUG_DISABLE_AF_PACKET environment variable.

Updates #3824
Replaces #5474

Co-authored-by: Andrew Dunham <andrew@du.nham.ca>
Signed-off-by: David Anderson <danderson@tailscale.com>
2022-08-31 14:52:31 -07:00
Mihai Parparita
58f35261d0 cmd/tsconnect: remove debugging code
Remove test prefix added to validate the error code from 27f36f77c3.

Signed-off-by: Mihai Parparita <mihai@tailscale.com>
2022-08-31 10:46:47 -07:00
Tom DNetto
be95aebabd tka: implement credential signatures (key material delegation)
This will be needed to support preauth-keys with network lock in the future,
so getting the core mechanics out of the way now.

Signed-off-by: Tom DNetto <tom@tailscale.com>
2022-08-31 10:13:13 -07:00
License Updater
490acdefb6 licenses: update android licenses
Signed-off-by: License Updater <noreply@tailscale.com>
2022-08-31 09:55:41 -07:00
License Updater
84b74825f0 licenses: update tailscale{,d} licenses
Signed-off-by: License Updater <noreply@tailscale.com>
2022-08-31 08:38:55 -07:00
Brad Fitzpatrick
9bd9f37d29 go.mod: bump wireguard/windows, which moves to using net/netip
Updates #5162

Change-Id: If99a3f0000bce0c01bdf44da1d513f236fd7cdf8
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2022-08-31 08:36:56 -07:00
Charlotte Brandhorst-Satzkorn
185f2e4768 words: this title should have been a pun, but I chickened out (#5506)
Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
2022-08-31 07:02:49 -07:00
Denton Gentry
53e08bd7ea VERSION.txt: this is 1.31
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2022-08-31 06:48:24 -07:00
102 changed files with 3358 additions and 1025 deletions

View File

@@ -1 +1 @@
1.29.0
1.31.0

View File

@@ -17,16 +17,31 @@ import (
"tailscale.com/syncs"
)
var dnsCache syncs.AtomicValue[[]byte]
const refreshTimeout = time.Minute
var bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
type dnsEntryMap map[string][]net.IP
var (
dnsCache syncs.AtomicValue[dnsEntryMap]
dnsCacheBytes syncs.AtomicValue[[]byte] // of JSON
unpublishedDNSCache syncs.AtomicValue[dnsEntryMap]
)
var (
bootstrapDNSRequests = expvar.NewInt("counter_bootstrap_dns_requests")
publishedDNSHits = expvar.NewInt("counter_bootstrap_dns_published_hits")
publishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_published_misses")
unpublishedDNSHits = expvar.NewInt("counter_bootstrap_dns_unpublished_hits")
unpublishedDNSMisses = expvar.NewInt("counter_bootstrap_dns_unpublished_misses")
)
func refreshBootstrapDNSLoop() {
if *bootstrapDNS == "" {
if *bootstrapDNS == "" && *unpublishedDNS == "" {
return
}
for {
refreshBootstrapDNS()
refreshUnpublishedDNS()
time.Sleep(10 * time.Minute)
}
}
@@ -35,10 +50,34 @@ func refreshBootstrapDNS() {
if *bootstrapDNS == "" {
return
}
dnsEntries := make(map[string][]net.IP)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
defer cancel()
names := strings.Split(*bootstrapDNS, ",")
dnsEntries := resolveList(ctx, strings.Split(*bootstrapDNS, ","))
j, err := json.MarshalIndent(dnsEntries, "", "\t")
if err != nil {
// leave the old values in place
return
}
dnsCache.Store(dnsEntries)
dnsCacheBytes.Store(j)
}
func refreshUnpublishedDNS() {
if *unpublishedDNS == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
defer cancel()
dnsEntries := resolveList(ctx, strings.Split(*unpublishedDNS, ","))
unpublishedDNSCache.Store(dnsEntries)
}
func resolveList(ctx context.Context, names []string) dnsEntryMap {
dnsEntries := make(dnsEntryMap)
var r net.Resolver
for _, name := range names {
addrs, err := r.LookupIP(ctx, "ip", name)
@@ -48,21 +87,47 @@ func refreshBootstrapDNS() {
}
dnsEntries[name] = addrs
}
j, err := json.MarshalIndent(dnsEntries, "", "\t")
if err != nil {
// leave the old values in place
return
}
dnsCache.Store(j)
return dnsEntries
}
func handleBootstrapDNS(w http.ResponseWriter, r *http.Request) {
bootstrapDNSRequests.Add(1)
w.Header().Set("Content-Type", "application/json")
j := dnsCache.Load()
// Bootstrap DNS requests occur cross-regions,
// and are randomized per request,
// so keeping a connection open is pointlessly expensive.
// Bootstrap DNS requests occur cross-regions, and are randomized per
// request, so keeping a connection open is pointlessly expensive.
w.Header().Set("Connection", "close")
// Try answering a query from our hidden map first
if q := r.URL.Query().Get("q"); q != "" {
if ips, ok := unpublishedDNSCache.Load()[q]; ok && len(ips) > 0 {
unpublishedDNSHits.Add(1)
// Only return the specific query, not everything.
m := dnsEntryMap{q: ips}
j, err := json.MarshalIndent(m, "", "\t")
if err == nil {
w.Write(j)
return
}
}
// If we have a "q" query for a name in the published cache
// list, then track whether that's a hit/miss.
if m, ok := dnsCache.Load()[q]; ok {
if len(m) > 0 {
publishedDNSHits.Add(1)
} else {
publishedDNSMisses.Add(1)
}
} else {
// If it wasn't in either cache, treat this as a query
// for the unpublished cache, and thus a cache miss.
unpublishedDNSMisses.Add(1)
}
}
// Fall back to returning the public set of cached DNS names
j := dnsCacheBytes.Load()
w.Write(j)
}

View File

@@ -5,7 +5,12 @@
package main
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
)
@@ -17,11 +22,12 @@ func BenchmarkHandleBootstrapDNS(b *testing.B) {
}()
refreshBootstrapDNS()
w := new(bitbucketResponseWriter)
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape("log.tailscale.io"), nil)
b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(b *testing.PB) {
for b.Next() {
handleBootstrapDNS(w, nil)
handleBootstrapDNS(w, req)
}
})
}
@@ -33,3 +39,116 @@ func (b *bitbucketResponseWriter) Header() http.Header { return make(http.Header
func (b *bitbucketResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
func (b *bitbucketResponseWriter) WriteHeader(statusCode int) {}
func getBootstrapDNS(t *testing.T, q string) dnsEntryMap {
t.Helper()
req, _ := http.NewRequest("GET", "https://localhost/bootstrap-dns?q="+url.QueryEscape(q), nil)
w := httptest.NewRecorder()
handleBootstrapDNS(w, req)
res := w.Result()
if res.StatusCode != 200 {
t.Fatalf("got status=%d; want %d", res.StatusCode, 200)
}
var ips dnsEntryMap
if err := json.NewDecoder(res.Body).Decode(&ips); err != nil {
t.Fatalf("error decoding response body: %v", err)
}
return ips
}
func TestUnpublishedDNS(t *testing.T) {
const published = "login.tailscale.com"
const unpublished = "log.tailscale.io"
prev1, prev2 := *bootstrapDNS, *unpublishedDNS
*bootstrapDNS = published
*unpublishedDNS = unpublished
t.Cleanup(func() {
*bootstrapDNS = prev1
*unpublishedDNS = prev2
})
refreshBootstrapDNS()
refreshUnpublishedDNS()
hasResponse := func(q string) bool {
_, found := getBootstrapDNS(t, q)[q]
return found
}
if !hasResponse(published) {
t.Errorf("expected response for: %s", published)
}
if !hasResponse(unpublished) {
t.Errorf("expected response for: %s", unpublished)
}
// Verify that querying for a random query or a real query does not
// leak our unpublished domain
m1 := getBootstrapDNS(t, published)
if _, found := m1[unpublished]; found {
t.Errorf("found unpublished domain %s: %+v", unpublished, m1)
}
m2 := getBootstrapDNS(t, "random.example.com")
if _, found := m2[unpublished]; found {
t.Errorf("found unpublished domain %s: %+v", unpublished, m2)
}
}
func resetMetrics() {
publishedDNSHits.Set(0)
publishedDNSMisses.Set(0)
unpublishedDNSHits.Set(0)
unpublishedDNSMisses.Set(0)
}
// Verify that we don't count an empty list in the unpublishedDNSCache as a
// cache hit in our metrics.
func TestUnpublishedDNSEmptyList(t *testing.T) {
pub := dnsEntryMap{
"tailscale.com": {net.IPv4(10, 10, 10, 10)},
}
dnsCache.Store(pub)
dnsCacheBytes.Store([]byte(`{"tailscale.com":["10.10.10.10"]}`))
unpublishedDNSCache.Store(dnsEntryMap{
"log.tailscale.io": {},
"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)},
})
t.Run("CacheMiss", func(t *testing.T) {
// One domain in map but empty, one not in map at all
for _, q := range []string{"log.tailscale.io", "login.tailscale.com"} {
resetMetrics()
ips := getBootstrapDNS(t, q)
// Expected our public map to be returned on a cache miss
if !reflect.DeepEqual(ips, pub) {
t.Errorf("got ips=%+v; want %+v", ips, pub)
}
if v := unpublishedDNSHits.Value(); v != 0 {
t.Errorf("got hits=%d; want 0", v)
}
if v := unpublishedDNSMisses.Value(); v != 1 {
t.Errorf("got misses=%d; want 1", v)
}
}
})
// Verify that we do get a valid response and metric.
t.Run("CacheHit", func(t *testing.T) {
resetMetrics()
ips := getBootstrapDNS(t, "controlplane.tailscale.com")
want := dnsEntryMap{"controlplane.tailscale.com": {net.IPv4(1, 2, 3, 4)}}
if !reflect.DeepEqual(ips, want) {
t.Errorf("got ips=%+v; want %+v", ips, want)
}
if v := unpublishedDNSHits.Value(); v != 1 {
t.Errorf("got hits=%d; want 1", v)
}
if v := unpublishedDNSMisses.Value(); v != 0 {
t.Errorf("got misses=%d; want 0", v)
}
})
}

View File

@@ -26,6 +26,7 @@ import (
"strings"
"time"
"go4.org/mem"
"golang.org/x/time/rate"
"tailscale.com/atomicfile"
"tailscale.com/derp"
@@ -46,14 +47,21 @@ var (
certDir = flag.String("certdir", tsweb.DefaultCertDir("derper-certs"), "directory to store LetsEncrypt certs, if addr's port is :443")
hostname = flag.String("hostname", "derp.tailscale.com", "LetsEncrypt host name, if addr's port is :443")
runSTUN = flag.Bool("stun", true, "whether to run a STUN server. It will bind to the same IP (if any) as the --addr flag value.")
runDERP = flag.Bool("derp", true, "whether to run a DERP server. The only reason to set this false is if you're decommissioning a server but want to keep its bootstrap DNS functionality still running.")
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list")
bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns")
verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.")
meshPSKFile = flag.String("mesh-psk-file", defaultMeshPSKFile(), "if non-empty, path to file containing the mesh pre-shared key file. It should contain some hex string; whitespace is trimmed.")
meshWith = flag.String("mesh-with", "", "optional comma-separated list of hostnames to mesh with; the server's own hostname can be in the list")
bootstrapDNS = flag.String("bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns")
unpublishedDNS = flag.String("unpublished-bootstrap-dns-names", "", "optional comma-separated list of hostnames to make available at /bootstrap-dns and not publish in the list")
verifyClients = flag.Bool("verify-clients", false, "verify clients to this DERP server through a local tailscaled instance.")
acceptConnLimit = flag.Float64("accept-connection-limit", math.Inf(+1), "rate limit for accepting new connection")
acceptConnBurst = flag.Int("accept-connection-burst", math.MaxInt, "burst limit for accepting new connection")
egressInterface = flag.String("egress-interface", "", "the interface to monitor for automatic ratelimit tuning")
egressDataLimit = flag.Int("egress-data-limit", 100*1024*1024/8, "the bandwidth in bytes/s the server will try to stay under, only applies if egress-interface is set")
clientDataMin = flag.Int("client-data-min-limit", 1024*1024/8, "minimum bandwidth in bytes/s for a single client, only applies if egress-interface is set")
clientDataBurst = flag.Int("client-data-burst", 3*1024*1024, "burst limit in bytes for forwarded data from a single client, only applies if egress-interface is set")
)
var (
@@ -151,6 +159,12 @@ func main() {
s := derp.NewServer(cfg.PrivateKey, log.Printf)
s.SetVerifyClient(*verifyClients)
if *egressInterface != "" && *egressDataLimit > 0 {
if err := s.StartEgressRateLimiter(*egressInterface, *egressDataLimit, *clientDataMin, *clientDataBurst); err != nil {
log.Fatalf("failed to start egress rate limiter: %v", err)
}
}
if *meshPSKFile != "" {
b, err := ioutil.ReadFile(*meshPSKFile)
if err != nil {
@@ -169,9 +183,15 @@ func main() {
expvar.Publish("derp", s.ExpVar())
mux := http.NewServeMux()
derpHandler := derphttp.Handler(s)
derpHandler = addWebSocketSupport(s, derpHandler)
mux.Handle("/derp", derpHandler)
if *runDERP {
derpHandler := derphttp.Handler(s)
derpHandler = addWebSocketSupport(s, derpHandler)
mux.Handle("/derp", derpHandler)
} else {
mux.Handle("/derp", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "derp server disabled", http.StatusNotFound)
}))
}
mux.HandleFunc("/derp/probe", probeHandler)
go refreshBootstrapDNSLoop()
mux.HandleFunc("/bootstrap-dns", handleBootstrapDNS)
@@ -187,10 +207,17 @@ func main() {
server.
</p>
`)
if !*runDERP {
io.WriteString(w, `<p>Status: <b>disabled</b></p>`)
}
if tsweb.AllowDebugAccess(r) {
io.WriteString(w, "<p>Debug info at <a href='/debug/'>/debug/</a>.</p>\n")
}
}))
mux.Handle("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "User-agent: *\nDisallow: /\n")
}))
mux.Handle("/generate_204", http.HandlerFunc(serveNoContent))
debug := tsweb.Debugger(mux)
debug.KV("TLS hostname", *hostname)
debug.KV("Mesh key", s.HasMeshKey())
@@ -208,9 +235,11 @@ func main() {
go serveSTUN(listenHost, *stunPort)
}
quietLogger := log.New(logFilter{}, "", 0)
httpsrv := &http.Server{
Addr: *addr,
Handler: mux,
Addr: *addr,
Handler: mux,
ErrorLog: quietLogger,
// Set read/write timeout. For derper, this basically
// only affects TLS setup, as read/write deadlines are
@@ -276,9 +305,13 @@ func main() {
})
if *httpPort > -1 {
go func() {
port80mux := http.NewServeMux()
port80mux.HandleFunc("/generate_204", serveNoContent)
port80mux.Handle("/", certManager.HTTPHandler(tsweb.Port80Handler{Main: mux}))
port80srv := &http.Server{
Addr: net.JoinHostPort(listenHost, fmt.Sprintf("%d", *httpPort)),
Handler: certManager.HTTPHandler(tsweb.Port80Handler{Main: mux}),
Handler: port80mux,
ErrorLog: quietLogger,
ReadTimeout: 30 * time.Second,
// Crank up WriteTimeout a bit more than usually
// necessary just so we can do long CPU profiles
@@ -304,6 +337,11 @@ func main() {
}
}
// For captive portal detection
func serveNoContent(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// probeHandler is the endpoint that js/wasm clients hit to measure
// DERP latency, since they can't do UDP STUN queries.
func probeHandler(w http.ResponseWriter, r *http.Request) {
@@ -449,3 +487,22 @@ func (l *rateLimitedListener) Accept() (net.Conn, error) {
l.numAccepts.Add(1)
return cn, nil
}
// logFilter is used to filter out useless error logs that are logged to
// the net/http.Server.ErrorLog logger.
type logFilter struct{}
func (logFilter) Write(p []byte) (int, error) {
b := mem.B(p)
if mem.HasSuffix(b, mem.S(": EOF\n")) ||
mem.HasSuffix(b, mem.S(": i/o timeout\n")) ||
mem.HasSuffix(b, mem.S(": read: connection reset by peer\n")) ||
mem.HasSuffix(b, mem.S(": remote error: tls: bad certificate\n")) ||
mem.HasSuffix(b, mem.S(": tls: first record does not look like a TLS handshake\n")) {
// Skip this log message, but say that we processed it
return len(p), nil
}
log.Printf("%s", p)
return len(p), nil
}

View File

@@ -8,6 +8,7 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
@@ -264,13 +265,16 @@ func applyNewACL(ctx context.Context, tailnet, apiKey, policyFname, oldEtag stri
}
func testNewACLs(ctx context.Context, tailnet, apiKey, policyFname string) error {
fin, err := os.Open(policyFname)
data, err := os.ReadFile(policyFname)
if err != nil {
return err
}
data, err = hujson.Standardize(data)
if err != nil {
return err
}
defer fin.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl/validate", tailnet), fin)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://api.tailscale.com/api/v2/tailnet/%s/acl/validate", tailnet), bytes.NewBuffer(data))
if err != nil {
return err
}

View File

@@ -75,12 +75,7 @@ func main() {
log.Printf("can't extract tailnet name from hostname %q", info.Node.Name)
return
}
tailnet, _, ok = strings.Cut(tailnet, ".beta.tailscale.net")
if !ok {
w.WriteHeader(http.StatusUnauthorized)
log.Printf("can't extract tailnet name from hostname %q", info.Node.Name)
return
}
tailnet = strings.TrimSuffix(tailnet, ".beta.tailscale.net")
}
if expectedTailnet := r.Header.Get("Expected-Tailnet"); expectedTailnet != "" && expectedTailnet != tailnet {

View File

@@ -29,7 +29,7 @@ var certCmd = &ffcli.Command{
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("cert")
fs.StringVar(&certArgs.certFile, "cert-file", "", "output cert file or \"-\" for stdout; defaults to DOMAIN.crt if --cert-file and --key-file are both unset")
fs.StringVar(&certArgs.keyFile, "key-file", "", "output cert file or \"-\" for stdout; defaults to DOMAIN.key if --cert-file and --key-file are both unset")
fs.StringVar(&certArgs.keyFile, "key-file", "", "output key file or \"-\" for stdout; defaults to DOMAIN.key if --cert-file and --key-file are both unset")
fs.BoolVar(&certArgs.serve, "serve-demo", false, "if true, serve on port :443 using the cert as a demo, instead of writing out the files to disk")
return fs
})(),

View File

@@ -762,6 +762,9 @@ func TestPrefFlagMapping(t *testing.T) {
case "NotepadURLs":
// TODO(bradfitz): https://github.com/tailscale/tailscale/issues/1830
continue
case "Egg":
// Not applicable.
continue
}
t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName)
}

View File

@@ -19,24 +19,27 @@ var licensesCmd = &ffcli.Command{
Exec: runLicenses,
}
func runLicenses(ctx context.Context, args []string) error {
var licenseURL string
// licensesURL returns the absolute URL containing open source license information for the current platform.
func licensesURL() string {
switch runtime.GOOS {
case "android":
licenseURL = "https://tailscale.com/licenses/android"
return "https://tailscale.com/licenses/android"
case "darwin", "ios":
licenseURL = "https://tailscale.com/licenses/apple"
return "https://tailscale.com/licenses/apple"
case "windows":
licenseURL = "https://tailscale.com/licenses/windows"
return "https://tailscale.com/licenses/windows"
default:
licenseURL = "https://tailscale.com/licenses/tailscale"
return "https://tailscale.com/licenses/tailscale"
}
}
func runLicenses(ctx context.Context, args []string) error {
licenses := licensesURL()
outln(`
Tailscale wouldn't be possible without the contributions of thousands of open
source developers. To see the open source packages included in Tailscale and
their respective license information, visit:
` + licenseURL)
` + licenses)
return nil
}

View File

@@ -406,8 +406,12 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus
}
func runUp(ctx context.Context, args []string) (retErr error) {
var egg bool
if len(args) > 0 {
fatalf("too many non-flag arguments: %q", args)
egg = fmt.Sprint(args) == "[up down down left right left right b a]"
if !egg {
fatalf("too many non-flag arguments: %q", args)
}
}
st, err := localClient.Status(ctx)
@@ -493,6 +497,7 @@ func runUp(ctx context.Context, args []string) (retErr error) {
fatalf("%s", err)
}
if justEditMP != nil {
justEditMP.EggSet = true
_, err := localClient.EditPrefs(ctx, justEditMP)
return err
}

View File

@@ -59,6 +59,7 @@ type tmplData struct {
IP string
AdvertiseExitNode bool
AdvertiseRoutes string
LicensesURL string
}
var webCmd = &ffcli.Command{
@@ -392,6 +393,7 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
Profile: profile,
Status: st.BackendState,
DeviceName: deviceName,
LicensesURL: licensesURL(),
}
exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0")
exitNodeRouteV6 := netip.MustParsePrefix("::/0")

View File

@@ -11,7 +11,7 @@
</head>
<body class="py-14">
<main class="container max-w-lg mx-auto py-6 px-8 bg-white rounded-md shadow-2xl" style="width: 95%">
<main class="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl" style="width: 95%">
<header class="flex justify-between items-center min-width-0 py-2 mb-8">
<svg width="26" height="26" viewBox="0 0 23 23" title="Tailscale" fill="none" xmlns="http://www.w3.org/2000/svg"
class="flex-shrink-0 mr-4">
@@ -100,6 +100,9 @@
</div>
{{ end }}
</main>
<footer class="container max-w-lg mx-auto text-center">
<a class="text-xs text-gray-500 hover:text-gray-600" href="{{ .LicensesURL }}">Open Source Licenses</a>
</footer>
<script>(function () {
const advertiseExitNode = {{.AdvertiseExitNode}};
let fetchingUrl = false;

View File

@@ -212,7 +212,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/logtail/filch from tailscale.com/logpolicy
💣 tailscale.com/metrics from tailscale.com/derp+
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dns/publicdns from tailscale.com/net/dns/resolver
tailscale.com/net/dns/publicdns from tailscale.com/net/dns/resolver+
tailscale.com/net/dns/resolvconffile from tailscale.com/net/dns+
tailscale.com/net/dns/resolver from tailscale.com/ipn/ipnlocal+
tailscale.com/net/dnscache from tailscale.com/control/controlclient+
@@ -281,7 +281,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/util/pidowner from tailscale.com/ipn/ipnserver
tailscale.com/util/racebuild from tailscale.com/logpolicy
tailscale.com/util/singleflight from tailscale.com/control/controlclient+
L tailscale.com/util/strs from tailscale.com/hostinfo
tailscale.com/util/strs from tailscale.com/hostinfo+
tailscale.com/util/systemd from tailscale.com/control/controlclient+
tailscale.com/util/uniq from tailscale.com/wgengine/magicsock
💣 tailscale.com/util/winutil from tailscale.com/cmd/tailscaled+
@@ -290,7 +290,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
W tailscale.com/wf from tailscale.com/cmd/tailscaled
tailscale.com/wgengine from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/filter from tailscale.com/control/controlclient+
tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/wgengine/magicsock from tailscale.com/ipn/ipnlocal+
tailscale.com/wgengine/monitor from tailscale.com/control/controlclient+
tailscale.com/wgengine/netstack from tailscale.com/cmd/tailscaled+
tailscale.com/wgengine/router from tailscale.com/ipn/ipnlocal+

View File

@@ -113,6 +113,7 @@ var args struct {
verbose int
socksAddr string // listen address for SOCKS5 server
httpProxyAddr string // listen address for HTTP proxy server
disableLogs bool
}
var (
@@ -144,6 +145,7 @@ func main() {
flag.StringVar(&args.socketpath, "socket", paths.DefaultTailscaledSocket(), "path of the service unix socket")
flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket")
flag.BoolVar(&printVersion, "version", false, "print version information and exit")
flag.BoolVar(&args.disableLogs, "no-logs-no-support", false, "disable log uploads; this also disables any technical support")
if len(os.Args) > 0 && filepath.Base(os.Args[0]) == "tailscale" && beCLI != nil {
beCLI()
@@ -199,6 +201,10 @@ func main() {
args.statepath = paths.DefaultTailscaledStateFile()
}
if args.disableLogs {
envknob.SetNoLogsNoSupport()
}
if beWindowsSubprocess() {
return
}

View File

@@ -38,3 +38,12 @@ The client is also available as an NPM package. To build it, run:
```
That places the output in the `pkg/` directory, which may then be uploaded to a package registry (or installed from the file path directly).
To do two-sided development (on both the NPM package and code that uses it), run:
```
./tool/go run ./cmd/tsconnect dev-pkg
```
This serves the module at http://localhost:9090/pkg/pkg.js and the generated wasm file at http://localhost:9090/pkg/main.wasm. The two files can be used as drop-in replacements for normal imports of the NPM module.

View File

@@ -11,13 +11,12 @@ import (
"os"
"path"
esbuild "github.com/evanw/esbuild/pkg/api"
"github.com/tailscale/hujson"
"tailscale.com/version"
)
func runBuildPkg() {
buildOptions, err := commonSetup(prodMode)
buildOptions, err := commonPkgSetup(prodMode)
if err != nil {
log.Fatalf("Cannot setup: %v", err)
}
@@ -31,10 +30,6 @@ func runBuildPkg() {
log.Fatalf("Cannot clean %s: %v", *pkgDir, err)
}
buildOptions.EntryPoints = []string{"src/pkg/pkg.ts", "src/pkg/pkg.css"}
buildOptions.Outdir = *pkgDir
buildOptions.Format = esbuild.FormatESModule
buildOptions.AssetNames = "[name]"
buildOptions.Write = true
buildOptions.MinifyWhitespace = true
buildOptions.MinifyIdentifiers = true

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io/ioutil"
"log"
"net"
"os"
"os/exec"
"path"
@@ -68,6 +69,18 @@ func commonSetup(dev bool) (*esbuild.BuildOptions, error) {
}, nil
}
func commonPkgSetup(dev bool) (*esbuild.BuildOptions, error) {
buildOptions, err := commonSetup(dev)
if err != nil {
return nil, err
}
buildOptions.EntryPoints = []string{"src/pkg/pkg.ts", "src/pkg/pkg.css"}
buildOptions.Outdir = *pkgDir
buildOptions.Format = esbuild.FormatESModule
buildOptions.AssetNames = "[name]"
return buildOptions, nil
}
// cleanDir removes files from dirPath, except the ones specified by
// preserveFiles.
func cleanDir(dirPath string, preserveFiles ...string) error {
@@ -90,6 +103,27 @@ func cleanDir(dirPath string, preserveFiles ...string) error {
return nil
}
func runEsbuildServe(buildOptions esbuild.BuildOptions) {
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
log.Fatalf("Cannot parse addr: %v", err)
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
log.Fatalf("Cannot parse port: %v", err)
}
result, err := esbuild.Serve(esbuild.ServeOptions{
Port: uint16(port),
Host: host,
Servedir: "./",
}, buildOptions)
if err != nil {
log.Fatalf("Cannot start esbuild server: %v", err)
}
log.Printf("Listening on http://%s:%d\n", result.Host, result.Port)
result.Wait()
}
func runEsbuild(buildOptions esbuild.BuildOptions) esbuild.BuildResult {
log.Printf("Running esbuild...\n")
result := esbuild.Build(buildOptions)

17
cmd/tsconnect/dev-pkg.go Normal file
View File

@@ -0,0 +1,17 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"log"
)
func runDevPkg() {
buildOptions, err := commonPkgSetup(devMode)
if err != nil {
log.Fatalf("Cannot setup: %v", err)
}
runEsbuildServe(*buildOptions)
}

View File

@@ -6,10 +6,6 @@ package main
import (
"log"
"net"
"strconv"
esbuild "github.com/evanw/esbuild/pkg/api"
)
func runDev() {
@@ -17,22 +13,5 @@ func runDev() {
if err != nil {
log.Fatalf("Cannot setup: %v", err)
}
host, portStr, err := net.SplitHostPort(*addr)
if err != nil {
log.Fatalf("Cannot parse addr: %v", err)
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
log.Fatalf("Cannot parse port: %v", err)
}
result, err := esbuild.Serve(esbuild.ServeOptions{
Port: uint16(port),
Host: host,
Servedir: "./",
}, *buildOptions)
if err != nil {
log.Fatalf("Cannot start esbuild server: %v", err)
}
log.Printf("Listening on http://%s:%d\n", result.Host, result.Port)
result.Wait()
runEsbuildServe(*buildOptions)
}

View File

@@ -10,8 +10,9 @@
"qrcode": "^1.5.0",
"tailwindcss": "^3.1.6",
"typescript": "^4.7.4",
"xterm": "^4.18.0",
"xterm-addon-fit": "^0.5.0"
"xterm": "5.0.0-beta.58",
"xterm-addon-fit": "^0.5.0",
"xterm-addon-web-links": "0.7.0-beta.6"
},
"scripts": {
"lint": "tsc --noEmit",

View File

@@ -92,6 +92,12 @@ class App extends Component<{}, AppState> {
}
handleBrowseToURL = (url: string) => {
if (this.state.ipnState === "Running") {
// Ignore URL requests if we're already running -- it's most likely an
// SSH check mode trigger and we already linkify the displayed URL
// in the terminal.
return
}
this.setState({ browseToURL: url })
}

View File

@@ -2,16 +2,24 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
import { useState, useCallback } from "preact/hooks"
import { useState, useCallback, useMemo, useEffect, useRef } from "preact/hooks"
import { createPortal } from "preact/compat"
import type { VNode } from "preact"
import { runSSHSession, SSHSessionDef } from "../lib/ssh"
export function SSH({ netMap, ipn }: { netMap: IPNNetMap; ipn: IPN }) {
const [sshSessionDef, setSSHSessionDef] = useState<SSHSessionDef | null>(null)
const [sshSessionDef, setSSHSessionDef] = useState<SSHFormSessionDef | null>(
null
)
const clearSSHSessionDef = useCallback(() => setSSHSessionDef(null), [])
if (sshSessionDef) {
return (
const sshSession = (
<SSHSession def={sshSessionDef} ipn={ipn} onDone={clearSSHSessionDef} />
)
if (sshSessionDef.newWindow) {
return <NewWindow close={clearSSHSessionDef}>{sshSession}</NewWindow>
}
return sshSession
}
const sshPeers = netMap.peers.filter(
(p) => p.tailscaleSSHEnabled && p.online !== false
@@ -24,6 +32,8 @@ export function SSH({ netMap, ipn }: { netMap: IPNNetMap; ipn: IPN }) {
return <SSHForm sshPeers={sshPeers} onSubmit={setSSHSessionDef} />
}
type SSHFormSessionDef = SSHSessionDef & { newWindow?: boolean }
function SSHSession({
def,
ipn,
@@ -33,20 +43,14 @@ function SSHSession({
ipn: IPN
onDone: () => void
}) {
return (
<div
class="flex-grow bg-black p-2 overflow-hidden"
ref={(node) => {
if (node) {
// Run the SSH session aysnchronously, so that the React render
// loop is complete (otherwise the SSH form may still be visible,
// which affects the size of the terminal, leading to a spurious
// initial resize).
setTimeout(() => runSSHSession(node, def, ipn, onDone), 0)
}
}}
/>
)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current) {
runSSHSession(ref.current, def, ipn, onDone)
}
}, [ref])
return <div class="flex-grow bg-black p-2 overflow-hidden" ref={ref} />
}
function NoSSHPeers() {
@@ -66,7 +70,7 @@ function SSHForm({
onSubmit,
}: {
sshPeers: IPNNetMapPeerNode[]
onSubmit: (def: SSHSessionDef) => void
onSubmit: (def: SSHFormSessionDef) => void
}) {
sshPeers = sshPeers.slice().sort((a, b) => a.name.localeCompare(b.name))
const [username, setUsername] = useState("")
@@ -99,7 +103,51 @@ function SSHForm({
type="submit"
class="button bg-green-500 border-green-500 text-white hover:bg-green-600 hover:border-green-600"
value="SSH"
onClick={(e) => {
if (e.altKey) {
e.preventDefault()
e.stopPropagation()
onSubmit({ username, hostname, newWindow: true })
}
}}
/>
</form>
)
}
const NewWindow = ({
children,
close,
}: {
children: VNode
close: () => void
}) => {
const newWindow = useMemo(() => {
const newWindow = window.open(undefined, undefined, "width=600,height=400")
if (newWindow) {
const containerNode = newWindow.document.createElement("div")
containerNode.className = "h-screen flex flex-col overflow-hidden"
newWindow.document.body.appendChild(containerNode)
for (const linkNode of document.querySelectorAll(
"head link[rel=stylesheet]"
)) {
const newLink = document.createElement("link")
newLink.rel = "stylesheet"
newLink.href = (linkNode as HTMLLinkElement).href
newWindow.document.head.appendChild(newLink)
}
}
return newWindow
}, [])
if (!newWindow) {
console.error("Could not open window")
return null
}
newWindow.onbeforeunload = () => {
close()
}
useEffect(() => () => newWindow.close(), [])
return createPortal(children, newWindow.document.body.lastChild as Element)
}

View File

@@ -1,5 +1,6 @@
import { Terminal } from "xterm"
import { Terminal, ITerminalOptions } from "xterm"
import { FitAddon } from "xterm-addon-fit"
import { WebLinksAddon } from "xterm-addon-web-links"
export type SSHSessionDef = {
username: string
@@ -10,16 +11,26 @@ export function runSSHSession(
termContainerNode: HTMLDivElement,
def: SSHSessionDef,
ipn: IPN,
onDone: () => void
onDone: () => void,
terminalOptions?: ITerminalOptions
) {
const parentWindow = termContainerNode.ownerDocument.defaultView ?? window
const term = new Terminal({
cursorBlink: true,
allowProposedApi: true,
...terminalOptions,
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(termContainerNode)
fitAddon.fit()
const webLinksAddon = new WebLinksAddon((event, uri) =>
event.view?.open(uri, "_blank", "noopener")
)
term.loadAddon(webLinksAddon)
let onDataHook: ((data: string) => void) | undefined
term.onData((e) => {
onDataHook?.(e)
@@ -30,7 +41,7 @@ export function runSSHSession(
let resizeObserver: ResizeObserver | undefined
let handleBeforeUnload: ((e: BeforeUnloadEvent) => void) | undefined
const sshSession = ipn.ssh(def.hostname + "2", def.username, {
const sshSession = ipn.ssh(def.hostname, def.username, {
writeFn(input) {
term.write(input)
},
@@ -47,19 +58,19 @@ export function runSSHSession(
resizeObserver?.disconnect()
term.dispose()
if (handleBeforeUnload) {
window.removeEventListener("beforeunload", handleBeforeUnload)
parentWindow.removeEventListener("beforeunload", handleBeforeUnload)
}
onDone()
},
})
// Make terminal and SSH session track the size of the containing DOM node.
resizeObserver = new ResizeObserver(() => fitAddon.fit())
resizeObserver = new parentWindow.ResizeObserver(() => fitAddon.fit())
resizeObserver.observe(termContainerNode)
term.onResize(({ rows, cols }) => sshSession.resize(rows, cols))
// Close the session if the user closes the window without an explicit
// exit.
handleBeforeUnload = () => sshSession.close()
window.addEventListener("beforeunload", handleBeforeUnload)
parentWindow.addEventListener("beforeunload", handleBeforeUnload)
}

View File

@@ -47,6 +47,7 @@ declare global {
stateStorage?: IPNStateStorage
authKey?: string
controlURL?: string
hostname?: string
}
type IPNCallbacks = {

View File

@@ -36,6 +36,8 @@ func main() {
switch flag.Arg(0) {
case "dev":
runDev()
case "dev-pkg":
runDevPkg()
case "build":
runBuild()
case "build-pkg":

View File

@@ -61,26 +61,30 @@ func main() {
func newIPN(jsConfig js.Value) map[string]any {
netns.SetEnabled(false)
jsStateStorage := jsConfig.Get("stateStorage")
var store ipn.StateStore
if jsStateStorage.IsUndefined() {
store = new(mem.Store)
} else {
if jsStateStorage := jsConfig.Get("stateStorage"); !jsStateStorage.IsUndefined() {
store = &jsStateStore{jsStateStorage}
} else {
store = new(mem.Store)
}
jsControlURL := jsConfig.Get("controlURL")
controlURL := ControlURL
if jsControlURL.Type() == js.TypeString {
if jsControlURL := jsConfig.Get("controlURL"); jsControlURL.Type() == js.TypeString {
controlURL = jsControlURL.String()
}
jsAuthKey := jsConfig.Get("authKey")
var authKey string
if jsAuthKey.Type() == js.TypeString {
if jsAuthKey := jsConfig.Get("authKey"); jsAuthKey.Type() == js.TypeString {
authKey = jsAuthKey.String()
}
var hostname string
if jsHostname := jsConfig.Get("hostname"); jsHostname.Type() == js.TypeString {
hostname = jsHostname.String()
} else {
hostname = generateHostname()
}
lpc := getOrCreateLogPolicyConfig(store)
c := logtail.Config{
Collection: lpc.Collection,
@@ -136,6 +140,7 @@ func newIPN(jsConfig js.Value) map[string]any {
lb: lb,
controlURL: controlURL,
authKey: authKey,
hostname: hostname,
}
return map[string]any{
@@ -196,6 +201,7 @@ type jsIPN struct {
lb *ipnlocal.LocalBackend
controlURL string
authKey string
hostname string
}
var jsIPNState = map[ipn.State]string{
@@ -284,7 +290,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
RouteAll: false,
AllowSingleHosts: true,
WantRunning: true,
Hostname: generateHostname(),
Hostname: i.hostname,
},
AuthKey: i.authKey,
})
@@ -343,6 +349,9 @@ type jsSSHSession struct {
username string
termConfig js.Value
session *ssh.Session
pendingResizeRows int
pendingResizeCols int
}
func (s *jsSSHSession) Run() {
@@ -354,9 +363,6 @@ func (s *jsSSHSession) Run() {
onDone := s.termConfig.Get("onDone")
defer onDone.Invoke()
write := func(s string) {
writeFn.Invoke(s)
}
writeError := func(label string, err error) {
writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err))
}
@@ -381,7 +387,6 @@ func (s *jsSSHSession) Run() {
return
}
defer sshConn.Close()
write("SSH Connected\r\n")
sshClient := ssh.NewClient(sshConn, nil, nil)
defer sshClient.Close()
@@ -392,7 +397,6 @@ func (s *jsSSHSession) Run() {
return
}
s.session = session
write("Session Established\r\n")
defer session.Close()
stdin, err := session.StdinPipe()
@@ -413,6 +417,14 @@ func (s *jsSSHSession) Run() {
return nil
}))
// We might have gotten a resize notification since we started opening the
// session, pick up the latest size.
if s.pendingResizeRows != 0 {
rows = s.pendingResizeRows
}
if s.pendingResizeCols != 0 {
cols = s.pendingResizeCols
}
err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
if err != nil {
@@ -438,6 +450,11 @@ func (s *jsSSHSession) Close() error {
}
func (s *jsSSHSession) Resize(rows, cols int) error {
if s.session == nil {
s.pendingResizeRows = rows
s.pendingResizeCols = cols
return nil
}
return s.session.WindowChange(rows, cols)
}

View File

@@ -644,10 +644,15 @@ xterm-addon-fit@^0.5.0:
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596"
integrity sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==
xterm@^4.18.0:
version "4.18.0"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1"
integrity sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ==
xterm@5.0.0-beta.58:
version "5.0.0-beta.58"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-5.0.0-beta.58.tgz#e3e96ab9fd24d006ec16cc9351a060cc79e67e80"
integrity sha512-gjg39oKdgUKful27+7I1hvSK51lu/LRhdimFhfZyMvdk0iATH0FAfzv1eAvBKWY2UBgYUfxhicTkanYioANdMw==
xterm-addon-web-links@0.7.0-beta.6:
version "0.7.0-beta.6"
resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.7.0-beta.6.tgz#ec63b681b4f0f0135fa039f53664f65fe9d9f43a"
integrity sha512-nD/r/GchGTN4c9gAIVLWVoxExTzAUV7E9xZnwsvhuwI4CEE6yqO15ns8g2hdcUrsPyCbNEw05mIrkF6W5Yj8qA==
y18n@^4.0.0:
version "4.0.3"

View File

@@ -937,6 +937,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
}
if resp.Debug.DisableLogTail {
logtail.Disable()
envknob.SetNoLogsNoSupport()
}
if resp.Debug.LogHeapPprof {
go logheap.LogHeap(resp.Debug.LogHeapURL)

View File

@@ -107,6 +107,9 @@ type Server struct {
metaCert []byte // the encoded x509 cert to send after LetsEncrypt cert+intermediate
dupPolicy dupPolicy
clientDataLimit *uint64 // limit for how many bytes/s of content a client can send; atomic
clientDataBurst int // burst limit for how many bytes/s of content a client can send
// Counters:
packetsSent, bytesSent expvar.Int
packetsRecv, bytesRecv expvar.Int
@@ -314,7 +317,10 @@ func NewServer(privateKey key.NodePrivate, logf logger.Logf) *Server {
sentTo: map[key.NodePublic]map[key.NodePublic]int64{},
avgQueueDuration: new(uint64),
keyOfAddr: map[netip.AddrPort]key.NodePublic{},
clientDataLimit: new(uint64),
clientDataBurst: 10 * 1024 * 1024, // 10Mb default burst
}
atomic.StoreUint64(s.clientDataLimit, 12*1024*1024) // 12Mb/s default ratelimit
s.initMetacert()
s.packetsRecvDisco = s.packetsRecvByKind.Get("disco")
s.packetsRecvOther = s.packetsRecvByKind.Get("other")
@@ -325,12 +331,48 @@ func NewServer(privateKey key.NodePrivate, logf logger.Logf) *Server {
s.packetsDroppedReason.Get("queue_head"),
s.packetsDroppedReason.Get("queue_tail"),
s.packetsDroppedReason.Get("write_error"),
s.packetsDroppedReason.Get("rate_limited"),
}
s.packetsDroppedTypeDisco = s.packetsDroppedType.Get("disco")
s.packetsDroppedTypeOther = s.packetsDroppedType.Get("other")
return s
}
// StartEgressRateLimiter starts dynamically adjusting the rate limit
// based on the desired limit and the utilization of the specified interface.
//
// It must be called before serving begins. All limits are in bytes/s.
func (s *Server) StartEgressRateLimiter(interfaceName string, egressDataLimit, clientDataMin, clientDataBurst int) error {
limiter, err := newEgressLimiter(interfaceName, uint64(egressDataLimit), uint64(clientDataMin))
if err != nil {
return fmt.Errorf("starting limiter: %v", err)
}
atomic.StoreUint64(s.clientDataLimit, uint64(egressDataLimit))
s.clientDataBurst = clientDataBurst
go func() {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
limit, err := limiter.Limit()
if err != nil {
s.logf("derp: failed to update egress limiter: %v", err)
return
}
atomic.StoreUint64(s.clientDataLimit, uint64(limit))
<-t.C
if s.closed {
return
}
}
}()
return nil
}
// SetMesh sets the pre-shared key that regional DERP servers used to mesh
// amongst themselves.
//
@@ -664,6 +706,7 @@ func (s *Server) accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, rem
remoteIPPort, _ := netip.ParseAddrPort(remoteAddr)
rateLimiter := rate.NewLimiter(rate.Limit(atomic.LoadUint64(s.clientDataLimit)), s.clientDataBurst)
c := &sclient{
connNum: connNum,
s: s,
@@ -681,6 +724,7 @@ func (s *Server) accept(ctx context.Context, nc Conn, brw *bufio.ReadWriter, rem
sendPongCh: make(chan [8]byte, 1),
peerGone: make(chan key.NodePublic),
canMesh: clientInfo.MeshKey != "" && clientInfo.MeshKey == s.meshKey,
rateLimiter: rateLimiter,
}
if c.canMesh {
@@ -757,6 +801,18 @@ func (c *sclient) run(ctx context.Context) error {
}
}
func (c *sclient) shouldRatelimitData(dataLen int) bool {
if c.canMesh {
return false // Mesh connections arent regular clients.
}
now := time.Now()
if rateLimit := atomic.LoadUint64(c.s.clientDataLimit); rateLimit != uint64(c.rateLimiter.Limit()) {
c.rateLimiter.SetLimitAt(now, rate.Limit(rateLimit))
}
return !c.rateLimiter.AllowN(now, dataLen)
}
func (c *sclient) handleUnknownFrame(ft frameType, fl uint32) error {
_, err := io.CopyN(ioutil.Discard, c.br, int64(fl))
return err
@@ -858,6 +914,11 @@ func (c *sclient) handleFrameForwardPacket(ft frameType, fl uint32) error {
}
s.packetsForwardedIn.Add(1)
if c.shouldRatelimitData(len(contents)) {
s.recordDrop(contents, c.key, dstKey, dropReasonRateLimited)
return nil
}
var dstLen int
var dst *sclient
@@ -908,6 +969,11 @@ func (c *sclient) handleFrameSendPacket(ft frameType, fl uint32) error {
return fmt.Errorf("client %x: recvPacket: %v", c.key, err)
}
if c.shouldRatelimitData(len(contents)) {
s.recordDrop(contents, c.key, dstKey, dropReasonRateLimited)
return nil
}
var fwd PacketForwarder
var dstLen int
var dst *sclient
@@ -962,6 +1028,7 @@ const (
dropReasonQueueTail // destination queue is full, dropped packet at queue tail
dropReasonWriteError // OS write() failed
dropReasonDupClient // the public key is connected 2+ times (active/active, fighting)
dropReasonRateLimited // send/forward packet content exceeds rate limit
)
func (s *Server) recordDrop(packetBytes []byte, srcKey, dstKey key.NodePublic, reason dropReason) {
@@ -1254,6 +1321,7 @@ type sclient struct {
canMesh bool // clientInfo had correct mesh token for inter-region routing
isDup atomic.Bool // whether more than 1 sclient for key is connected
isDisabled atomic.Bool // whether sends to this peer are disabled due to active/active dups
rateLimiter *rate.Limiter
// replaceLimiter controls how quickly two connections with
// the same client key can kick each other off the server by
@@ -1700,6 +1768,7 @@ func (s *Server) ExpVar() expvar.Var {
m.Set("average_queue_duration_ms", expvar.Func(func() any {
return math.Float64frombits(atomic.LoadUint64(s.avgQueueDuration))
}))
m.Set("client_ratelimit_bytes_per_second", expvar.Func(func() any { return atomic.LoadUint64(s.clientDataLimit) }))
var expvarVersion expvar.String
expvarVersion.Set(version.Long)
m.Set("version", &expvarVersion)

View File

@@ -19,11 +19,12 @@ func _() {
_ = x[dropReasonQueueTail-4]
_ = x[dropReasonWriteError-5]
_ = x[dropReasonDupClient-6]
_ = x[dropReasonRateLimited-7]
}
const _dropReason_name = "UnknownDestUnknownDestOnFwdGoneQueueHeadQueueTailWriteErrorDupClient"
const _dropReason_name = "UnknownDestUnknownDestOnFwdGoneQueueHeadQueueTailWriteErrorDupClientRateLimited"
var _dropReason_index = [...]uint8{0, 11, 27, 31, 40, 49, 59, 68}
var _dropReason_index = [...]uint8{0, 11, 27, 31, 40, 49, 59, 68, 79}
func (i dropReason) String() string {
if i < 0 || i >= dropReason(len(_dropReason_index)-1) {

171
derp/limiter.go Normal file
View File

@@ -0,0 +1,171 @@
// 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 derp
import (
"io/ioutil"
"strconv"
"strings"
"time"
)
func readTxBytes(interfaceName string) (uint64, error) {
v, err := ioutil.ReadFile("/sys/class/net/" + interfaceName + "/statistics/tx_bytes")
if err != nil {
return 0, err
}
tx, err := strconv.Atoi(strings.TrimSpace(string(v)))
if err != nil {
return 0, err
}
return uint64(tx), nil
}
type egressLimiter struct {
interfaceName string
limitBytesSec uint64 // the egress bytes/s we want to stay under.
minBytesSec uint64 // the minimum bytes/s rate limit.
lastTxBytes uint64
controlLoop limiterLoop
}
func newEgressLimiter(interfaceName string, limitBytesSec, minBytesSec uint64) (*egressLimiter, error) {
initial, err := readTxBytes(interfaceName)
if err != nil {
return nil, err
}
return &egressLimiter{
interfaceName: interfaceName,
limitBytesSec: limitBytesSec,
minBytesSec: minBytesSec,
lastTxBytes: initial,
controlLoop: newLimiterLoop(limitBytesSec, time.Now()),
}, err
}
// Limit returns the current rate limit value based on interface utilization.
func (e *egressLimiter) Limit() (uint64, error) {
rx, err := readTxBytes(e.interfaceName)
if err != nil {
return 0, err
}
last := e.lastTxBytes
e.lastTxBytes = rx
limit := e.controlLoop.tick(uint64(rx)-last, time.Now())
if limit < 0 || uint64(limit) < e.minBytesSec {
limit = float64(e.minBytesSec)
}
if uint64(limit) > e.limitBytesSec {
limit = float64(e.limitBytesSec)
}
return uint64(limit), nil
}
// PID loop values for the dynamic ratelimit.
// The wikipedia page on PID is recommended reading if you are not familiar
// with PID loops or open-loop control theory.
//
// Gain values are unitless, but operate on a feedback value in bytes
// and a setpoint value in bytes/s, and a time delta (dt) of seconds.
//
// These values are initial and should be tuned: These are just initial
// values based on first principles and vibin with pretty graphs.
const (
// Proportional gain.
// Given this represents a global ratelimit, the P term doesnt make a lot of
// sense, as each clients contribution to link utilization is entirely
// dependent on the client workload.
//
// For this reason, its set super low: Its expected the I term will do
// most of the heavy lifting.
limiterP float64 = 1.0 / 1024
// Derivative gain.
// This term reacts against 'trends', that is, the first derivative of
// the feedback value. Think of it like a rapid-change damper.
//
// This isnt super important, so again we've set it fairly low.
limiterD float64 = 0.003
// Integral gain.
//
// This is where all the heavy lifting happens. Basically, we increase
// the ratelimit (by limiterIP) when we have room to spare, and
// decrease it once we exceed 4/5ths of the limit (by limiterIN).
// The increase is linear to the error between feedback and the setpoint,
// but clamped proportionate to the limit.
//
// The decrease term is stronger than the increase term, so we 'backoff
// quickly' when we are approaching limits, but test the waters on
// the other end cautiously.
limiterIP float64 = 0.008
limiterIN float64 = 0.3
)
// limiterLoop exposes a dynamic ratelimit, based on the egress rate
// of some interface. The PID loop tries to keep egress at 4/5 of the limit.
type limiterLoop struct {
limitBytesSec uint64 // the egress bytes/s we want to stay under.
integral float64 // the integral sum at lastUpdate instant
lastEgress uint64 // feedback value of previous iteration, bytes/s
lastUpdate time.Time // instant at which last iteration occurred.
}
func newLimiterLoop(limitBytesSec uint64, now time.Time) limiterLoop {
return limiterLoop{
limitBytesSec: limitBytesSec * 4 / 5,
lastUpdate: now,
lastEgress: 0,
integral: float64(limitBytesSec),
}
}
// tick computes & returns the ratelimit value in bytes/s, computing
// the next iteration of the PID loop in the process.
func (l *limiterLoop) tick(egressBytesPerSec uint64, now time.Time) float64 {
var (
dt = now.Sub(l.lastUpdate).Seconds()
err = float64(l.limitBytesSec) - float64(egressBytesPerSec)
)
// Integral term.
var iDelta float64
if err > 0 {
iDelta = err * dt * limiterIP
} else {
iDelta = err * dt * limiterIN
}
// Constrain integral sum change to a 20th of the setpoint per second.
maxDelta := dt * float64(l.limitBytesSec) / 20
if iDelta > maxDelta {
iDelta = maxDelta
} else if iDelta < -maxDelta {
iDelta = -maxDelta
}
l.integral += iDelta
// Constrain integral sum to prevent windup.
if max := float64(l.limitBytesSec); l.integral > max {
l.integral = max
} else if l.integral < -max {
l.integral = -max
}
// Derivative term.
var d float64
if dt > 0 {
d = -(float64(egressBytesPerSec-l.lastEgress) / dt) * limiterD
}
// Proportional term.
p := limiterP * err
l.lastEgress = egressBytesPerSec
l.lastUpdate = now
output := p + l.integral + d
// fmt.Printf("in=%d, out=%0.3f: p=%0.2f d=%0.2f i=%0.2f\n", egressBytesPerSec, output, p, d, l.integral)
return output
}

56
derp/limiter_test.go Normal file
View File

@@ -0,0 +1,56 @@
// 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 derp
import (
"testing"
"time"
)
func mb(mb uint64) uint64 {
return mb * 1024 * 1024
}
func TestLimiterLoopGradual(t *testing.T) {
// Make a limiter that tries to keep under 200Mb/s.
limit := mb(200)
start := time.Now()
l := newLimiterLoop(limit, start)
// Make sure the initial value is sane.
// Lets imagine the egress is only like 1Mb/s.
now := start.Add(time.Second)
if v := uint64(l.tick(1024*1024, now)); v < mb(150) || v > limit {
t.Errorf("initial value = %dMb/s, want 150 < value < limit", v/1024/1024)
}
// Tick through 10 minutes of low usage. Lets make sure the limit stays high.
lowUsage := limit / 10
for i := 0; i < 600; i++ {
now = now.Add(time.Second)
v := uint64(l.tick(lowUsage, now))
if v < mb(150) {
t.Errorf("[t=%0.f] limit too low for low usage: %d (expected >150)", now.Sub(start).Seconds(), v/1024/1024)
}
}
// Lets tick through 60 seconds of steadily-increasing usage.
for i := 0; i < 60; i++ {
now = now.Add(time.Second)
l.tick(uint64(i)*limit/60, now)
}
if v := uint64(l.tick(limit, now)); v > mb(100) || v < mb(1) {
t.Errorf("[t=%0.f] limit = %dMb/s, want 1-100Mb/s", now.Sub(start).Seconds(), v/1024/1024)
}
// Lets imagine we are at limits for 10s. Does the limit drop pretty hard?
for i := 0; i < 10; i++ {
now = now.Add(time.Second)
l.tick(limit, now)
}
if v := uint64(l.tick(limit, now)); v > mb(20) || v < mb(1) {
t.Errorf("[t=%0.f] limit = %dMb/s, want 1-20Mb/s", now.Sub(start).Seconds(), v/1024/1024)
}
}

View File

@@ -4,8 +4,6 @@
#! /bin/sh
set -m # enable job control
export PATH=$PATH:/tailscale/bin
TS_AUTH_KEY="${TS_AUTH_KEY:-}"
@@ -60,8 +58,16 @@ if [[ ! -z "${TS_TAILSCALED_EXTRA_ARGS}" ]]; then
TAILSCALED_ARGS="${TAILSCALED_ARGS} ${TS_TAILSCALED_EXTRA_ARGS}"
fi
handler() {
echo "Caught SIGINT/SIGTERM, shutting down tailscaled"
kill -s SIGINT $PID
wait ${PID}
}
echo "Starting tailscaled"
tailscaled ${TAILSCALED_ARGS} &
PID=$!
trap handler SIGINT SIGTERM
UP_ARGS="--accept-dns=${TS_ACCEPT_DNS}"
if [[ ! -z "${TS_ROUTES}" ]]; then
@@ -82,4 +88,5 @@ if [[ ! -z "${TS_DEST_IP}" ]]; then
iptables -t nat -I PREROUTING -d "$(tailscale --socket=/tmp/tailscaled.sock ip -4)" -j DNAT --to-destination "${TS_DEST_IP}"
fi
fg
echo "Waiting for tailscaled to exit"
wait ${PID}

View File

@@ -155,3 +155,14 @@ func SSHPolicyFile() string { return String("TS_DEBUG_SSH_POLICY_FILE") }
// SSHIgnoreTailnetPolicy is whether to ignore the Tailnet SSH policy for development.
func SSHIgnoreTailnetPolicy() bool { return Bool("TS_DEBUG_SSH_IGNORE_TAILNET_POLICY") }
// NoLogsNoSupport reports whether the client's opted out of log uploads and
// technical support.
func NoLogsNoSupport() bool {
return Bool("TS_NO_LOGS_NO_SUPPORT")
}
// SetNoLogsNoSupport enables no-logs-no-support mode.
func SetNoLogsNoSupport() {
os.Setenv("TS_NO_LOGS_NO_SUPPORT", "true")
}

8
go.mod
View File

@@ -63,9 +63,9 @@ require (
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
golang.org/x/tools v0.1.11
golang.zx2c4.com/wireguard v0.0.0-20220703234212-c31a7b1ab478
golang.zx2c4.com/wireguard/windows v0.4.10
gvisor.dev/gvisor v0.0.0-20220801230058-850e42eb4444
golang.zx2c4.com/wireguard v0.0.0-20220904105730-b51010ba13f0
golang.zx2c4.com/wireguard/windows v0.5.3
gvisor.dev/gvisor v0.0.0-20220817001344-846276b3dbc5
honnef.co/go/tools v0.4.0-0.dev.0.20220404092545-59d7a2877f83
inet.af/peercred v0.0.0-20210906144145-0893ea02156a
inet.af/wf v0.0.0-20220728202103-50d96caab2f6
@@ -266,7 +266,7 @@ require (
github.com/yeya24/promlinter v0.1.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20220328175248-053ad81199eb // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/text v0.3.8-0.20211105212822-18b340fc7af2 // indirect
golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/ini.v1 v1.66.2 // indirect

20
go.sum
View File

@@ -729,8 +729,6 @@ github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
@@ -1352,7 +1350,6 @@ golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -1449,7 +1446,6 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201109165425-215b40eba54c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1508,8 +1504,9 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8-0.20211105212822-18b340fc7af2 h1:GLw7MR8AfAG2GmGcmVgObFOHXYypgGjnGno25RDwn3Y=
golang.org/x/text v0.3.8-0.20211105212822-18b340fc7af2/go.mod h1:EFNZuWvGYxIRUEX+K8UmCFwYmZjqcrnq15ZuVldZkZ0=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1636,11 +1633,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 h1:Ug9qvr1myri/zFN6xL17LSCBGFDnphBBhzmILHsM5TY=
golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20210905140043-2ef39d47540c/go.mod h1:laHzsbfMhGSobUmruXWAyMKKHSqvIcrqZJMyHD+/3O8=
golang.zx2c4.com/wireguard v0.0.0-20220703234212-c31a7b1ab478 h1:vDy//hdR+GnROE3OdYbQKt9rdtNdHkDtONvpRwmls/0=
golang.zx2c4.com/wireguard v0.0.0-20220703234212-c31a7b1ab478/go.mod h1:bVQfyl2sCM/QIIGHpWbFGfHPuDvqnCNkT6MQLTCjO/U=
golang.zx2c4.com/wireguard/windows v0.4.10 h1:HmjzJnb+G4NCdX+sfjsQlsxGPuYaThxRbZUZFLyR0/s=
golang.zx2c4.com/wireguard/windows v0.4.10/go.mod h1:v7w/8FC48tTBm1IzScDVPEEb0/GjLta+T0ybpP9UWRg=
golang.zx2c4.com/wireguard v0.0.0-20220904105730-b51010ba13f0 h1:5ZkdpbduT/g+9OtbSDvbF3KvfQG45CtH/ppO8FUmvCQ=
golang.zx2c4.com/wireguard v0.0.0-20220904105730-b51010ba13f0/go.mod h1:enML0deDxY1ux+B6ANGiwtg0yAJi1rctkTpcHNAVPyg=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@@ -1820,8 +1816,8 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
gvisor.dev/gvisor v0.0.0-20220801230058-850e42eb4444 h1:0d3ygmOM5RgQB8rmsZNeAY/7Q98fKt1HrGO2XIp4pDI=
gvisor.dev/gvisor v0.0.0-20220801230058-850e42eb4444/go.mod h1:TIvkJD0sxe8pIob3p6T8IzxXunlp6yfgktvTNp+DGNM=
gvisor.dev/gvisor v0.0.0-20220817001344-846276b3dbc5 h1:cv/zaNV0nr1mJzaeo4S5mHIm5va1W0/9J3/5prlsuRM=
gvisor.dev/gvisor v0.0.0-20220817001344-846276b3dbc5/go.mod h1:TIvkJD0sxe8pIob3p6T8IzxXunlp6yfgktvTNp+DGNM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -1 +1 @@
6dca83b256c7decd3dd6706ee47e04f21a0b935c
b13188dd36c1ad2509796ce10b6a1231b200c36a

View File

@@ -12,10 +12,12 @@ import (
"os"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"go4.org/mem"
"tailscale.com/envknob"
"tailscale.com/tailcfg"
"tailscale.com/types/opt"
"tailscale.com/util/cloudenv"
@@ -31,25 +33,69 @@ func New() *tailcfg.Hostinfo {
hostname, _ := os.Hostname()
hostname = dnsname.FirstLabel(hostname)
return &tailcfg.Hostinfo{
IPNVersion: version.Long,
Hostname: hostname,
OS: version.OS(),
OSVersion: GetOSVersion(),
Desktop: desktop(),
Package: packageTypeCached(),
GoArch: runtime.GOARCH,
GoVersion: runtime.Version(),
DeviceModel: deviceModel(),
Cloud: string(cloudenv.Get()),
IPNVersion: version.Long,
Hostname: hostname,
OS: version.OS(),
OSVersion: GetOSVersion(),
Container: lazyInContainer.Get(),
Distro: condCall(distroName),
DistroVersion: condCall(distroVersion),
DistroCodeName: condCall(distroCodeName),
Env: string(GetEnvType()),
Desktop: desktop(),
Package: packageTypeCached(),
GoArch: runtime.GOARCH,
GoVersion: runtime.Version(),
DeviceModel: deviceModel(),
Cloud: string(cloudenv.Get()),
NoLogsNoSupport: envknob.NoLogsNoSupport(),
}
}
// non-nil on some platforms
var (
osVersion func() string
packageType func() string
osVersion func() string
packageType func() string
distroName func() string
distroVersion func() string
distroCodeName func() string
)
func condCall[T any](fn func() T) T {
var zero T
if fn == nil {
return zero
}
return fn()
}
var (
lazyInContainer = &lazyAtomicValue[opt.Bool]{f: ptrTo(inContainer)}
)
func ptrTo[T any](v T) *T { return &v }
type lazyAtomicValue[T any] struct {
// f is a pointer to a fill function. If it's nil or points
// to nil, then Get returns the zero value for T.
f *func() T
once sync.Once
v T
}
func (v *lazyAtomicValue[T]) Get() T {
v.once.Do(v.fill)
return v.v
}
func (v *lazyAtomicValue[T]) fill() {
if v.f == nil || *v.f == nil {
return
}
v.v = (*v.f)()
}
// GetOSVersion returns the OSVersion of current host if available.
func GetOSVersion() string {
if s, _ := osVersionAtomic.Load().(string); s != "" {
@@ -179,22 +225,32 @@ func getEnvType() EnvType {
}
// inContainer reports whether we're running in a container.
func inContainer() bool {
func inContainer() opt.Bool {
if runtime.GOOS != "linux" {
return false
return ""
}
var ret opt.Bool
ret.Set(false)
if _, err := os.Stat("/.dockerenv"); err == nil {
ret.Set(true)
return ret
}
if _, err := os.Stat("/run/.containerenv"); err == nil {
// See https://github.com/cri-o/cri-o/issues/5461
ret.Set(true)
return ret
}
var ret bool
lineread.File("/proc/1/cgroup", func(line []byte) error {
if mem.Contains(mem.B(line), mem.S("/docker/")) ||
mem.Contains(mem.B(line), mem.S("/lxc/")) {
ret = true
ret.Set(true)
return io.EOF // arbitrary non-nil error to stop loop
}
return nil
})
lineread.File("/proc/mounts", func(line []byte) error {
if mem.Contains(mem.B(line), mem.S("fuse.lxcfs")) {
ret = true
ret.Set(true)
return io.EOF
}
return nil

View File

@@ -8,48 +8,58 @@
package hostinfo
import (
"fmt"
"bytes"
"os"
"os/exec"
"strings"
"golang.org/x/sys/unix"
"tailscale.com/version/distro"
)
func init() {
osVersion = osVersionFreebsd
osVersion = lazyOSVersion.Get
distroName = distroNameFreeBSD
distroVersion = distroVersionFreeBSD
}
func osVersionFreebsd() string {
un := unix.Utsname{}
var (
lazyVersionMeta = &lazyAtomicValue[versionMeta]{f: ptrTo(freebsdVersionMeta)}
lazyOSVersion = &lazyAtomicValue[string]{f: ptrTo(osVersionFreeBSD)}
)
func distroNameFreeBSD() string {
return lazyVersionMeta.Get().DistroName
}
func distroVersionFreeBSD() string {
return lazyVersionMeta.Get().DistroVersion
}
type versionMeta struct {
DistroName string
DistroVersion string
DistroCodeName string
}
func osVersionFreeBSD() string {
var un unix.Utsname
unix.Uname(&un)
return unix.ByteSliceToString(un.Release[:])
}
var attrBuf strings.Builder
attrBuf.WriteString("; version=")
attrBuf.WriteString(unix.ByteSliceToString(un.Release[:]))
attr := attrBuf.String()
version := "FreeBSD"
switch distro.Get() {
func freebsdVersionMeta() (meta versionMeta) {
d := distro.Get()
meta.DistroName = string(d)
switch d {
case distro.Pfsense:
b, _ := os.ReadFile("/etc/version")
version = fmt.Sprintf("pfSense %s", b)
meta.DistroVersion = string(bytes.TrimSpace(b))
case distro.OPNsense:
b, err := exec.Command("opnsense-version").Output()
if err == nil {
version = string(b)
} else {
version = "OPNsense"
}
b, _ := exec.Command("opnsense-version").Output()
meta.DistroVersion = string(bytes.TrimSpace(b))
case distro.TrueNAS:
b, err := os.ReadFile("/etc/version")
if err == nil {
version = string(b)
} else {
version = "TrueNAS"
}
b, _ := os.ReadFile("/etc/version")
meta.DistroVersion = string(bytes.TrimSpace(b))
}
// the /etc/version files end in a newline
return fmt.Sprintf("%s%s", strings.TrimSuffix(version, "\n"), attr)
return
}

View File

@@ -9,7 +9,6 @@ package hostinfo
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"strings"
@@ -21,14 +20,39 @@ import (
)
func init() {
osVersion = osVersionLinux
osVersion = lazyOSVersion.Get
packageType = packageTypeLinux
distroName = distroNameLinux
distroVersion = distroVersionLinux
distroCodeName = distroCodeNameLinux
if v := linuxDeviceModel(); v != "" {
SetDeviceModel(v)
}
}
var (
lazyVersionMeta = &lazyAtomicValue[versionMeta]{f: ptrTo(linuxVersionMeta)}
lazyOSVersion = &lazyAtomicValue[string]{f: ptrTo(osVersionLinux)}
)
type versionMeta struct {
DistroName string
DistroVersion string
DistroCodeName string // "jammy", etc (VERSION_CODENAME from /etc/os-release)
}
func distroNameLinux() string {
return lazyVersionMeta.Get().DistroName
}
func distroVersionLinux() string {
return lazyVersionMeta.Get().DistroVersion
}
func distroCodeNameLinux() string {
return lazyVersionMeta.Get().DistroCodeName
}
func linuxDeviceModel() string {
for _, path := range []string{
// First try the Synology-specific location.
@@ -52,15 +76,22 @@ func linuxDeviceModel() string {
func getQnapQtsVersion(versionInfo string) string {
for _, field := range strings.Fields(versionInfo) {
if suffix, ok := strs.CutPrefix(field, "QTSFW_"); ok {
return "QTS " + suffix
return suffix
}
}
return ""
}
func osVersionLinux() string {
// TODO(bradfitz,dgentry): cache this, or make caller(s) cache it.
var un unix.Utsname
unix.Uname(&un)
return unix.ByteSliceToString(un.Release[:])
}
func linuxVersionMeta() (meta versionMeta) {
dist := distro.Get()
meta.DistroName = string(dist)
propFile := "/etc/os-release"
switch dist {
case distro.Synology:
@@ -69,10 +100,12 @@ func osVersionLinux() string {
propFile = "/etc/openwrt_release"
case distro.WDMyCloud:
slurp, _ := ioutil.ReadFile("/etc/version")
return fmt.Sprintf("%s", string(bytes.TrimSpace(slurp)))
meta.DistroVersion = string(bytes.TrimSpace(slurp))
return
case distro.QNAP:
slurp, _ := ioutil.ReadFile("/etc/version_info")
return getQnapQtsVersion(string(slurp))
meta.DistroVersion = getQnapQtsVersion(string(slurp))
return
}
m := map[string]string{}
@@ -86,50 +119,45 @@ func osVersionLinux() string {
return nil
})
var un unix.Utsname
unix.Uname(&un)
var attrBuf strings.Builder
attrBuf.WriteString("; kernel=")
attrBuf.WriteString(unix.ByteSliceToString(un.Release[:]))
if inContainer() {
attrBuf.WriteString("; container")
if v := m["VERSION_CODENAME"]; v != "" {
meta.DistroCodeName = v
}
if env := GetEnvType(); env != "" {
fmt.Fprintf(&attrBuf, "; env=%s", env)
if v := m["VERSION_ID"]; v != "" {
meta.DistroVersion = v
}
attr := attrBuf.String()
id := m["ID"]
if id != "" {
meta.DistroName = id
}
switch id {
case "debian":
// Debian's VERSION_ID is just like "11". But /etc/debian_version has "11.5" normally.
// Or "bookworm/sid" on sid/testing.
slurp, _ := ioutil.ReadFile("/etc/debian_version")
return fmt.Sprintf("Debian %s (%s)%s", bytes.TrimSpace(slurp), m["VERSION_CODENAME"], attr)
case "ubuntu":
return fmt.Sprintf("Ubuntu %s%s", m["VERSION"], attr)
if v := string(bytes.TrimSpace(slurp)); v != "" {
if '0' <= v[0] && v[0] <= '9' {
meta.DistroVersion = v
} else if meta.DistroCodeName == "" {
meta.DistroCodeName = v
}
}
case "", "centos": // CentOS 6 has no /etc/os-release, so its id is ""
if cr, _ := ioutil.ReadFile("/etc/centos-release"); len(cr) > 0 { // "CentOS release 6.10 (Final)
return fmt.Sprintf("%s%s", bytes.TrimSpace(cr), attr)
}
fallthrough
case "fedora", "rhel", "alpine", "nixos":
// Their PRETTY_NAME is fine as-is for all versions I tested.
fallthrough
default:
if v := m["PRETTY_NAME"]; v != "" {
return fmt.Sprintf("%s%s", v, attr)
if meta.DistroVersion == "" {
if cr, _ := ioutil.ReadFile("/etc/centos-release"); len(cr) > 0 { // "CentOS release 6.10 (Final)
meta.DistroVersion = string(bytes.TrimSpace(cr))
}
}
}
if v := m["PRETTY_NAME"]; v != "" && meta.DistroVersion == "" && !strings.HasSuffix(v, "/sid") {
meta.DistroVersion = v
}
switch dist {
case distro.Synology:
return fmt.Sprintf("Synology %s%s", m["productversion"], attr)
meta.DistroVersion = m["productversion"]
case distro.OpenWrt:
return fmt.Sprintf("OpenWrt %s%s", m["DISTRIB_RELEASE"], attr)
case distro.Gokrazy:
return fmt.Sprintf("Gokrazy%s", attr)
meta.DistroVersion = m["DISTRIB_RELEASE"]
}
return fmt.Sprintf("Other%s", attr)
return
}
func packageTypeLinux() string {

View File

@@ -19,7 +19,7 @@ Date: 2022-05-30 16:08:45 +0800
remotes/origin/QTSFW_5.0.0`
got := getQnapQtsVersion(version_info)
want := "QTS 5.0.0"
want := "5.0.0"
if got != want {
t.Errorf("got %q; want %q", got, want)
}

View File

@@ -11,21 +11,20 @@ import (
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"tailscale.com/syncs"
"tailscale.com/util/winutil"
)
func init() {
osVersion = osVersionWindows
packageType = packageTypeWindows
osVersion = lazyOSVersion.Get
packageType = lazyPackageType.Get
}
var winVerCache syncs.AtomicValue[string]
var (
lazyOSVersion = &lazyAtomicValue[string]{f: ptrTo(osVersionWindows)}
lazyPackageType = &lazyAtomicValue[string]{f: ptrTo(packageTypeWindows)}
)
func osVersionWindows() string {
if s, ok := winVerCache.LoadOk(); ok {
return s
}
major, minor, build := windows.RtlGetNtVersionNumbers()
s := fmt.Sprintf("%d.%d.%d", major, minor, build)
// Windows 11 still uses 10 as its major number internally
@@ -34,9 +33,6 @@ func osVersionWindows() string {
s += fmt.Sprintf(".%d", ubr)
}
}
if s != "" {
winVerCache.Store(s)
}
return s // "10.0.19041.388", ideally
}

View File

@@ -48,6 +48,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
Hostname string
NotepadURLs bool
ForceDaemon bool
Egg bool
AdvertiseRoutes []netip.Prefix
NoSNAT bool
NetfilterMode preftype.NetfilterMode

View File

@@ -34,6 +34,7 @@ import (
"tailscale.com/ipn/ipnstate"
"tailscale.com/ipn/policy"
"tailscale.com/net/dns"
"tailscale.com/net/dnsfallback"
"tailscale.com/net/interfaces"
"tailscale.com/net/netutil"
"tailscale.com/net/tsaddr"
@@ -164,6 +165,7 @@ type LocalBackend struct {
authURL string // cleared on Notify
authURLSticky string // not cleared on Notify
interact bool
egg bool
prevIfState *interfaces.State
peerAPIServer *peerAPIServer // or nil
peerAPIListeners []*peerAPIListener
@@ -423,7 +425,6 @@ func (b *LocalBackend) updateStatus(sb *ipnstate.StatusBuilder, extraLocked func
s.Version = version.Long
s.BackendState = b.state.String()
s.AuthURL = b.authURLSticky
if err := health.OverallError(); err != nil {
switch e := err.(type) {
case multierr.Error:
@@ -731,6 +732,9 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) {
b.e.SetNetworkMap(st.NetMap)
b.e.SetDERPMap(st.NetMap.DERPMap)
// Update our cached DERP map
dnsfallback.UpdateCache(st.NetMap.DERPMap)
b.send(ipn.Notify{NetMap: st.NetMap})
}
if st.URL != "" {
@@ -2015,6 +2019,11 @@ func (b *LocalBackend) isDefaultServerLocked() bool {
func (b *LocalBackend) EditPrefs(mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
b.mu.Lock()
if mp.EggSet {
mp.EggSet = false
b.egg = true
go b.doSetHostinfoFilterServices(b.hostinfo.Clone())
}
p0 := b.prefs.Clone()
p1 := b.prefs.Clone()
p1.ApplyEdits(mp)
@@ -2211,6 +2220,9 @@ func (b *LocalBackend) doSetHostinfoFilterServices(hi *tailcfg.Hostinfo) {
return
}
peerAPIServices := b.peerAPIServicesLocked()
if b.egg {
peerAPIServices = append(peerAPIServices, tailcfg.Service{Proto: "egg"})
}
b.mu.Unlock()
// Make a shallow copy of hostinfo so we can mutate

View File

@@ -147,7 +147,7 @@ func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeK
SigKind: tka.SigDirect,
KeyID: signer.KeyID(),
Pubkey: p,
RotationPubkey: nodeInfo.RotationPubkey,
WrappingPubkey: nodeInfo.RotationPubkey,
}
sig.Signature, err = signer.SignNKS(sig.SigHash())
if err != nil {

View File

@@ -37,6 +37,7 @@ import (
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/localapi"
"tailscale.com/logtail/backoff"
"tailscale.com/net/dnsfallback"
"tailscale.com/net/netstat"
"tailscale.com/net/netutil"
"tailscale.com/net/tsdial"
@@ -786,6 +787,8 @@ func New(logf logger.Logf, logid string, store ipn.StateStore, eng wgengine.Engi
b.SetTailnetKeyAuthority(authority, storage)
logf("tka initialized at head %x", authority.Head())
}
dnsfallback.SetCachePath(filepath.Join(root, "derpmap.cached.json"))
} else {
logf("network-lock unavailable; no state directory")
}

View File

@@ -18,7 +18,6 @@ import (
"net/http/httputil"
"net/netip"
"net/url"
"reflect"
"runtime"
"strconv"
"strings"
@@ -26,6 +25,7 @@ import (
"time"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnstate"
@@ -34,6 +34,7 @@ import (
"tailscale.com/tka"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/util/mak"
"tailscale.com/version"
)
@@ -213,6 +214,9 @@ func (h *Handler) serveBugReport(w http.ResponseWriter, r *http.Request) {
}
logMarker := fmt.Sprintf("BUG-%v-%v-%v", h.backendLogID, time.Now().UTC().Format("20060102150405Z"), randHex(8))
if envknob.NoLogsNoSupport() {
logMarker = "BUG-NO-LOGS-NO-SUPPORT-this-node-has-had-its-logging-disabled"
}
h.logf("user bugreport: %s", logMarker)
if note := r.FormValue("note"); len(note) > 0 {
h.logf("user bugreport note: %s", note)
@@ -527,7 +531,7 @@ func (h *Handler) serveFileTargets(w http.ResponseWriter, r *http.Request) {
writeErrorJSON(w, err)
return
}
makeNonNil(&fts)
mak.NonNilSliceForJSON(&fts)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(fts)
}
@@ -858,30 +862,3 @@ func defBool(a string, def bool) bool {
}
return v
}
// makeNonNil takes a pointer to a Go data structure
// (currently only a slice or a map) and makes sure it's non-nil for
// JSON serialization. (In particular, JavaScript clients usually want
// the field to be defined after they decode the JSON.)
func makeNonNil(ptr any) {
if ptr == nil {
panic("nil interface")
}
rv := reflect.ValueOf(ptr)
if rv.Kind() != reflect.Ptr {
panic(fmt.Sprintf("kind %v, not Ptr", rv.Kind()))
}
if rv.Pointer() == 0 {
panic("nil pointer")
}
rv = rv.Elem()
if rv.Pointer() != 0 {
return
}
switch rv.Type().Kind() {
case reflect.Slice:
rv.Set(reflect.MakeSlice(rv.Type(), 0, 0))
case reflect.Map:
rv.Set(reflect.MakeMap(rv.Type()))
}
}

View File

@@ -163,6 +163,9 @@ type Prefs struct {
// for Linux/etc, which always operate in daemon mode.
ForceDaemon bool `json:"ForceDaemon,omitempty"`
// Egg is a optional debug flag.
Egg bool
// The following block of options only have an effect on Linux.
// AdvertiseRoutes specifies CIDR prefixes to advertise into the
@@ -217,6 +220,7 @@ type MaskedPrefs struct {
HostnameSet bool `json:",omitempty"`
NotepadURLsSet bool `json:",omitempty"`
ForceDaemonSet bool `json:",omitempty"`
EggSet bool `json:",omitempty"`
AdvertiseRoutesSet bool `json:",omitempty"`
NoSNATSet bool `json:",omitempty"`
NetfilterModeSet bool `json:",omitempty"`

View File

@@ -52,6 +52,7 @@ func TestPrefsEqual(t *testing.T) {
"Hostname",
"NotepadURLs",
"ForceDaemon",
"Egg",
"AdvertiseRoutes",
"NoSNAT",
"NetfilterMode",

View File

@@ -59,10 +59,10 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [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.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.3.7:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/18b340fc:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/f0f3c7e8:LICENSE))
- [golang.zx2c4.com/wireguard](https://pkg.go.dev/golang.zx2c4.com/wireguard) ([MIT](https://git.zx2c4.com/wireguard-go/tree/LICENSE?id=c31a7b1ab478))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/850e42eb4444/LICENSE))
- [golang.zx2c4.com/wireguard](https://pkg.go.dev/golang.zx2c4.com/wireguard) ([MIT](https://git.zx2c4.com/wireguard-go/tree/LICENSE?id=b51010ba13f0))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/846276b3dbc5/LICENSE))
- [inet.af/netaddr](https://pkg.go.dev/inet.af/netaddr) ([BSD-3-Clause](https://github.com/inetaf/netaddr/blob/097006376321/LICENSE))
- [nhooyr.io/websocket](https://pkg.go.dev/nhooyr.io/websocket) ([MIT](https://github.com/nhooyr/websocket/blob/v1.8.7/LICENSE.txt))
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))

View File

@@ -17,7 +17,7 @@ and [iOS][]. See also the dependencies in the [Tailscale CLI][].
- [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/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.0.1/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/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/7d93572ebe8e/LICENSE))
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/1ca156eafb9f/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/flate](https://pkg.go.dev/github.com/klauspost/compress/flate) ([Apache-2.0](https://github.com/klauspost/compress/blob/v1.15.5/LICENSE))
@@ -42,10 +42,10 @@ and [iOS][]. See also the dependencies in the [Tailscale CLI][].
- [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/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.3.7:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/18b340fc:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/f0f3c7e8:LICENSE))
- [golang.zx2c4.com/wireguard](https://pkg.go.dev/golang.zx2c4.com/wireguard) ([MIT](https://git.zx2c4.com/wireguard-go/tree/LICENSE?id=c31a7b1ab478))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/850e42eb4444/LICENSE))
- [golang.zx2c4.com/wireguard](https://pkg.go.dev/golang.zx2c4.com/wireguard) ([MIT](https://git.zx2c4.com/wireguard-go/tree/LICENSE?id=b51010ba13f0))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/846276b3dbc5/LICENSE))
- [nhooyr.io/websocket](https://pkg.go.dev/nhooyr.io/websocket) ([MIT](https://github.com/nhooyr/websocket/blob/v1.8.7/LICENSE.txt))
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))

View File

@@ -75,12 +75,12 @@ Some packages may only be included on certain architectures or operating systems
- [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.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.3.7:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/18b340fc:LICENSE))
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/f0f3c7e8: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](https://pkg.go.dev/golang.zx2c4.com/wireguard) ([MIT](https://git.zx2c4.com/wireguard-go/tree/LICENSE?id=c31a7b1ab478))
- [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))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/850e42eb4444/LICENSE))
- [golang.zx2c4.com/wireguard](https://pkg.go.dev/golang.zx2c4.com/wireguard) ([MIT](https://git.zx2c4.com/wireguard-go/tree/LICENSE?id=b51010ba13f0))
- [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.5.3))
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/846276b3dbc5/LICENSE))
- [inet.af/peercred](https://pkg.go.dev/inet.af/peercred) ([BSD-3-Clause](https://github.com/inetaf/peercred/blob/0893ea02156a/LICENSE))
- [inet.af/wf](https://pkg.go.dev/inet.af/wf) ([BSD-3-Clause](https://github.com/inetaf/wf/blob/50d96caab2f6/LICENSE))
- [nhooyr.io/websocket](https://pkg.go.dev/nhooyr.io/websocket) ([MIT](https://github.com/nhooyr/websocket/blob/v1.8.7/LICENSE.txt))

View File

@@ -36,7 +36,7 @@ Windows][]. See also the dependencies in the [Tailscale CLI][].
- [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))
- [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.5.3))
- [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/HEAD/LICENSE))

View File

@@ -438,6 +438,13 @@ func tryFixLogStateLocation(dir, cmdname string) {
// New returns a new log policy (a logger and its instance ID) for a
// given collection name.
func New(collection string) *Policy {
return NewWithConfigPath(collection, "", "")
}
// NewWithConfigPath is identical to New,
// but uses the specified directory and command name.
// If either is empty, it derives them automatically.
func NewWithConfigPath(collection, dir, cmdName string) *Policy {
var lflags int
if term.IsTerminal(2) || runtime.GOOS == "windows" {
lflags = 0
@@ -460,9 +467,12 @@ func New(collection string) *Policy {
earlyErrBuf.WriteByte('\n')
}
dir := logsDir(earlyLogf)
cmdName := version.CmdName()
if dir == "" {
dir = logsDir(earlyLogf)
}
if cmdName == "" {
cmdName = version.CmdName()
}
tryFixLogStateLocation(dir, cmdName)
cfgPath := filepath.Join(dir, fmt.Sprintf("%s.log.conf", cmdName))
@@ -539,7 +549,10 @@ func New(collection string) *Policy {
conf.IncludeProcSequence = true
}
if val := getLogTarget(); val != "" {
if envknob.NoLogsNoSupport() {
log.Println("You have disabled logging. Tailscale will not be able to provide support.")
conf.HTTPC = &http.Client{Transport: noopPretendSuccessTransport{}}
} else if val := getLogTarget(); val != "" {
log.Println("You have enabled a non-default log target. Doing without being told to by Tailscale staff or your network administrator will make getting support difficult.")
conf.BaseURL = val
u, _ := url.Parse(val)
@@ -735,3 +748,14 @@ func goVersion() string {
}
return v
}
type noopPretendSuccessTransport struct{}
func (noopPretendSuccessTransport) RoundTrip(req *http.Request) (*http.Response, error) {
io.ReadAll(req.Body)
req.Body.Close()
return &http.Response{
StatusCode: 200,
Status: "200 OK",
}, nil
}

View File

@@ -10,6 +10,7 @@ import (
"net/netip"
"sort"
"tailscale.com/net/dns/publicdns"
"tailscale.com/net/dns/resolver"
"tailscale.com/net/tsaddr"
"tailscale.com/types/dnstype"
@@ -78,13 +79,14 @@ func (c Config) hasRoutes() bool {
}
// hasDefaultIPResolversOnly reports whether the only resolvers in c are
// DefaultResolvers, and that those resolvers are simple IP addresses.
// DefaultResolvers, and that those resolvers are simple IP addresses
// that speak regular port 53 DNS.
func (c Config) hasDefaultIPResolversOnly() bool {
if !c.hasDefaultResolvers() || c.hasRoutes() {
return false
}
for _, r := range c.DefaultResolvers {
if ipp, ok := r.IPPort(); !ok || ipp.Port() != 53 {
if ipp, ok := r.IPPort(); !ok || ipp.Port() != 53 || publicdns.IPIsDoHOnlyServer(ipp.Addr()) {
return false
}
}

View File

@@ -194,6 +194,7 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
routes[suffix] = resolvers
}
}
// Similarly, the OS always gets search paths.
ocfg.SearchDomains = cfg.SearchDomains
if runtime.GOOS == "windows" {

View File

@@ -562,6 +562,42 @@ func TestManager(t *testing.T) {
"bradfitz.ts.com.", "2.3.4.5"),
},
},
{
name: "corp-v6",
in: Config{
DefaultResolvers: mustRes("1::1"),
},
os: OSConfig{
Nameservers: mustIPs("1::1"),
},
},
{
// This one's structurally the same as the previous one (corp-v6), but
// instead of 1::1 as the IPv6 address, it uses a NextDNS IPv6 address which
// is specially recognized.
name: "corp-v6-nextdns",
in: Config{
DefaultResolvers: mustRes("2a07:a8c0::c3:a884"),
},
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
},
rs: resolver.Config{
Routes: upstreams(".", "2a07:a8c0::c3:a884"),
},
},
{
name: "nextdns-doh",
in: Config{
DefaultResolvers: mustRes("https://dns.nextdns.io/c3a884"),
},
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
},
rs: resolver.Config{
Routes: upstreams(".", "https://dns.nextdns.io/c3a884"),
},
},
}
trIP := cmp.Transformer("ipStr", func(ip netip.Addr) string { return ip.String() })

View File

@@ -485,11 +485,7 @@ func (m windowsManager) getBasePrimaryResolver() (resolvers []netip.Addr, err er
}
ipLoop:
for _, stdip := range ips {
ip, ok := netip.AddrFromSlice(stdip)
if !ok {
continue
}
for _, ip := range ips {
ip = ip.Unmap()
// Skip IPv6 site-local resolvers. These are an ancient
// and obsolete IPv6 RFC, which Windows still faithfully

View File

@@ -7,26 +7,109 @@
package publicdns
import (
"bytes"
"encoding/hex"
"fmt"
"net/netip"
"sort"
"strings"
"sync"
"tailscale.com/util/strs"
)
var knownDoH = map[netip.Addr]string{} // 8.8.8.8 => "https://..."
// dohOfIP maps from public DNS IPs to their DoH base URL.
//
// This does not include NextDNS which is handled specially.
var dohOfIP = map[netip.Addr]string{} // 8.8.8.8 => "https://..."
var dohIPsOfBase = map[string][]netip.Addr{}
var populateOnce sync.Once
// KnownDoH returns a map of well-known public DNS IPs to their DoH URL.
// The returned map should not be modified.
func KnownDoH() map[netip.Addr]string {
// DoHEndpointFromIP returns the DNS-over-HTTPS base URL for a given IP
// and whether it's DoH-only (not speaking DNS on port 53).
//
// The ok result is whether the IP is a known DNS server.
func DoHEndpointFromIP(ip netip.Addr) (dohBase string, dohOnly bool, ok bool) {
populateOnce.Do(populate)
return knownDoH
if b, ok := dohOfIP[ip]; ok {
return b, false, true
}
// NextDNS DoH URLs are of the form "https://dns.nextdns.io/c3a884"
// where the path component is the lower 8 bytes of the IPv6 address
// in lowercase hex without any zero padding.
if nextDNSv6RangeA.Contains(ip) || nextDNSv6RangeB.Contains(ip) {
a := ip.As16()
var sb strings.Builder
const base = "https://dns.nextdns.io/"
sb.Grow(len(base) + 8)
sb.WriteString(base)
for _, b := range bytes.TrimLeft(a[8:], "\x00") {
fmt.Fprintf(&sb, "%02x", b)
}
return sb.String(), true, true
}
return "", false, false
}
// DoHIPsOfBase returns a map of DNS server IP addresses keyed
// by their DoH URL. It is the inverse of KnownDoH.
func DoHIPsOfBase() map[string][]netip.Addr {
// KnownDoHPrefixes returns the list of DoH base URLs.
//
// It returns a new copy each time, sorted. It's meant for tests.
//
// It does not include providers that have customer-specific DoH URLs like
// NextDNS.
func KnownDoHPrefixes() []string {
populateOnce.Do(populate)
return dohIPsOfBase
ret := make([]string, 0, len(dohIPsOfBase))
for b := range dohIPsOfBase {
ret = append(ret, b)
}
sort.Strings(ret)
return ret
}
func isSlashOrQuestionMark(r rune) bool {
return r == '/' || r == '?'
}
// DoHIPsOfBase returns the IP addresses to use to dial the provided DoH base
// URL.
//
// It is basically the inverse of DoHEndpointFromIP with the exception that for
// NextDNS it returns IPv4 addresses that DoHEndpointFromIP doesn't map back.
func DoHIPsOfBase(dohBase string) []netip.Addr {
populateOnce.Do(populate)
if s := dohIPsOfBase[dohBase]; len(s) > 0 {
return s
}
if hexStr, ok := strs.CutPrefix(dohBase, "https://dns.nextdns.io/"); ok {
// The path is of the form /<profile-hex>[/<hostname>/<model>/<device id>...]
// or /<profile-hex>?<query params>
// but only the <profile-hex> is required. Ignore the rest:
if i := strings.IndexFunc(hexStr, isSlashOrQuestionMark); i != -1 {
hexStr = hexStr[:i]
}
// TODO(bradfitz): using the NextDNS anycast addresses works but is not
// ideal. Some of their regions have better latency via a non-anycast IP
// which we could get by first resolving A/AAAA "dns.nextdns.io" over
// DoH using their anycast address. For now we only use the anycast
// addresses. The IPv4 IPs we use are just the first one in their ranges.
// For IPv6 we put the profile ID in the lower bytes, but that seems just
// conventional for them and not required (it'll already be in the DoH path).
// (Really we shouldn't use either IPv4 or IPv6 anycast for DoH once we
// resolve "dns.nextdns.io".)
if b, err := hex.DecodeString(hexStr); err == nil && len(b) <= 8 && len(b) > 0 {
return []netip.Addr{
nextDNSv4One,
nextDNSv4Two,
nextDNSv6Gen(nextDNSv6RangeA.Addr(), b),
nextDNSv6Gen(nextDNSv6RangeB.Addr(), b),
}
}
}
return nil
}
// DoHV6 returns the first IPv6 DNS address from a given public DNS provider
@@ -45,7 +128,7 @@ func DoHV6(base string) (ip netip.Addr, ok bool) {
// adds it to both knownDoH and dohIPsOFBase maps.
func addDoH(ipStr, base string) {
ip := netip.MustParseAddr(ipStr)
knownDoH[ip] = base
dohOfIP[ip] = base
dohIPsOfBase[base] = append(dohIPsOfBase[base], ip)
}
@@ -106,3 +189,43 @@ func populate() {
addDoH("193.19.108.3", "https://adblock.doh.mullvad.net/dns-query")
addDoH("2a07:e340::3", "https://adblock.doh.mullvad.net/dns-query")
}
var (
// The NextDNS IPv6 ranges (primary and secondary). The customer ID is
// encoded in the lower bytes and is used (in hex form) as the DoH query
// path.
nextDNSv6RangeA = netip.MustParsePrefix("2a07:a8c0::/33")
nextDNSv6RangeB = netip.MustParsePrefix("2a07:a8c1::/33")
// The first two IPs in the /24 v4 ranges can be used for DoH to NextDNS.
//
// They're Anycast and usually okay, but NextDNS has some locations that
// don't do BGP and can get results for querying them over DoH to find the
// IPv4 address of "dns.mynextdns.io" and find an even better result.
//
// Note that the Tailscale DNS client does not do any of the "IP address
// linking" that NextDNS can do with its IPv4 addresses. These addresses
// are only used for DoH.
nextDNSv4RangeA = netip.MustParsePrefix("45.90.28.0/24")
nextDNSv4RangeB = netip.MustParsePrefix("45.90.30.0/24")
nextDNSv4One = nextDNSv4RangeA.Addr()
nextDNSv4Two = nextDNSv4RangeB.Addr()
)
// nextDNSv6Gen generates a NextDNS IPv6 address from the upper 8 bytes in the
// provided ip and using id as the lowest 0-8 bytes.
func nextDNSv6Gen(ip netip.Addr, id []byte) netip.Addr {
if len(id) > 8 {
return netip.Addr{}
}
a := ip.As16()
copy(a[16-len(id):], id)
return netip.AddrFrom16(a)
}
// IPIsDoHOnlyServer reports whether ip is a DNS server that should only use
// DNS-over-HTTPS (not regular port 53 DNS).
func IPIsDoHOnlyServer(ip netip.Addr) bool {
return nextDNSv6RangeA.Contains(ip) || nextDNSv6RangeB.Contains(ip) ||
nextDNSv4RangeA.Contains(ip) || nextDNSv4RangeB.Contains(ip)
}

View File

@@ -6,20 +6,30 @@ package publicdns
import (
"net/netip"
"reflect"
"testing"
)
func TestInit(t *testing.T) {
for baseKey, baseSet := range DoHIPsOfBase() {
for _, baseKey := range KnownDoHPrefixes() {
baseSet := DoHIPsOfBase(baseKey)
for _, addr := range baseSet {
if KnownDoH()[addr] != baseKey {
t.Errorf("Expected %v to map to %s, got %s", addr, baseKey, KnownDoH()[addr])
back, only, ok := DoHEndpointFromIP(addr)
if !ok {
t.Errorf("DoHEndpointFromIP(%v) not mapped back to %v", addr, baseKey)
continue
}
if only {
t.Errorf("unexpected DoH only bit set for %v", addr)
}
if back != baseKey {
t.Errorf("Expected %v to map to %s, got %s", addr, baseKey, back)
}
}
}
}
func TestDohV6(t *testing.T) {
func TestDoHV6(t *testing.T) {
tests := []struct {
in string
firstIP netip.Addr
@@ -38,3 +48,67 @@ func TestDohV6(t *testing.T) {
})
}
}
func TestDoHIPsOfBase(t *testing.T) {
ips := func(s ...string) (ret []netip.Addr) {
for _, ip := range s {
ret = append(ret, netip.MustParseAddr(ip))
}
return
}
tests := []struct {
base string
want []netip.Addr
}{
{
base: "https://cloudflare-dns.com/dns-query",
want: ips("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001"),
},
{
base: "https://dns.nextdns.io/",
want: ips(),
},
{
base: "https://dns.nextdns.io/ff",
want: ips(
"45.90.28.0",
"45.90.30.0",
"2a07:a8c0::ff",
"2a07:a8c1::ff",
),
},
{
base: "https://dns.nextdns.io/c3a884",
want: ips(
"45.90.28.0",
"45.90.30.0",
"2a07:a8c0::c3:a884",
"2a07:a8c1::c3:a884",
),
},
{
base: "https://dns.nextdns.io/c3a884/with/more/stuff",
want: ips(
"45.90.28.0",
"45.90.30.0",
"2a07:a8c0::c3:a884",
"2a07:a8c1::c3:a884",
),
},
{
base: "https://dns.nextdns.io/c3a884?with=query&params",
want: ips(
"45.90.28.0",
"45.90.30.0",
"2a07:a8c0::c3:a884",
"2a07:a8c1::c3:a884",
),
},
}
for _, tt := range tests {
got := DoHIPsOfBase(tt.base)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("DoHIPsOfBase(%q) = %v; want %v", tt.base, got, tt.want)
}
}
}

View File

@@ -205,7 +205,7 @@ func (m *resolvedManager) run(ctx context.Context) {
// When ctx goes away systemd-resolved auto reverts.
// Keeping for potential use in future refactor.
if call := rManager.CallWithContext(ctx, dbusResolvedInterface+".RevertLink", 0, m.ifidx); call.Err != nil {
m.logf("[v1] RevertLink: %w", call.Err)
m.logf("[v1] RevertLink: %v", call.Err)
return
}
return

View File

@@ -41,7 +41,8 @@ func TestDoH(t *testing.T) {
if !*testDoH {
t.Skip("skipping manual test without --test-doh flag")
}
if len(publicdns.KnownDoH()) == 0 {
prefixes := publicdns.KnownDoHPrefixes()
if len(prefixes) == 0 {
t.Fatal("no known DoH")
}
@@ -49,7 +50,7 @@ func TestDoH(t *testing.T) {
dohSem: make(chan struct{}, 10),
}
for urlBase := range publicdns.DoHIPsOfBase() {
for _, urlBase := range prefixes {
t.Run(urlBase, func(t *testing.T) {
c, ok := f.getKnownDoHClientForProvider(urlBase)
if !ok {
@@ -86,13 +87,15 @@ func TestDoH(t *testing.T) {
}
func TestDoHV6Fallback(t *testing.T) {
for ip, base := range publicdns.KnownDoH() {
if ip.Is4() {
ip6, ok := publicdns.DoHV6(base)
if !ok {
t.Errorf("no v6 DoH known for %v", ip)
} else if !ip6.Is6() {
t.Errorf("dohV6(%q) returned non-v6 address %v", base, ip6)
for _, base := range publicdns.KnownDoHPrefixes() {
for _, ip := range publicdns.DoHIPsOfBase(base) {
if ip.Is4() {
ip6, ok := publicdns.DoHV6(base)
if !ok {
t.Errorf("no v6 DoH known for %v", ip)
} else if !ip6.Is6() {
t.Errorf("dohV6(%q) returned non-v6 address %v", base, ip6)
}
}
}
}

View File

@@ -37,6 +37,7 @@ import (
"tailscale.com/types/nettype"
"tailscale.com/util/cloudenv"
"tailscale.com/util/dnsname"
"tailscale.com/version"
"tailscale.com/wgengine/monitor"
)
@@ -57,9 +58,6 @@ func truncatedFlagSet(pkt []byte) bool {
}
const (
// responseTimeout is the maximal amount of time to wait for a DNS response.
responseTimeout = 5 * time.Second
// dohTransportTimeout is how long to keep idle HTTP
// connections open to DNS-over-HTTPs servers. This is pretty
// arbitrary.
@@ -259,18 +257,26 @@ func (f *forwarder) Close() error {
func resolversWithDelays(resolvers []*dnstype.Resolver) []resolverAndDelay {
rr := make([]resolverAndDelay, 0, len(resolvers)+2)
type dohState uint8
const addedDoH = dohState(1)
const addedDoHAndDontAddUDP = dohState(2)
// Add the known DoH ones first, starting immediately.
didDoH := map[string]bool{}
didDoH := map[string]dohState{}
for _, r := range resolvers {
ipp, ok := r.IPPort()
if !ok {
continue
}
dohBase, ok := publicdns.KnownDoH()[ipp.Addr()]
if !ok || didDoH[dohBase] {
dohBase, dohOnly, ok := publicdns.DoHEndpointFromIP(ipp.Addr())
if !ok || didDoH[dohBase] != 0 {
continue
}
didDoH[dohBase] = true
if dohOnly {
didDoH[dohBase] = addedDoHAndDontAddUDP
} else {
didDoH[dohBase] = addedDoH
}
rr = append(rr, resolverAndDelay{name: &dnstype.Resolver{Addr: dohBase}})
}
@@ -289,8 +295,12 @@ func resolversWithDelays(resolvers []*dnstype.Resolver) []resolverAndDelay {
}
ip := ipp.Addr()
var startDelay time.Duration
if host, ok := publicdns.KnownDoH()[ip]; ok {
if host, _, ok := publicdns.DoHEndpointFromIP(ip); ok {
if didDoH[host] == addedDoHAndDontAddUDP {
continue
}
// We already did the DoH query early. These
// are for normal dns53 UDP queries.
startDelay = dohHeadStart
key := hostAndFam{host, uint8(ip.BitLen())}
if done[key] > 0 {
@@ -391,7 +401,7 @@ func (f *forwarder) getKnownDoHClientForProvider(urlBase string) (c *http.Client
if c, ok := f.dohClient[urlBase]; ok {
return c, true
}
allIPs := publicdns.DoHIPsOfBase()[urlBase]
allIPs := publicdns.DoHIPsOfBase(urlBase)
if len(allIPs) == 0 {
return nil, false
}
@@ -407,7 +417,7 @@ func (f *forwarder) getKnownDoHClientForProvider(urlBase string) (c *http.Client
c = &http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
IdleConnTimeout: dohTransportTimeout,
IdleConnTimeout: dohTransportTimeout,
DialContext: func(ctx context.Context, netw, addr string) (net.Conn, error) {
if !strings.HasPrefix(netw, "tcp") {
return nil, fmt.Errorf("unexpected network %q", netw)
@@ -447,11 +457,8 @@ func (f *forwarder) sendDoH(ctx context.Context, urlBase string, c *http.Client,
return nil, err
}
req.Header.Set("Content-Type", dohType)
// Note: we don't currently set the Accept header (which is
// only a SHOULD in the spec) as iOS doesn't use HTTP/2 and
// we'd rather save a few bytes on outgoing requests when
// empirically no provider cares about the Accept header's
// absence.
req.Header.Set("Accept", dohType)
req.Header.Set("User-Agent", "tailscaled/"+version.Long)
hres, err := c.Do(req)
if err != nil {

View File

@@ -79,6 +79,16 @@ func TestResolversWithDelays(t *testing.T) {
in: q("9.9.9.9", "2620:fe::fe"),
want: o("https://dns.quad9.net/dns-query", "9.9.9.9+0.5s", "2620:fe::fe+0.5s"),
},
{
name: "nextdns-ipv6-expand",
in: q("2a07:a8c0::c3:a884"),
want: o("https://dns.nextdns.io/c3a884"),
},
{
name: "nextdns-doh-input",
in: q("https://dns.nextdns.io/c3a884"),
want: o("https://dns.nextdns.io/c3a884"),
},
}
for _, tt := range tests {

View File

@@ -25,6 +25,8 @@ import (
"tailscale.com/util/singleflight"
)
var zaddr netip.Addr
var single = &Resolver{
Forward: &net.Resolver{PreferGo: preferGoResolver()},
}
@@ -90,14 +92,14 @@ type Resolver struct {
// ipRes is the type used by the Resolver.sf singleflight group.
type ipRes struct {
ip, ip6 net.IP
allIPs []net.IPAddr
ip, ip6 netip.Addr
allIPs []netip.Addr
}
type ipCacheEntry struct {
ip net.IP // either v4 or v6
ip6 net.IP // nil if no v4 or no v6
allIPs []net.IPAddr // 1+ v4 and/or v6
ip netip.Addr // either v4 or v6
ip6 netip.Addr // nil if no v4 or no v6
allIPs []netip.Addr // 1+ v4 and/or v6
expires time.Time
}
@@ -147,34 +149,28 @@ var debug = envknob.Bool("TS_DEBUG_DNS_CACHE")
//
// If err is nil, ip will be non-nil. The v6 address may be nil even
// with a nil error.
func (r *Resolver) LookupIP(ctx context.Context, host string) (ip, v6 net.IP, allIPs []net.IPAddr, err error) {
func (r *Resolver) LookupIP(ctx context.Context, host string) (ip, v6 netip.Addr, allIPs []netip.Addr, err error) {
if r.SingleHostStaticResult != nil {
if r.SingleHost != host {
return nil, nil, nil, fmt.Errorf("dnscache: unexpected hostname %q doesn't match expected %q", host, r.SingleHost)
return zaddr, zaddr, nil, fmt.Errorf("dnscache: unexpected hostname %q doesn't match expected %q", host, r.SingleHost)
}
for _, naIP := range r.SingleHostStaticResult {
ipa := &net.IPAddr{
IP: naIP.AsSlice(),
Zone: naIP.Zone(),
if !ip.IsValid() && naIP.Is4() {
ip = naIP
}
if ip == nil && naIP.Is4() {
ip = ipa.IP
if !v6.IsValid() && naIP.Is6() {
v6 = naIP
}
if v6 == nil && naIP.Is6() {
v6 = ipa.IP
}
allIPs = append(allIPs, *ipa)
allIPs = append(allIPs, naIP)
}
return
}
if ip := net.ParseIP(host); ip != nil {
if ip4 := ip.To4(); ip4 != nil {
return ip4, nil, []net.IPAddr{{IP: ip4}}, nil
}
if ip, err := netip.ParseAddr(host); err == nil {
ip = ip.Unmap()
if debug {
log.Printf("dnscache: %q is an IP", host)
}
return ip, nil, []net.IPAddr{{IP: ip}}, nil
return ip, zaddr, []netip.Addr{ip}, nil
}
if ip, ip6, allIPs, ok := r.lookupIPCache(host); ok {
@@ -205,7 +201,7 @@ func (r *Resolver) LookupIP(ctx context.Context, host string) (ip, v6 net.IP, al
if debug {
log.Printf("dnscache: error resolving %q: %v", host, res.Err)
}
return nil, nil, nil, res.Err
return zaddr, zaddr, nil, res.Err
}
r := res.Val
return r.ip, r.ip6, r.allIPs, nil
@@ -213,26 +209,26 @@ func (r *Resolver) LookupIP(ctx context.Context, host string) (ip, v6 net.IP, al
if debug {
log.Printf("dnscache: context done while resolving %q: %v", host, ctx.Err())
}
return nil, nil, nil, ctx.Err()
return zaddr, zaddr, nil, ctx.Err()
}
}
func (r *Resolver) lookupIPCache(host string) (ip, ip6 net.IP, allIPs []net.IPAddr, ok bool) {
func (r *Resolver) lookupIPCache(host string) (ip, ip6 netip.Addr, allIPs []netip.Addr, ok bool) {
r.mu.Lock()
defer r.mu.Unlock()
if ent, ok := r.ipCache[host]; ok && ent.expires.After(time.Now()) {
return ent.ip, ent.ip6, ent.allIPs, true
}
return nil, nil, nil, false
return zaddr, zaddr, nil, false
}
func (r *Resolver) lookupIPCacheExpired(host string) (ip, ip6 net.IP, allIPs []net.IPAddr, ok bool) {
func (r *Resolver) lookupIPCacheExpired(host string) (ip, ip6 netip.Addr, allIPs []netip.Addr, ok bool) {
r.mu.Lock()
defer r.mu.Unlock()
if ent, ok := r.ipCache[host]; ok {
return ent.ip, ent.ip6, ent.allIPs, true
}
return nil, nil, nil, false
return zaddr, zaddr, nil, false
}
func (r *Resolver) lookupTimeoutForHost(host string) time.Duration {
@@ -252,7 +248,7 @@ func (r *Resolver) lookupTimeoutForHost(host string) time.Duration {
return 10 * time.Second
}
func (r *Resolver) lookupIP(host string) (ip, ip6 net.IP, allIPs []net.IPAddr, err error) {
func (r *Resolver) lookupIP(host string) (ip, ip6 netip.Addr, allIPs []netip.Addr, err error) {
if ip, ip6, allIPs, ok := r.lookupIPCache(host); ok {
if debug {
log.Printf("dnscache: %q found in cache as %v", host, ip)
@@ -262,47 +258,37 @@ func (r *Resolver) lookupIP(host string) (ip, ip6 net.IP, allIPs []net.IPAddr, e
ctx, cancel := context.WithTimeout(context.Background(), r.lookupTimeoutForHost(host))
defer cancel()
ips, err := r.fwd().LookupIPAddr(ctx, host)
ips, err := r.fwd().LookupNetIP(ctx, "ip", host)
if err != nil || len(ips) == 0 {
if resolver, ok := r.cloudHostResolver(); ok {
ips, err = resolver.LookupIPAddr(ctx, host)
ips, err = resolver.LookupNetIP(ctx, "ip", host)
}
}
if (err != nil || len(ips) == 0) && r.LookupIPFallback != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var fips []netip.Addr
fips, err = r.LookupIPFallback(ctx, host)
if err == nil {
ips = nil
for _, fip := range fips {
ips = append(ips, net.IPAddr{
IP: fip.AsSlice(),
Zone: fip.Zone(),
})
}
}
ips, err = r.LookupIPFallback(ctx, host)
}
if err != nil {
return nil, nil, nil, err
return netip.Addr{}, netip.Addr{}, nil, err
}
if len(ips) == 0 {
return nil, nil, nil, fmt.Errorf("no IPs for %q found", host)
return netip.Addr{}, netip.Addr{}, nil, fmt.Errorf("no IPs for %q found", host)
}
have4 := false
for _, ipa := range ips {
if ip4 := ipa.IP.To4(); ip4 != nil {
if ipa.Is4() {
if !have4 {
ip6 = ip
ip = ip4
ip = ipa
have4 = true
}
} else {
if have4 {
ip6 = ipa.IP
ip6 = ipa
} else {
ip = ipa.IP
ip = ipa
}
}
}
@@ -310,7 +296,7 @@ func (r *Resolver) lookupIP(host string) (ip, ip6 net.IP, allIPs []net.IPAddr, e
return ip, ip6, ips, nil
}
func (r *Resolver) addIPCache(host string, ip, ip6 net.IP, allIPs []net.IPAddr, d time.Duration) {
func (r *Resolver) addIPCache(host string, ip, ip6 netip.Addr, allIPs []netip.Addr, d time.Duration) {
if ip.IsPrivate() {
// Don't cache obviously wrong entries from captive portals.
// TODO: use DoH or DoT for the forwarding resolver?
@@ -375,7 +361,7 @@ func (d *dialer) DialContext(ctx context.Context, network, address string) (retC
defer func() {
// On failure, consider that our DNS might be wrong and ask the DNS fallback mechanism for
// some other IPs to try.
if ret == nil || ctx.Err() != nil || d.dnsCache.LookupIPFallback == nil || dc.dnsWasTrustworthy() {
if !d.shouldTryBootstrap(ctx, ret, dc) {
return
}
ips, err := d.dnsCache.LookupIPFallback(ctx, host)
@@ -399,20 +385,12 @@ func (d *dialer) DialContext(ctx context.Context, network, address string) (retC
if debug {
log.Printf("dnscache: dialing %s, %s for %s", network, ip, address)
}
ipNA, ok := netip.AddrFromSlice(ip)
if !ok {
return nil, fmt.Errorf("invalid IP %q", ip)
}
c, err := dc.dialOne(ctx, ipNA.Unmap())
c, err := dc.dialOne(ctx, ip.Unmap())
if err == nil || ctx.Err() != nil {
return c, err
}
// Fall back to trying IPv6, if any.
ip6NA, ok := netip.AddrFromSlice(ip6)
if !ok {
return nil, err
}
return dc.dialOne(ctx, ip6NA)
return dc.dialOne(ctx, ip6)
}
// Multiple IPv4 candidates, and 0+ IPv6.
@@ -420,6 +398,40 @@ func (d *dialer) DialContext(ctx context.Context, network, address string) (retC
return dc.raceDial(ctx, ipsToTry)
}
func (d *dialer) shouldTryBootstrap(ctx context.Context, err error, dc *dialCall) bool {
// No need to do anything when we succeeded.
if err == nil {
return false
}
// Can't try bootstrap DNS if we don't have a fallback function
if d.dnsCache.LookupIPFallback == nil {
if debug {
log.Printf("dnscache: not using bootstrap DNS: no fallback")
}
return false
}
// We can't retry if the context is canceled, since any further
// operations with this context will fail.
if ctxErr := ctx.Err(); ctxErr != nil {
if debug {
log.Printf("dnscache: not using bootstrap DNS: context error: %v", ctxErr)
}
return false
}
wasTrustworthy := dc.dnsWasTrustworthy()
if wasTrustworthy {
if debug {
log.Printf("dnscache: not using bootstrap DNS: DNS was trustworthy")
}
return false
}
return true
}
// dialCall is the state around a single call to dial.
type dialCall struct {
d *dialer
@@ -610,21 +622,20 @@ func interleaveSlices[T any](a, b []T) []T {
return ret
}
func v4addrs(aa []net.IPAddr) (ret []netip.Addr) {
func v4addrs(aa []netip.Addr) (ret []netip.Addr) {
for _, a := range aa {
ip, ok := netip.AddrFromSlice(a.IP)
ip = ip.Unmap()
if ok && ip.Is4() {
ret = append(ret, ip)
a = a.Unmap()
if a.Is4() {
ret = append(ret, a)
}
}
return ret
}
func v6addrs(aa []net.IPAddr) (ret []netip.Addr) {
func v6addrs(aa []netip.Addr) (ret []netip.Addr) {
for _, a := range aa {
if ip, ok := netip.AddrFromSlice(a.IP); ok && ip.Is6() {
ret = append(ret, ip)
if a.Is6() && !a.Is4In6() {
ret = append(ret, a)
}
}
return ret

View File

@@ -131,7 +131,7 @@ func TestResolverAllHostStaticResult(t *testing.T) {
if got, want := ip6.String(), "2001:4860:4860::8888"; got != want {
t.Errorf("ip4 got %q; want %q", got, want)
}
if got, want := fmt.Sprintf("%q", allIPs), `[{"2001:4860:4860::8888" ""} {"2001:4860:4860::8844" ""} {"8.8.8.8" ""} {"8.8.4.4" ""}]`; got != want {
if got, want := fmt.Sprintf("%q", allIPs), `["2001:4860:4860::8888" "2001:4860:4860::8844" "8.8.8.8" "8.8.4.4"]`; got != want {
t.Errorf("allIPs got %q; want %q", got, want)
}
@@ -164,3 +164,104 @@ func TestInterleaveSlices(t *testing.T) {
})
}
}
func TestShouldTryBootstrap(t *testing.T) {
oldDebug := debug
t.Cleanup(func() {
debug = oldDebug
})
debug = true
type step struct {
ip netip.Addr // IP we pretended to dial
err error // the dial error or nil for success
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
deadlineExceeded, cancel := context.WithTimeout(context.Background(), 0)
defer cancel()
ctx := context.Background()
errFailed := errors.New("some failure")
cacheWithFallback := &Resolver{
LookupIPFallback: func(_ context.Context, _ string) ([]netip.Addr, error) {
panic("unimplemented")
},
}
cacheNoFallback := &Resolver{}
testCases := []struct {
name string
steps []step
ctx context.Context
err error
noFallback bool
want bool
}{
{
name: "no-error",
ctx: ctx,
err: nil,
want: false,
},
{
name: "canceled",
ctx: canceled,
err: errFailed,
want: false,
},
{
name: "deadline-exceeded",
ctx: deadlineExceeded,
err: errFailed,
want: false,
},
{
name: "no-fallback",
ctx: ctx,
err: errFailed,
noFallback: true,
want: false,
},
{
name: "dns-was-trustworthy",
ctx: ctx,
err: errFailed,
steps: []step{
{netip.MustParseAddr("2003::1"), nil},
{netip.MustParseAddr("2003::1"), errFailed},
},
want: false,
},
{
name: "should-bootstrap",
ctx: ctx,
err: errFailed,
want: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
d := &dialer{
pastConnect: map[netip.Addr]time.Time{},
}
if tt.noFallback {
d.dnsCache = cacheNoFallback
} else {
d.dnsCache = cacheWithFallback
}
dc := &dialCall{d: d}
for _, st := range tt.steps {
dc.noteDialResult(st.ip, st.err)
}
got := d.shouldTryBootstrap(tt.ctx, tt.err, dc)
if got != tt.want {
t.Errorf("got %v; want %v", got, tt.want)
}
})
}
}

View File

@@ -20,12 +20,18 @@ import (
"net/http"
"net/netip"
"net/url"
"os"
"reflect"
"sync/atomic"
"time"
"tailscale.com/atomicfile"
"tailscale.com/net/netns"
"tailscale.com/net/tlsdial"
"tailscale.com/net/tshttpproxy"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
)
func Lookup(ctx context.Context, host string) ([]netip.Addr, error) {
@@ -39,6 +45,7 @@ func Lookup(ctx context.Context, host string) ([]netip.Addr, error) {
}
dm := getDERPMap()
var cands4, cands6 []nameIP
for _, dr := range dm.Regions {
for _, n := range dr.Nodes {
@@ -72,16 +79,16 @@ func Lookup(ctx context.Context, host string) ([]netip.Addr, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
log.Printf("trying bootstrapDNS(%q, %q) for %q ...", cand.dnsName, cand.ip, host)
logf("trying bootstrapDNS(%q, %q) for %q ...", cand.dnsName, cand.ip, host)
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
dm, err := bootstrapDNSMap(ctx, cand.dnsName, cand.ip, host)
if err != nil {
log.Printf("bootstrapDNS(%q, %q) for %q error: %v", cand.dnsName, cand.ip, host, err)
logf("bootstrapDNS(%q, %q) for %q error: %v", cand.dnsName, cand.ip, host, err)
continue
}
if ips := dm[host]; len(ips) > 0 {
log.Printf("bootstrapDNS(%q, %q) for %q = %v", cand.dnsName, cand.ip, host, ips)
logf("bootstrapDNS(%q, %q) for %q = %v", cand.dnsName, cand.ip, host, ips)
return ips, nil
}
}
@@ -94,7 +101,7 @@ func Lookup(ctx context.Context, host string) ([]netip.Addr, error) {
// serverName and serverIP of are, say, "derpN.tailscale.com".
// queryName is the name being sought (e.g. "controlplane.tailscale.com"), passed as hint.
func bootstrapDNSMap(ctx context.Context, serverName string, serverIP netip.Addr, queryName string) (dnsMap, error) {
dialer := netns.NewDialer(log.Printf)
dialer := netns.NewDialer(logf)
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = tshttpproxy.ProxyFromEnvironment
tr.DialContext = func(ctx context.Context, netw, addr string) (net.Conn, error) {
@@ -128,12 +135,45 @@ type dnsMap map[string][]netip.Addr
// getDERPMap returns some DERP map. The DERP servers also run a fallback
// DNS server.
func getDERPMap() *tailcfg.DERPMap {
// TODO(bradfitz): try to read the last known DERP map from disk,
// at say /var/lib/tailscale/derpmap.txt and write it when it changes,
// and read it here.
// But ultimately the fallback will be to use a copy baked into the binary,
// which is this part:
dm := getStaticDERPMap()
// Merge in any DERP servers from the cached map that aren't in the
// static map; this ensures that we're getting new region(s) while not
// overriding the built-in fallbacks if things go horribly wrong and we
// get a bad DERP map.
//
// TODO(andrew): should we expect OmitDefaultRegions here? We're not
// forwarding traffic, just resolving DNS, so maybe we can ignore that
// value anyway?
cached := cachedDERPMap.Load()
if cached == nil {
return dm
}
for id, region := range cached.Regions {
dr, ok := dm.Regions[id]
if !ok {
dm.Regions[id] = region
continue
}
// Add any nodes that we don't already have.
seen := make(map[string]bool)
for _, n := range dr.Nodes {
seen[n.HostName] = true
}
for _, n := range region.Nodes {
if !seen[n.HostName] {
dr.Nodes = append(dr.Nodes, n)
}
}
}
return dm
}
// getStaticDERPMap returns the DERP map that was compiled into this binary.
func getStaticDERPMap() *tailcfg.DERPMap {
dm := new(tailcfg.DERPMap)
if err := json.Unmarshal(staticDERPMapJSON, dm); err != nil {
panic(err)
@@ -143,3 +183,83 @@ func getDERPMap() *tailcfg.DERPMap {
//go:embed dns-fallback-servers.json
var staticDERPMapJSON []byte
// cachedDERPMap is the path to a cached DERP map that we loaded from our on-disk cache.
var cachedDERPMap atomic.Pointer[tailcfg.DERPMap]
// cachePath is the path to the DERP map cache file, set by SetCachePath via
// ipnserver.New() if we have a state directory.
var cachePath string
// UpdateCache stores the DERP map cache back to disk.
//
// The caller must not mutate 'c' after calling this function.
func UpdateCache(c *tailcfg.DERPMap) {
// Don't do anything if nothing changed.
curr := cachedDERPMap.Load()
if reflect.DeepEqual(curr, c) {
return
}
d, err := json.Marshal(c)
if err != nil {
logf("[v1] dnsfallback: UpdateCache error marshaling: %v", err)
return
}
// Only store after we're confident this is at least valid JSON
cachedDERPMap.Store(c)
// Don't try writing if we don't have a cache path set; this can happen
// when we don't have a state path (e.g. /var/lib/tailscale) configured.
if cachePath != "" {
err = atomicfile.WriteFile(cachePath, d, 0600)
if err != nil {
logf("[v1] dnsfallback: UpdateCache error writing: %v", err)
return
}
}
}
// SetCachePath sets the path to the on-disk DERP map cache that we store and
// update. Additionally, if a file at this path exists, we load it and merge it
// with the DERP map baked into the binary.
//
// This function should be called before any calls to UpdateCache, as it is not
// concurrency-safe.
func SetCachePath(path string) {
cachePath = path
f, err := os.Open(path)
if err != nil {
logf("[v1] dnsfallback: SetCachePath error reading %q: %v", path, err)
return
}
defer f.Close()
dm := new(tailcfg.DERPMap)
if err := json.NewDecoder(f).Decode(dm); err != nil {
logf("[v1] dnsfallback: SetCachePath error decoding %q: %v", path, err)
return
}
cachedDERPMap.Store(dm)
logf("[v2] dnsfallback: SetCachePath loaded cached DERP map")
}
// logfunc stores the logging function to use for this package.
var logfunc syncs.AtomicValue[logger.Logf]
// SetLogger sets the logging function that this package will use. The default
// logger if this function is not called is 'log.Printf'.
func SetLogger(log logger.Logf) {
logfunc.Store(log)
}
func logf(format string, args ...any) {
if lf := logfunc.Load(); lf != nil {
lf(format, args...)
} else {
log.Printf(format, args...)
}
}

View File

@@ -4,7 +4,15 @@
package dnsfallback
import "testing"
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
"tailscale.com/tailcfg"
)
func TestGetDERPMap(t *testing.T) {
dm := getDERPMap()
@@ -15,3 +23,161 @@ func TestGetDERPMap(t *testing.T) {
t.Fatal("no regions")
}
}
func TestCache(t *testing.T) {
oldlog := logfunc.Load()
SetLogger(t.Logf)
t.Cleanup(func() {
SetLogger(oldlog)
})
cacheFile := filepath.Join(t.TempDir(), "cache.json")
// Write initial cache value
initialCache := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
99: {
RegionID: 99,
RegionCode: "test",
RegionName: "Testville",
Nodes: []*tailcfg.DERPNode{{
Name: "99a",
RegionID: 99,
HostName: "derp99a.tailscale.com",
IPv4: "1.2.3.4",
}},
},
// Intentionally attempt to "overwrite" something
1: {
RegionID: 1,
RegionCode: "r1",
RegionName: "r1",
Nodes: []*tailcfg.DERPNode{{
Name: "1c",
RegionID: 1,
HostName: "derp1c.tailscale.com",
IPv4: "127.0.0.1",
IPv6: "::1",
}},
},
},
}
d, err := json.Marshal(initialCache)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cacheFile, d, 0666); err != nil {
t.Fatal(err)
}
// Clear any existing cached DERP map(s)
cachedDERPMap.Store(nil)
// Load the cache
SetCachePath(cacheFile)
if cm := cachedDERPMap.Load(); !reflect.DeepEqual(initialCache, cm) {
t.Fatalf("cached map was %+v; want %+v", cm, initialCache)
}
// Verify that our DERP map is merged with the cache.
dm := getDERPMap()
region, ok := dm.Regions[99]
if !ok {
t.Fatal("expected region 99")
}
if !reflect.DeepEqual(region, initialCache.Regions[99]) {
t.Fatalf("region 99: got %+v; want %+v", region, initialCache.Regions[99])
}
// Verify that our cache can't override a statically-baked-in DERP server.
n0 := dm.Regions[1].Nodes[0]
if n0.IPv4 == "127.0.0.1" || n0.IPv6 == "::1" {
t.Errorf("got %+v; expected no overwrite for node", n0)
}
// Also, make sure that the static DERP map still has the same first
// node as when this test was last written/updated; this ensures that
// we don't accidentally start allowing overwrites due to some of the
// test's assumptions changing out from underneath us as we update the
// JSON file of fallback servers.
if getStaticDERPMap().Regions[1].Nodes[0].HostName != "derp1c.tailscale.com" {
t.Errorf("DERP server has a different name; please update this test")
}
}
func TestCacheUnchanged(t *testing.T) {
oldlog := logfunc.Load()
SetLogger(t.Logf)
t.Cleanup(func() {
SetLogger(oldlog)
})
cacheFile := filepath.Join(t.TempDir(), "cache.json")
// Write initial cache value
initialCache := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
99: {
RegionID: 99,
RegionCode: "test",
RegionName: "Testville",
Nodes: []*tailcfg.DERPNode{{
Name: "99a",
RegionID: 99,
HostName: "derp99a.tailscale.com",
IPv4: "1.2.3.4",
}},
},
},
}
d, err := json.Marshal(initialCache)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cacheFile, d, 0666); err != nil {
t.Fatal(err)
}
// Clear any existing cached DERP map(s)
cachedDERPMap.Store(nil)
// Load the cache
SetCachePath(cacheFile)
if cm := cachedDERPMap.Load(); !reflect.DeepEqual(initialCache, cm) {
t.Fatalf("cached map was %+v; want %+v", cm, initialCache)
}
// Remove the cache file on-disk, then re-set to the current value. If
// our equality comparison is working, we won't rewrite the file
// on-disk since the cached value won't have changed.
if err := os.Remove(cacheFile); err != nil {
t.Fatal(err)
}
UpdateCache(initialCache)
if _, err := os.Stat(cacheFile); !os.IsNotExist(err) {
t.Fatalf("got err=%v; expected to not find cache file", err)
}
// Now, update the cache with something slightly different and verify
// that we did re-write the file on-disk.
updatedCache := &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
99: {
RegionID: 99,
RegionCode: "test",
RegionName: "Testville",
Nodes: []*tailcfg.DERPNode{ /* set below */ },
},
},
}
clonedNode := *initialCache.Regions[99].Nodes[0]
clonedNode.IPv4 = "1.2.3.5"
updatedCache.Regions[99].Nodes = append(updatedCache.Regions[99].Nodes, &clonedNode)
UpdateCache(updatedCache)
if st, err := os.Stat(cacheFile); err != nil {
t.Fatalf("could not stat cache file; err=%v", err)
} else if !st.Mode().IsRegular() || st.Size() == 0 {
t.Fatalf("didn't find non-empty regular file; mode=%v size=%d", st.Mode(), st.Size())
}
}

View File

@@ -6,7 +6,6 @@ package interfaces
import (
"log"
"net"
"net/netip"
"net/url"
"strings"
@@ -54,22 +53,21 @@ func likelyHomeRouterIPWindows() (ret netip.Addr, ok bool) {
return
}
unspec := net.IPv4(0, 0, 0, 0)
v4unspec := netip.IPv4Unspecified()
var best *winipcfg.MibIPforwardRow2 // best (lowest metric) found so far, or nil
for i := range rs {
r := &rs[i]
if r.Loopback || r.DestinationPrefix.PrefixLength != 0 || !r.DestinationPrefix.Prefix.IP().Equal(unspec) {
if r.Loopback || r.DestinationPrefix.PrefixLength != 0 || r.DestinationPrefix.Prefix().Addr().Unmap() != v4unspec {
// Not a default route, so skip
continue
}
ip, ok := netip.AddrFromSlice(r.NextHop.IP())
if !ok {
ip := r.NextHop.Addr().Unmap()
if !ip.IsValid() {
// Not a valid gateway, so skip (won't happen though)
continue
}
ip = ip.Unmap()
if best == nil {
best = r

View File

@@ -15,6 +15,7 @@ import (
"syscall"
"golang.org/x/sys/unix"
"tailscale.com/envknob"
"tailscale.com/net/interfaces"
"tailscale.com/types/logger"
)
@@ -62,9 +63,14 @@ func socketMarkWorks() bool {
return true
}
// useSocketMark reports whether SO_MARK works.
var forceBindToDevice = envknob.Bool("TS_FORCE_LINUX_BIND_TO_DEVICE")
// UseSocketMark reports whether SO_MARK is in use.
// If it doesn't, we have to use SO_BINDTODEVICE on our sockets instead.
func useSocketMark() bool {
func UseSocketMark() bool {
if forceBindToDevice {
return false
}
socketMarkWorksOnce.Do(func() {
socketMarkWorksOnce.v = socketMarkWorks()
})
@@ -97,7 +103,7 @@ func controlC(network, address string, c syscall.RawConn) error {
var sockErr error
err := c.Control(func(fd uintptr) {
if useSocketMark() {
if UseSocketMark() {
sockErr = setBypassMark(fd)
} else {
sockErr = bindToDevice(fd)

View File

@@ -208,7 +208,7 @@ func ParseResponse(b []byte) (tID TxID, addr netip.AddrPort, err error) {
b = b[:attrsLen] // trim trailing packet bytes
}
var addr6, fallbackAddr, fallbackAddr6 netip.AddrPort
var fallbackAddr netip.AddrPort
// Read through the attributes.
// The the addr+port reported by XOR-MAPPED-ADDRESS
@@ -218,24 +218,20 @@ func ParseResponse(b []byte) (tID TxID, addr netip.AddrPort, err error) {
if err := foreachAttr(b, func(attrType uint16, attr []byte) error {
switch attrType {
case attrXorMappedAddress, attrXorMappedAddressAlt:
a, p, err := xorMappedAddress(tID, attr)
ipSlice, port, err := xorMappedAddress(tID, attr)
if err != nil {
return err
}
if len(a) == 16 {
addr6 = netip.AddrPortFrom(netip.AddrFrom16(*(*[16]byte)([]byte(a))), p)
} else {
addr = netip.AddrPortFrom(netip.AddrFrom4(*(*[4]byte)([]byte(a))), p)
if ip, ok := netip.AddrFromSlice(ipSlice); ok {
addr = netip.AddrPortFrom(ip.Unmap(), port)
}
case attrMappedAddress:
a, p, err := mappedAddress(attr)
ipSlice, port, err := mappedAddress(attr)
if err != nil {
return ErrMalformedAttrs
}
if len(a) == 16 {
fallbackAddr6 = netip.AddrPortFrom(netip.AddrFrom16(*(*[16]byte)([]byte(a))), p)
} else {
fallbackAddr = netip.AddrPortFrom(netip.AddrFrom4(*(*[4]byte)([]byte(a))), p)
if ip, ok := netip.AddrFromSlice(ipSlice); ok {
fallbackAddr = netip.AddrPortFrom(ip.Unmap(), port)
}
}
return nil
@@ -250,12 +246,6 @@ func ParseResponse(b []byte) (tID TxID, addr netip.AddrPort, err error) {
if fallbackAddr.IsValid() {
return tID, fallbackAddr, nil
}
if addr6.IsValid() {
return tID, addr6, nil
}
if fallbackAddr6.IsValid() {
return tID, fallbackAddr6, nil
}
return tID, netip.AddrPort{}, ErrMalformedAttrs
}

View File

@@ -6,11 +6,13 @@ package stun_test
import (
"bytes"
"encoding/hex"
"fmt"
"net/netip"
"testing"
"tailscale.com/net/stun"
"tailscale.com/util/must"
)
// TODO(bradfitz): fuzz this.
@@ -175,6 +177,13 @@ var responseTests = []struct {
wantAddr: netip.AddrFrom4([4]byte{127, 0, 0, 1}),
wantPort: 61300,
},
{
name: "no-4in6",
data: must.Get(hex.DecodeString("010100182112a4424fd5d202dcb37d31fc773306002000140002cd3d2112a4424fd5d202dcb382ce2dc3fcc7")),
wantTID: []byte{79, 213, 210, 2, 220, 179, 125, 49, 252, 119, 51, 6},
wantAddr: netip.AddrFrom4([4]byte{209, 180, 207, 193}),
wantPort: 60463,
},
}
func TestParseResponse(t *testing.T) {

View File

@@ -26,6 +26,9 @@ func argvSubject(argv ...string) string {
ret = filepath.Base(argv[1])
}
// Handle space separated argv
ret, _, _ = strings.Cut(ret, " ")
// Remove common noise.
ret = strings.TrimSpace(ret)
ret = strings.TrimSuffix(ret, ".exe")

View File

@@ -31,6 +31,22 @@ func TestArgvSubject(t *testing.T) {
in: []string{"/bin/mono", "/sbin/exampleProgram.bin"},
want: "exampleProgram.bin",
},
{
in: []string{"/usr/bin/sshd_config [listener] 1 of 10-100 startups"},
want: "sshd_config",
},
{
in: []string{"/usr/bin/sshd [listener] 0 of 10-100 startups"},
want: "sshd",
},
{
in: []string{"/opt/aws/bin/eic_run_authorized_keys %u %f -o AuthorizedKeysCommandUser ec2-instance-connect [listener] 0 of 10-100 startups"},
want: "eic_run_authorized_keys",
},
{
in: []string{"/usr/bin/nginx worker"},
want: "nginx",
},
}
for _, test := range tests {

View File

@@ -40,45 +40,46 @@ type CapabilityVersion int
//
// History of versions:
//
// 3: implicit compression, keep-alives
// 4: opt-in keep-alives via KeepAlive field, opt-in compression via Compress
// 5: 2020-10-19, implies IncludeIPv6, delta Peers/UserProfiles, supports MagicDNS
// 6: 2020-12-07: means MapResponse.PacketFilter nil means unchanged
// 7: 2020-12-15: FilterRule.SrcIPs accepts CIDRs+ranges, doesn't warn about 0.0.0.0/::
// 8: 2020-12-19: client can buggily receive IPv6 addresses and routes if beta enabled server-side
// 9: 2020-12-30: client doesn't auto-add implicit search domains from peers; only DNSConfig.Domains
// 10: 2021-01-17: client understands MapResponse.PeerSeenChange
// 11: 2021-03-03: client understands IPv6, multiple default routes, and goroutine dumping
// 12: 2021-03-04: client understands PingRequest
// 13: 2021-03-19: client understands FilterRule.IPProto
// 14: 2021-04-07: client understands DNSConfig.Routes and DNSConfig.Resolvers
// 15: 2021-04-12: client treats nil MapResponse.DNSConfig as meaning unchanged
// 16: 2021-04-15: client understands Node.Online, MapResponse.OnlineChange
// 17: 2021-04-18: MapResponse.Domain empty means unchanged
// 18: 2021-04-19: MapResponse.Node nil means unchanged (all fields now omitempty)
// 19: 2021-04-21: MapResponse.Debug.SleepSeconds
// 20: 2021-06-11: MapResponse.LastSeen used even less (https://github.com/tailscale/tailscale/issues/2107)
// 21: 2021-06-15: added MapResponse.DNSConfig.CertDomains
// 22: 2021-06-16: added MapResponse.DNSConfig.ExtraRecords
// 23: 2021-08-25: DNSConfig.Routes values may be empty (for ExtraRecords support in 1.14.1+)
// 24: 2021-09-18: MapResponse.Health from control to node; node shows in "tailscale status"
// 25: 2021-11-01: MapResponse.Debug.Exit
// 26: 2022-01-12: (nothing, just bumping for 1.20.0)
// 27: 2022-02-18: start of SSHPolicy being respected
// 28: 2022-03-09: client can communicate over Noise.
// 29: 2022-03-21: MapResponse.PopBrowserURL
// 30: 2022-03-22: client can request id tokens.
// 31: 2022-04-15: PingRequest & PingResponse TSMP & disco support
// 32: 2022-04-17: client knows FilterRule.CapMatch
// 33: 2022-07-20: added MapResponse.PeersChangedPatch (DERPRegion + Endpoints)
// 34: 2022-08-02: client understands CapabilityFileSharingTarget
// 36: 2022-08-02: added PeersChangedPatch.{Key,DiscoKey,Online,LastSeen,KeyExpiry,Capabilities}
// 37: 2022-08-09: added Debug.{SetForceBackgroundSTUN,SetRandomizeClientPort}; Debug are sticky
// 38: 2022-08-11: added PingRequest.URLIsNoise
// 39: 2022-08-15: clients can talk Noise over arbitrary HTTPS port
// 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
const CurrentCapabilityVersion CapabilityVersion = 41
// - 3: implicit compression, keep-alives
// - 4: opt-in keep-alives via KeepAlive field, opt-in compression via Compress
// - 5: 2020-10-19, implies IncludeIPv6, delta Peers/UserProfiles, supports MagicDNS
// - 6: 2020-12-07: means MapResponse.PacketFilter nil means unchanged
// - 7: 2020-12-15: FilterRule.SrcIPs accepts CIDRs+ranges, doesn't warn about 0.0.0.0/::
// - 8: 2020-12-19: client can buggily receive IPv6 addresses and routes if beta enabled server-side
// - 9: 2020-12-30: client doesn't auto-add implicit search domains from peers; only DNSConfig.Domains
// - 10: 2021-01-17: client understands MapResponse.PeerSeenChange
// - 11: 2021-03-03: client understands IPv6, multiple default routes, and goroutine dumping
// - 12: 2021-03-04: client understands PingRequest
// - 13: 2021-03-19: client understands FilterRule.IPProto
// - 14: 2021-04-07: client understands DNSConfig.Routes and DNSConfig.Resolvers
// - 15: 2021-04-12: client treats nil MapResponse.DNSConfig as meaning unchanged
// - 16: 2021-04-15: client understands Node.Online, MapResponse.OnlineChange
// - 17: 2021-04-18: MapResponse.Domain empty means unchanged
// - 18: 2021-04-19: MapResponse.Node nil means unchanged (all fields now omitempty)
// - 19: 2021-04-21: MapResponse.Debug.SleepSeconds
// - 20: 2021-06-11: MapResponse.LastSeen used even less (https://github.com/tailscale/tailscale/issues/2107)
// - 21: 2021-06-15: added MapResponse.DNSConfig.CertDomains
// - 22: 2021-06-16: added MapResponse.DNSConfig.ExtraRecords
// - 23: 2021-08-25: DNSConfig.Routes values may be empty (for ExtraRecords support in 1.14.1+)
// - 24: 2021-09-18: MapResponse.Health from control to node; node shows in "tailscale status"
// - 25: 2021-11-01: MapResponse.Debug.Exit
// - 26: 2022-01-12: (nothing, just bumping for 1.20.0)
// - 27: 2022-02-18: start of SSHPolicy being respected
// - 28: 2022-03-09: client can communicate over Noise.
// - 29: 2022-03-21: MapResponse.PopBrowserURL
// - 30: 2022-03-22: client can request id tokens.
// - 31: 2022-04-15: PingRequest & PingResponse TSMP & disco support
// - 32: 2022-04-17: client knows FilterRule.CapMatch
// - 33: 2022-07-20: added MapResponse.PeersChangedPatch (DERPRegion + Endpoints)
// - 34: 2022-08-02: client understands CapabilityFileSharingTarget
// - 36: 2022-08-02: added PeersChangedPatch.{Key,DiscoKey,Online,LastSeen,KeyExpiry,Capabilities}
// - 37: 2022-08-09: added Debug.{SetForceBackgroundSTUN,SetRandomizeClientPort}; Debug are sticky
// - 38: 2022-08-11: added PingRequest.URLIsNoise
// - 39: 2022-08-15: clients can talk Noise over arbitrary HTTPS port
// - 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
type StableID string
@@ -465,17 +466,36 @@ type Service struct {
// Because it contains pointers (slices), this type should not be used
// as a value type.
type Hostinfo struct {
IPNVersion string `json:",omitempty"` // version of this code
FrontendLogID string `json:",omitempty"` // logtail ID of frontend instance
BackendLogID string `json:",omitempty"` // logtail ID of backend instance
OS string `json:",omitempty"` // operating system the client runs on (a version.OS value)
OSVersion string `json:",omitempty"` // operating system version, with optional distro prefix ("Debian 10.4", "Windows 10 Pro 10.0.19041")
IPNVersion string `json:",omitempty"` // version of this code (in version.Long format)
FrontendLogID string `json:",omitempty"` // logtail ID of frontend instance
BackendLogID string `json:",omitempty"` // logtail ID of backend instance
OS string `json:",omitempty"` // operating system the client runs on (a version.OS value)
// OSVersion is the version of the OS, if available.
//
// For Android, it's like "10", "11", "12", etc. For iOS and macOS it's like
// "15.6.1" or "12.4.0". For Windows it's like "10.0.19044.1889". For
// FreeBSD it's like "12.3-STABLE".
//
// For Linux, prior to Tailscale 1.32, we jammed a bunch of fields into this
// string on Linux, like "Debian 10.4; kernel=xxx; container; env=kn" and so
// on. As of Tailscale 1.32, this is simply the kernel version on Linux, like
// "5.10.0-17-amd64".
OSVersion string `json:",omitempty"`
Container opt.Bool `json:",omitempty"` // whether the client is running in a container
Env string `json:",omitempty"` // a hostinfo.EnvType in string form
Distro string `json:",omitempty"` // "debian", "ubuntu", "nixos", ...
DistroVersion string `json:",omitempty"` // "20.04", ...
DistroCodeName string `json:",omitempty"` // "jammy", "bullseye", ...
Desktop opt.Bool `json:",omitempty"` // if a desktop was detected on Linux
Package string `json:",omitempty"` // Tailscale package to disambiguate ("choco", "appstore", etc; "" for unknown)
DeviceModel string `json:",omitempty"` // mobile phone model ("Pixel 3a", "iPhone12,3")
Hostname string `json:",omitempty"` // name of the host the client runs on
ShieldsUp bool `json:",omitempty"` // indicates whether the host is blocking incoming connections
ShareeNode bool `json:",omitempty"` // indicates this node exists in netmap because it's owned by a shared-to user
NoLogsNoSupport bool `json:",omitempty"` // indicates that the user has opted out of sending logs and support
GoArch string `json:",omitempty"` // the host's GOARCH value (of the running binary)
GoVersion string `json:",omitempty"` // Go version binary was built with
RoutableIPs []netip.Prefix `json:",omitempty"` // set of IP ranges this client can route
@@ -903,11 +923,6 @@ type MapRequest struct {
Stream bool // if true, multiple MapResponse objects are returned
Hostinfo *Hostinfo
// TKA describes request parameters relating to a local instance of
// the tailnet key authority. This field is omitted if a local instance
// is not running.
TKA *TKAMapRequest `json:",omitempty"`
// Endpoints are the client's magicsock UDP ip:port endpoints (IPv4 or IPv6).
Endpoints []string
// EndpointTypes are the types of the corresponding endpoints in Endpoints.
@@ -1347,9 +1362,15 @@ type MapResponse struct {
// ControlTime, if non-zero, is the current timestamp according to the control server.
ControlTime *time.Time `json:",omitempty"`
// TKA, if non-nil, describes updates for the local instance of the
// tailnet key authority.
TKA *TKAMapResponse `json:",omitempty"`
// TKAInfo describes the control plane's view of tailnet
// key authority (TKA) state.
//
// An initial nil TKAInfo indicates that the control plane
// believes TKA should not be enabled. An initial non-nil TKAInfo
// indicates the control plane believes TKA should be enabled.
// A nil TKAInfo in a mapresponse stream (i.e. a 'delta' mapresponse)
// indicates no change from the value sent earlier.
TKAInfo *TKAInfo `json:",omitempty"`
// Debug is normally nil, except for when the control server
// is setting debug settings on a node.
@@ -1853,85 +1874,6 @@ type PeerChange struct {
Capabilities *[]string `json:",omitempty"`
}
// TKAInitBeginRequest submits a genesis AUM to seed the creation of the
// tailnet's key authority.
type TKAInitBeginRequest struct {
NodeID NodeID // NodeID of the initiating client
GenesisAUM tkatype.MarshaledAUM
}
// TKASignInfo describes information about an existing node that needs
// to be signed into a node-key signature.
type TKASignInfo struct {
NodeID NodeID // NodeID of the node-key being signed
NodePublic key.NodePublic
// RotationPubkey specifies the public key which may sign
// a NodeKeySignature (NKS), which rotates the node key.
//
// This is necessary so the node can rotate its node-key without
// talking to a node which holds a trusted network-lock key.
// It does this by nesting the original NKS in a 'rotation' NKS,
// which it then signs with the key corresponding to RotationPubkey.
//
// This field expects a raw ed25519 public key.
RotationPubkey []byte
}
// TKAInitBeginResponse describes node information which must be signed to
// complete initialization of the tailnets' key authority.
type TKAInitBeginResponse struct {
NeedSignatures []TKASignInfo
}
// TKAInitFinishRequest finalizes initialization of the tailnet key authority
// by submitting node-key signatures for all existing nodes.
type TKAInitFinishRequest struct {
NodeID NodeID // NodeID of the initiating client
Signatures map[NodeID]tkatype.MarshaledSignature
}
// TKAInitFinishResponse describes the successful enablement of the tailnet's
// key authority.
type TKAInitFinishResponse struct{}
// TKAMapRequest describes request parameters relating to the tailnet key
// authority instance on this node. This information is transmitted as
// part of the MapRequest.
type TKAMapRequest struct {
// Head is the AUMHash of the latest authority update message committed
// by this node.
Head string // tka.AUMHash.String
}
// TKAMapResponse encodes information for the tailnet key authority
// instance on the node. This information is transmitted as
// part of the MapResponse.
//
// If there are no updates to be transmitted (in other words, if both
// control and the node have the same head hash), len(Updates) == 0 and
// WantSync is false.
//
// If control has updates that build off the head hash reported by the
// node, they are simply transmitted in Updates (avoiding the more
// expensive synchronization process).
//
// In all other cases, WantSync is set to true, and the node is expected
// to reach out to control separately to synchronize.
type TKAMapResponse struct {
// Updates is any AUMs that control believes the node should apply.
Updates []tkatype.MarshaledAUM `json:",omitempty"`
// WantSync is set by control to request the node complete AUM
// synchronization.
//
// TODO(tom): Implement AUM synchronization, probably as noise endpoints
// /machine/tka/sync/offer & /machine/tka/sync/send.
WantSync bool `json:",omitempty"`
}
// DerpMagicIP is a fake WireGuard endpoint IP address that means to
// use DERP. When used (in the Node.DERP field), the port number of
// the WireGuard endpoint is the DERP region ID number to use.

View File

@@ -120,12 +120,18 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct {
BackendLogID string
OS string
OSVersion string
Container opt.Bool
Env string
Distro string
DistroVersion string
DistroCodeName string
Desktop opt.Bool
Package string
DeviceModel string
Hostname string
ShieldsUp bool
ShareeNode bool
NoLogsNoSupport bool
GoArch string
GoVersion string
RoutableIPs []netip.Prefix

View File

@@ -31,13 +31,33 @@ func fieldsOf(t reflect.Type) (fields []string) {
func TestHostinfoEqual(t *testing.T) {
hiHandles := []string{
"IPNVersion", "FrontendLogID", "BackendLogID",
"OS", "OSVersion", "Desktop", "Package", "DeviceModel", "Hostname",
"ShieldsUp", "ShareeNode",
"GoArch", "GoVersion",
"RoutableIPs", "RequestTags",
"Services", "NetInfo", "SSH_HostKeys", "Cloud",
"Userspace", "UserspaceRouter",
"IPNVersion",
"FrontendLogID",
"BackendLogID",
"OS",
"OSVersion",
"Container",
"Env",
"Distro",
"DistroVersion",
"DistroCodeName",
"Desktop",
"Package",
"DeviceModel",
"Hostname",
"ShieldsUp",
"ShareeNode",
"NoLogsNoSupport",
"GoArch",
"GoVersion",
"RoutableIPs",
"RequestTags",
"Services",
"NetInfo",
"SSH_HostKeys",
"Cloud",
"Userspace",
"UserspaceRouter",
}
if have := fieldsOf(reflect.TypeOf(Hostinfo{})); !reflect.DeepEqual(have, hiHandles) {
t.Errorf("Hostinfo.Equal check might be out of sync\nfields: %q\nhandled: %q\n",
@@ -661,7 +681,7 @@ func TestRegisterRequestNilClone(t *testing.T) {
// We've screwed this up several times.
func TestCurrentCapabilityVersion(t *testing.T) {
f := must.Get(os.ReadFile("tailcfg.go"))
matches := regexp.MustCompile(`(?m)^//\s+(\d+): \d\d\d\d-\d\d-\d\d: `).FindAllStringSubmatch(string(f), -1)
matches := regexp.MustCompile(`(?m)^//[\s-]+(\d+): \d\d\d\d-\d\d-\d\d: `).FindAllStringSubmatch(string(f), -1)
max := 0
for _, m := range matches {
n := must.Get(strconv.Atoi(m[1]))

View File

@@ -250,19 +250,25 @@ func (v *HostinfoView) UnmarshalJSON(b []byte) error {
return nil
}
func (v HostinfoView) IPNVersion() string { return v.ж.IPNVersion }
func (v HostinfoView) FrontendLogID() string { return v.ж.FrontendLogID }
func (v HostinfoView) BackendLogID() string { return v.ж.BackendLogID }
func (v HostinfoView) OS() string { return v.ж.OS }
func (v HostinfoView) OSVersion() string { return v.ж.OSVersion }
func (v HostinfoView) Desktop() opt.Bool { return v.ж.Desktop }
func (v HostinfoView) Package() string { return v.ж.Package }
func (v HostinfoView) DeviceModel() string { return v.ж.DeviceModel }
func (v HostinfoView) Hostname() string { return v.ж.Hostname }
func (v HostinfoView) ShieldsUp() bool { return v.ж.ShieldsUp }
func (v HostinfoView) ShareeNode() bool { return v.ж.ShareeNode }
func (v HostinfoView) GoArch() string { return v.ж.GoArch }
func (v HostinfoView) GoVersion() string { return v.ж.GoVersion }
func (v HostinfoView) IPNVersion() string { return v.ж.IPNVersion }
func (v HostinfoView) FrontendLogID() string { return v.ж.FrontendLogID }
func (v HostinfoView) BackendLogID() string { return v.ж.BackendLogID }
func (v HostinfoView) OS() string { return v.ж.OS }
func (v HostinfoView) OSVersion() string { return v.ж.OSVersion }
func (v HostinfoView) Container() opt.Bool { return v.ж.Container }
func (v HostinfoView) Env() string { return v.ж.Env }
func (v HostinfoView) Distro() string { return v.ж.Distro }
func (v HostinfoView) DistroVersion() string { return v.ж.DistroVersion }
func (v HostinfoView) DistroCodeName() string { return v.ж.DistroCodeName }
func (v HostinfoView) Desktop() opt.Bool { return v.ж.Desktop }
func (v HostinfoView) Package() string { return v.ж.Package }
func (v HostinfoView) DeviceModel() string { return v.ж.DeviceModel }
func (v HostinfoView) Hostname() string { return v.ж.Hostname }
func (v HostinfoView) ShieldsUp() bool { return v.ж.ShieldsUp }
func (v HostinfoView) ShareeNode() bool { return v.ж.ShareeNode }
func (v HostinfoView) NoLogsNoSupport() bool { return v.ж.NoLogsNoSupport }
func (v HostinfoView) GoArch() string { return v.ж.GoArch }
func (v HostinfoView) GoVersion() string { return v.ж.GoVersion }
func (v HostinfoView) RoutableIPs() views.IPPrefixSlice {
return views.IPPrefixSliceOf(v.ж.RoutableIPs)
}
@@ -282,12 +288,18 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct {
BackendLogID string
OS string
OSVersion string
Container opt.Bool
Env string
Distro string
DistroVersion string
DistroCodeName string
Desktop opt.Bool
Package string
DeviceModel string
Hostname string
ShieldsUp bool
ShareeNode bool
NoLogsNoSupport bool
GoArch string
GoVersion string
RoutableIPs []netip.Prefix

161
tailcfg/tka.go Normal file
View File

@@ -0,0 +1,161 @@
// 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 tailcfg
import (
"tailscale.com/types/key"
"tailscale.com/types/tkatype"
)
// 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
// GenesisAUM is the initial (genesis) AUM that the node generated
// to bootstrap tailnet key authority state.
GenesisAUM tkatype.MarshaledAUM
}
// TKASignInfo describes information about an existing node that needs
// to be signed into a node-key signature.
type TKASignInfo struct {
// NodeID is the ID of the node which needs a signature. It must
// correspond to NodePublic.
NodeID NodeID
// NodePublic is the node (Wireguard) public key which is being
// signed.
NodePublic key.NodePublic
// RotationPubkey specifies the public key which may sign
// a NodeKeySignature (NKS), which rotates the node key.
//
// This is necessary so the node can rotate its node-key without
// talking to a node which holds a trusted network-lock key.
// It does this by nesting the original NKS in a 'rotation' NKS,
// which it then signs with the key corresponding to RotationPubkey.
//
// This field expects a raw ed25519 public key.
RotationPubkey []byte
}
// TKAInitBeginResponse is the JSON response from a /tka/init/begin RPC.
// This structure describes node information which must be signed to
// complete initialization of the tailnets' key authority.
type TKAInitBeginResponse struct {
// NeedSignatures specify information about the nodes in your tailnet
// which need initial signatures to function once the tailnet key
// authority is in use. The generated signatures should then be
// submitted in a /tka/init/finish RPC.
NeedSignatures []TKASignInfo
}
// TKAInitFinishRequest is the JSON request of a /tka/init/finish RPC.
// 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
// Signatures are serialized tka.NodeKeySignatures for all nodes
// in the tailnet.
Signatures map[NodeID]tkatype.MarshaledSignature
}
// TKAInitFinishResponse is the JSON response from a /tka/init/finish RPC.
// This schema describes the successful enablement of the tailnet's
// key authority.
type TKAInitFinishResponse struct {
// Nothing. (yet?)
}
// TKAInfo encodes the control plane's view of tailnet key authority (TKA)
// state. This information is transmitted as part of the MapResponse.
type TKAInfo struct {
// Head describes the hash of the latest AUM applied to the authority.
// Head is encoded as tka.AUMHash.MarshalText.
//
// If the Head state differs to that known locally, the node should perform
// synchronization via a separate RPC.
//
// TODO(tom): Implement AUM synchronization as noise endpoints
// /machine/tka/sync/offer & /machine/tka/sync/send.
Head string `json:",omitempty"`
// Disabled indicates the control plane believes TKA should be disabled,
// and the node should reach out to fetch a disablement
// secret. If the disablement secret verifies, then the node should then
// disable TKA locally.
// This field exists to disambiguate a nil TKAInfo in a delta mapresponse
// from a nil TKAInfo indicating TKA should be disabled.
//
// TODO(tom): Implement /machine/tka/boostrap as a noise endpoint, to
// communicate the genesis AUM & any disablement secrets.
Disabled bool `json:",omitempty"`
}
// TKABootstrapRequest is sent by a node to get information necessary for
// enabling or disabling the tailnet key authority.
type TKABootstrapRequest struct {
// Head represents the node's head AUMHash (tka.Authority.Head), if
// network lock is enabled.
Head string
}
// TKABootstrapResponse encodes values necessary to enable or disable
// the tailnet key authority (TKA).
type TKABootstrapResponse struct {
// GenesisAUM returns the initial AUM necessary to initialize TKA.
GenesisAUM tkatype.MarshaledAUM `json:",omitempty"`
// DisablementSecret encodes a secret necessary to disable TKA.
DisablementSecret []byte `json:",omitempty"`
}
// TKASyncOfferRequest encodes a request to synchronize tailnet key authority
// state (TKA). Values of type tka.AUMHash are encoded as strings in their
// MarshalText form.
type TKASyncOfferRequest struct {
// Head represents the node's head AUMHash (tka.Authority.Head). This
// corresponds to tka.SyncOffer.Head.
Head string
// Ancestors represents a selection of ancestor AUMHash values ascending
// from the current head. This corresponds to tka.SyncOffer.Ancestors.
Ancestors []string
}
// TKASyncOfferResponse encodes a response in synchronizing a node's
// tailnet key authority state. Values of type tka.AUMHash are encoded as
// strings in their MarshalText form.
type TKASyncOfferResponse struct {
// Head represents the control plane's head AUMHash (tka.Authority.Head).
// This corresponds to tka.SyncOffer.Head.
Head string
// Ancestors represents a selection of ancestor AUMHash values ascending
// from the control plane's head. This corresponds to
// tka.SyncOffer.Ancestors.
Ancestors []string
// MissingAUMs encodes AUMs that the control plane believes the node
// is missing.
MissingAUMs []tkatype.MarshaledAUM
}
// TKASyncSendRequest encodes AUMs that a node believes the control plane
// is missing.
type TKASyncSendRequest struct {
// MissingAUMs encodes AUMs that the node believes the control plane
// is missing.
MissingAUMs []tkatype.MarshaledAUM
}
// TKASyncSendResponse encodes the control plane's response to a node
// submitting AUMs during AUM synchronization.
type TKASyncSendResponse struct {
// Head represents the control plane's head AUMHash (tka.Authority.Head),
// after applying the missing AUMs.
Head string
}

View File

@@ -33,6 +33,19 @@ const (
// SigRotation signature and sign it again with their rotation key. That
// way, SigRotation nesting should only be 2 deep in the common case.
SigRotation
// SigCredential describes a signature over a specifi public key, signed
// by a key in the tailnet key authority referenced by the specified keyID.
// In effect, SigCredential delegates the ability to make a signature to
// a different public/private key pair.
//
// It is intended that a different public/private key pair be generated
// for each different SigCredential that is created. Implementors must
// take care that the private side is only known to the entity that needs
// to generate the wrapping SigRotation signature, and it is immediately
// discarded after use.
//
// SigCredential is expected to be nested in a SigRotation signature.
SigCredential
)
func (s SigKind) String() string {
@@ -43,6 +56,8 @@ func (s SigKind) String() string {
return "direct"
case SigRotation:
return "rotation"
case SigCredential:
return "credential"
default:
return fmt.Sprintf("Sig?<%d>", int(s))
}
@@ -53,8 +68,9 @@ func (s SigKind) String() string {
type NodeKeySignature struct {
// SigKind identifies the variety of signature.
SigKind SigKind `cbor:"1,keyasint"`
// Pubkey identifies the public key which is being authorized.
Pubkey []byte `cbor:"2,keyasint"`
// Pubkey identifies the key.NodePublic which is being authorized.
// SigCredential signatures do not use this field.
Pubkey []byte `cbor:"2,keyasint,omitempty"`
// KeyID identifies which key in the tailnet key authority should
// be used to verify this signature. Only set for SigDirect and
@@ -69,19 +85,23 @@ type NodeKeySignature struct {
// used as Pubkey. Only used for SigRotation signatures.
Nested *NodeKeySignature `cbor:"5,keyasint,omitempty"`
// RotationPubkey specifies the ed25519 public key which may sign a
// SigRotation signature, which embeds this one.
// WrappingPubkey specifies the ed25519 public key which must be used
// to sign a Signature which embeds this one.
//
// Intermediate SigRotation signatures may omit this value to use the
// parent one.
RotationPubkey []byte `cbor:"6,keyasint,omitempty"`
// For SigRotation signatures multiple levels deep, intermediate
// signatures may omit this value, in which case the parent WrappingPubkey
// is used.
//
// SigCredential signatures use this field to specify the public key
// they are certifying, following the usual semanticsfor WrappingPubkey.
WrappingPubkey []byte `cbor:"6,keyasint,omitempty"`
}
// rotationPublic returns the public key which must sign a SigRotation
// signature that embeds this signature, if any.
func (s NodeKeySignature) rotationPublic() (pub ed25519.PublicKey, ok bool) {
if len(s.RotationPubkey) > 0 {
return ed25519.PublicKey(s.RotationPubkey), true
// wrappingPublic returns the public key which must sign a signature which
// embeds this one, if any.
func (s NodeKeySignature) wrappingPublic() (pub ed25519.PublicKey, ok bool) {
if len(s.WrappingPubkey) > 0 {
return ed25519.PublicKey(s.WrappingPubkey), true
}
switch s.SigKind {
@@ -89,7 +109,7 @@ func (s NodeKeySignature) rotationPublic() (pub ed25519.PublicKey, ok bool) {
if s.Nested == nil {
return nil, false
}
return s.Nested.rotationPublic()
return s.Nested.wrappingPublic()
default:
return nil, false
@@ -138,15 +158,18 @@ func (s *NodeKeySignature) Unserialize(data []byte) error {
return dec.Unmarshal(data, s)
}
// verifySignature checks that the NodeKeySignature is authentic, certified
// by the given verificationKey, and authorizes the given nodeKey.
// verifySignature checks that the NodeKeySignature is authentic & certified
// by the given verificationKey. Additionally, SigDirect and SigRotation
// signatures are checked to ensure they authorize the given nodeKey.
func (s *NodeKeySignature) verifySignature(nodeKey key.NodePublic, verificationKey Key) error {
nodeBytes, err := nodeKey.MarshalBinary()
if err != nil {
return fmt.Errorf("marshalling pubkey: %v", err)
}
if !bytes.Equal(nodeBytes, s.Pubkey) {
return errors.New("signature does not authorize nodeKey")
if s.SigKind != SigCredential {
nodeBytes, err := nodeKey.MarshalBinary()
if err != nil {
return fmt.Errorf("marshalling pubkey: %v", err)
}
if !bytes.Equal(nodeBytes, s.Pubkey) {
return errors.New("signature does not authorize nodeKey")
}
}
sigHash := s.SigHash()
@@ -157,7 +180,7 @@ func (s *NodeKeySignature) verifySignature(nodeKey key.NodePublic, verificationK
}
// Verify the signature using the nested rotation key.
verifyPub, ok := s.Nested.rotationPublic()
verifyPub, ok := s.Nested.wrappingPublic()
if !ok {
return errors.New("missing rotation key")
}
@@ -167,15 +190,22 @@ func (s *NodeKeySignature) verifySignature(nodeKey key.NodePublic, verificationK
// Recurse to verify the signature on the nested structure.
var nestedPub key.NodePublic
if err := nestedPub.UnmarshalBinary(s.Nested.Pubkey); err != nil {
return fmt.Errorf("nested pubkey: %v", err)
// SigCredential signatures certify an indirection key rather than a node
// key, so theres no need to check the node key.
if s.Nested.SigKind != SigCredential {
if err := nestedPub.UnmarshalBinary(s.Nested.Pubkey); err != nil {
return fmt.Errorf("nested pubkey: %v", err)
}
}
if err := s.Nested.verifySignature(nestedPub, verificationKey); err != nil {
return fmt.Errorf("nested: %v", err)
}
return nil
case SigDirect:
case SigDirect, SigCredential:
if s.Nested != nil {
return fmt.Errorf("invalid signature: signatures of type %v cannot nest another signature", s.SigKind)
}
switch verificationKey.Kind {
case Key25519:
if ed25519consensus.Verify(ed25519.PublicKey(verificationKey.Public), sigHash[:], s.Signature) {

View File

@@ -67,7 +67,7 @@ func TestSigNested(t *testing.T) {
SigKind: SigDirect,
KeyID: k.ID(),
Pubkey: oldPub,
RotationPubkey: rPub,
WrappingPubkey: rPub,
}
sigHash := nestedSig.SigHash()
nestedSig.Signature = ed25519.Sign(priv, sigHash[:])
@@ -110,6 +110,13 @@ func TestSigNested(t *testing.T) {
if err := sig.verifySignature(node.Public(), k); err == nil {
t.Error("verifySignature(node) succeeded with bad outer signature")
}
// Test verification fails if the outer signature is signed with a
// different public key to whats specified in WrappingPubkey
sig.Signature = ed25519.Sign(priv, sigHash[:])
if err := sig.verifySignature(node.Public(), k); err == nil {
t.Error("verifySignature(node) succeeded with different signature")
}
}
func TestSigNested_DeepNesting(t *testing.T) {
@@ -128,7 +135,7 @@ func TestSigNested_DeepNesting(t *testing.T) {
SigKind: SigDirect,
KeyID: k.ID(),
Pubkey: oldPub,
RotationPubkey: rPub,
WrappingPubkey: rPub,
}
sigHash := nestedSig.SigHash()
nestedSig.Signature = ed25519.Sign(priv, sigHash[:])
@@ -175,6 +182,91 @@ func TestSigNested_DeepNesting(t *testing.T) {
}
}
func TestSigCredential(t *testing.T) {
// Network-lock key (the key used to sign the nested sig)
pub, priv := testingKey25519(t, 1)
k := Key{Kind: Key25519, Public: pub, Votes: 2}
// 'credential' key (the one being delegated to)
cPub, cPriv := testingKey25519(t, 2)
// The node key being certified
node := key.NewNode()
nodeKeyPub, _ := node.Public().MarshalBinary()
// The signature certifying delegated trust to another
// public key.
nestedSig := NodeKeySignature{
SigKind: SigCredential,
KeyID: k.ID(),
WrappingPubkey: cPub,
}
sigHash := nestedSig.SigHash()
nestedSig.Signature = ed25519.Sign(priv, sigHash[:])
// The signature authorizing the node key, signed by the
// delegated key & embedding the original signature.
sig := NodeKeySignature{
SigKind: SigRotation,
KeyID: k.ID(),
Pubkey: nodeKeyPub,
Nested: &nestedSig,
}
sigHash = sig.SigHash()
sig.Signature = ed25519.Sign(cPriv, sigHash[:])
if err := sig.verifySignature(node.Public(), k); err != nil {
t.Fatalf("verifySignature(node) failed: %v", err)
}
// Test verification fails if the wrong verification key is provided
kBad := Key{Kind: Key25519, Public: []byte{1, 2, 3, 4}, Votes: 2}
if err := sig.verifySignature(node.Public(), kBad); err == nil {
t.Error("verifySignature() did not error for wrong verification key")
}
// Test someone can't misuse our public API for verifying node-keys
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})},
}})).Chonk())
if err := a.NodeKeyAuthorized(node.Public(), nestedSig.Serialize()); err == nil {
t.Error("NodeKeyAuthorized(SigCredential, node) did not fail")
}
// but that they can use it properly (nested in a SigRotation)
if err := a.NodeKeyAuthorized(node.Public(), sig.Serialize()); err != nil {
t.Errorf("NodeKeyAuthorized(SigRotation{SigCredential}, node) failed: %v", err)
}
// Test verification fails if the inner signature is invalid
tmp := make([]byte, ed25519.SignatureSize)
copy(tmp, nestedSig.Signature)
copy(nestedSig.Signature, []byte{1, 2, 3, 4})
if err := sig.verifySignature(node.Public(), k); err == nil {
t.Error("verifySignature(node) succeeded with bad inner signature")
}
copy(nestedSig.Signature, tmp)
// Test verification fails if the outer signature is invalid
copy(tmp, sig.Signature)
copy(sig.Signature, []byte{1, 2, 3, 4})
if err := sig.verifySignature(node.Public(), k); err == nil {
t.Error("verifySignature(node) succeeded with bad outer signature")
}
copy(sig.Signature, tmp)
// Test verification fails if we attempt to check a different node-key
otherNode := key.NewNode()
if err := sig.verifySignature(otherNode.Public(), k); err == nil {
t.Error("verifySignature(otherNode) succeeded with different principal")
}
// Test verification fails if the outer signature is signed with a
// different public key to whats specified in WrappingPubkey
sig.Signature = ed25519.Sign(priv, sigHash[:])
if err := sig.verifySignature(node.Public(), k); err == nil {
t.Error("verifySignature(node) succeeded with different signature")
}
}
func TestSigSerializeUnserialize(t *testing.T) {
nodeKeyPub := []byte{1, 2, 3, 4}
pub, priv := testingKey25519(t, 1)

View File

@@ -673,6 +673,10 @@ func (a *Authority) NodeKeyAuthorized(nodeKey key.NodePublic, nodeKeySignature t
if err := decoded.Unserialize(nodeKeySignature); err != nil {
return fmt.Errorf("unserialize: %v", err)
}
if decoded.SigKind == SigCredential {
return errors.New("credential signatures cannot authorize nodes on their own")
}
key, err := a.state.GetKey(decoded.KeyID)
if err != nil {
return fmt.Errorf("key: %v", err)

View File

@@ -541,6 +541,9 @@ func (ln *listener) Close() error {
return nil
}
// Server returns the tsnet Server associated with the listener.
func (ln *listener) Server() *Server { return ln.s }
type addr struct{ ln *listener }
func (a addr) Network() string { return a.ln.key.network }

18
tsnet/tsnet_test.go Normal file
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.
package tsnet
import "testing"
// TestListener_Server ensures that the listener type always keeps the Server
// method, which is used by some external applications to identify a tsnet.Listener
// from other net.Listeners, as well as access the underlying Server.
func TestListener_Server(t *testing.T) {
s := &Server{}
ln := listener{s: s}
if ln.Server() != s {
t.Errorf("listener.Server() returned %v, want %v", ln.Server(), s)
}
}

View File

@@ -17,8 +17,11 @@ type Resolver struct {
// - A plain IP address for a "classic" UDP+TCP DNS resolver.
// This is the common format as sent by the control plane.
// - An IP:port, for tests.
// - "https://resolver.com/path" for DNS over HTTPS; currently
// as of 2022-09-08 only used for certain well-known resolvers
// (see the publicdns package) for which the IP addresses to dial DoH are
// known ahead of time, so bootstrap DNS resolution is not required.
// - [TODO] "tls://resolver.com" for DNS over TCP+TLS
// - [TODO] "https://resolver.com/query-tmpl" for DNS over HTTPS
Addr string `json:",omitempty"`
// BootstrapResolution is an optional suggested resolution for the
@@ -27,6 +30,8 @@ type Resolver struct {
// BootstrapResolution may be empty, in which case clients should
// look up the DoT/DoH server using their local "classic" DNS
// resolver.
//
// As of 2022-09-08, BootstrapResolution is not yet used.
BootstrapResolution []netip.Addr `json:",omitempty"`
}

View File

@@ -25,10 +25,8 @@ func Set[K comparable, V any, T ~map[K]V](m *T, k K, v V) {
// (currently only a slice or a map) and makes sure it's non-nil for
// JSON serialization. (In particular, JavaScript clients usually want
// the field to be defined after they decode the JSON.)
// MakeNonNil takes a pointer to a Go data structure
// (currently only a slice or a map) and makes sure it's non-nil for
// JSON serialization. (In particular, JavaScript clients usually want
// the field to be defined after they decode the JSON.)
//
// Deprecated: use NonNilSliceForJSON or NonNilMapForJSON instead.
func NonNil(ptr interface{}) {
if ptr == nil {
panic("nil interface")
@@ -51,3 +49,23 @@ func NonNil(ptr interface{}) {
rv.Set(reflect.MakeMap(rv.Type()))
}
}
// NonNilSliceForJSON makes sure that *slicePtr is non-nil so it will
// won't be omitted from JSON serialization and possibly confuse JavaScript
// clients expecting it to be preesnt.
func NonNilSliceForJSON[T any, S ~[]T](slicePtr *S) {
if *slicePtr != nil {
return
}
*slicePtr = make([]T, 0)
}
// NonNilMapForJSON makes sure that *slicePtr is non-nil so it will
// won't be omitted from JSON serialization and possibly confuse JavaScript
// clients expecting it to be preesnt.
func NonNilMapForJSON[K comparable, V any, M ~map[K]V](mapPtr *M) {
if *mapPtr != nil {
return
}
*mapPtr = make(M)
}

View File

@@ -69,3 +69,21 @@ func TestNonNil(t *testing.T) {
t.Error("map still nil")
}
}
func TestNonNilMapForJSON(t *testing.T) {
type M map[string]int
var m M
NonNilMapForJSON(&m)
if m == nil {
t.Fatal("still nil")
}
}
func TestNonNilSliceForJSON(t *testing.T) {
type S []int
var s S
NonNilSliceForJSON(&s)
if s == nil {
t.Fatal("still nil")
}
}

View File

@@ -172,6 +172,11 @@ func printEndpointHTML(w io.Writer, ep *endpoint) {
break
}
pos := (int(s.recentPong) - i) % len(s.recentPongs)
// If s.recentPongs wraps around pos will be negative, so start
// again from the end of the slice.
if pos < 0 {
pos += len(s.recentPongs)
}
pr := s.recentPongs[pos]
fmt.Fprintf(w, "<li>pong %v ago: in %v, from %v src %v</li>\n",
fmtMono(pr.pongAt), pr.latency.Round(time.Millisecond/10),

View File

@@ -14,6 +14,7 @@ import (
"errors"
"fmt"
"hash/fnv"
"io"
"math"
"math/rand"
"net"
@@ -60,6 +61,16 @@ import (
"tailscale.com/wgengine/monitor"
)
const (
// These are disco.Magic in big-endian form, 4 then 2 bytes. The
// BPF filters need the magic in this format to match on it. Used
// only in magicsock_linux.go, but defined here so that the test
// which verifies this is the correct magic doesn't also need a
// _linux variant.
discoMagic1 = 0x5453f09f
discoMagic2 = 0x92ac
)
// useDerpRoute reports whether magicsock should enable the DERP
// return path optimization (Issue 150).
func useDerpRoute() bool {
@@ -251,8 +262,14 @@ type Conn struct {
// pconn4 and pconn6 are the underlying UDP sockets used to
// send/receive packets for wireguard and other magicsock
// protocols.
pconn4 *RebindingUDPConn
pconn6 *RebindingUDPConn
pconn4 RebindingUDPConn
pconn6 RebindingUDPConn
// closeDisco4 and closeDisco6 are io.Closers to shut down the raw
// disco packet receivers. If nil, no raw disco receiver is
// running for the given family.
closeDisco4 io.Closer
closeDisco6 io.Closer
// netChecker is the prober that discovers local network
// conditions, including the closest DERP relay and NAT mappings.
@@ -553,7 +570,7 @@ func NewConn(opts Options) (*Conn, error) {
}
c.linkMon = opts.LinkMonitor
if err := c.initialBind(); err != nil {
if err := c.rebind(keepCurrentPort); err != nil {
return nil, err
}
@@ -561,17 +578,27 @@ func NewConn(opts Options) (*Conn, error) {
c.donec = c.connCtx.Done()
c.netChecker = &netcheck.Client{
Logf: logger.WithPrefix(c.logf, "netcheck: "),
GetSTUNConn4: func() netcheck.STUNConn { return c.pconn4 },
GetSTUNConn4: func() netcheck.STUNConn { return &c.pconn4 },
GetSTUNConn6: func() netcheck.STUNConn { return &c.pconn6 },
SkipExternalNetwork: inTest(),
PortMapper: c.portMapper,
}
if c.pconn6 != nil {
c.netChecker.GetSTUNConn6 = func() netcheck.STUNConn { return c.pconn6 }
}
c.ignoreSTUNPackets()
if d4, err := c.listenRawDisco("ip4"); err == nil {
c.logf("[v1] using BPF disco receiver for IPv4")
c.closeDisco4 = d4
} else {
c.logf("[v1] couldn't create raw v4 disco listener, using regular listener instead: %v", err)
}
if d6, err := c.listenRawDisco("ip6"); err == nil {
c.logf("[v1] using BPF disco receiver for IPv6")
c.closeDisco6 = d6
} else {
c.logf("[v1] couldn't create raw v6 disco listener, using regular listener instead: %v", err)
}
return c, nil
}
@@ -1210,10 +1237,6 @@ func (c *Conn) sendUDPStd(addr netip.AddrPort, b []byte) (sent bool, err error)
return false, nil
}
case addr.Addr().Is6():
if c.pconn6 == nil {
// ignore IPv6 dest if we don't have an IPv6 address.
return false, nil
}
_, err = c.pconn6.WriteToUDPAddrPort(b, addr)
if err != nil && (c.noV6.Load() || neterror.TreatAsLostUDP(err)) {
return false, nil
@@ -1638,7 +1661,7 @@ func (c *Conn) receiveIPv6(b []byte) (int, conn.Endpoint, error) {
if err != nil {
return 0, nil, err
}
if ep, ok := c.receiveIP(b[:n], ipp, &c.ippEndpoint6); ok {
if ep, ok := c.receiveIP(b[:n], ipp, &c.ippEndpoint6, c.closeDisco6 == nil); ok {
metricRecvDataIPv6.Add(1)
return n, ep, nil
}
@@ -1654,7 +1677,7 @@ func (c *Conn) receiveIPv4(b []byte) (n int, ep conn.Endpoint, err error) {
if err != nil {
return 0, nil, err
}
if ep, ok := c.receiveIP(b[:n], ipp, &c.ippEndpoint4); ok {
if ep, ok := c.receiveIP(b[:n], ipp, &c.ippEndpoint4, c.closeDisco4 == nil); ok {
metricRecvDataIPv4.Add(1)
return n, ep, nil
}
@@ -1665,12 +1688,18 @@ func (c *Conn) receiveIPv4(b []byte) (n int, ep conn.Endpoint, err error) {
//
// ok is whether this read should be reported up to wireguard-go (our
// caller).
func (c *Conn) receiveIP(b []byte, ipp netip.AddrPort, cache *ippEndpointCache) (ep *endpoint, ok bool) {
func (c *Conn) receiveIP(b []byte, ipp netip.AddrPort, cache *ippEndpointCache, checkDisco bool) (ep *endpoint, ok bool) {
if stun.Is(b) {
c.stunReceiveFunc.Load()(b, ipp)
return nil, false
}
if c.handleDiscoMessage(b, ipp, key.NodePublic{}) {
if checkDisco {
if c.handleDiscoMessage(b, ipp, key.NodePublic{}) {
return nil, false
}
} else if disco.LooksLikeDiscoWrapper(b) {
// Caller told us to ignore disco traffic, don't let it fall
// through to wireguard-go.
return nil, false
}
if !c.havePrivateKey.Load() {
@@ -2094,13 +2123,11 @@ func (c *Conn) enqueueCallMeMaybe(derpAddr netip.AddrPort, de *endpoint) {
if !c.lastEndpointsTime.After(time.Now().Add(-endpointsFreshEnoughDuration)) {
c.logf("[v1] magicsock: want call-me-maybe but endpoints stale; restunning")
if c.onEndpointRefreshed == nil {
c.onEndpointRefreshed = map[*endpoint]func(){}
}
c.onEndpointRefreshed[de] = func() {
mak.Set(&c.onEndpointRefreshed, de, func() {
c.logf("[v1] magicsock: STUN done; sending call-me-maybe to %v %v", de.discoShort, de.publicKey.ShortString())
c.enqueueCallMeMaybe(derpAddr, de)
}
})
// TODO(bradfitz): make a new 'reSTUNQuickly' method
// that passes down a do-a-lite-netcheck flag down to
// netcheck that does 1 (or 2 max) STUN queries
@@ -2626,11 +2653,13 @@ func (c *connBind) Close() error {
}
c.closed = true
// Unblock all outstanding receives.
if c.pconn4 != nil {
c.pconn4.Close()
c.pconn4.Close()
c.pconn6.Close()
if c.closeDisco4 != nil {
c.closeDisco4.Close()
}
if c.pconn6 != nil {
c.pconn6.Close()
if c.closeDisco6 != nil {
c.closeDisco6.Close()
}
// Send an empty read result to unblock receiveDERP,
// which will then check connBind.Closed.
@@ -2670,12 +2699,8 @@ func (c *Conn) Close() error {
c.closeAllDerpLocked("conn-close")
// Ignore errors from c.pconnN.Close.
// They will frequently have been closed already by a call to connBind.Close.
if c.pconn6 != nil {
c.pconn6.Close()
}
if c.pconn4 != nil {
c.pconn4.Close()
}
c.pconn6.Close()
c.pconn4.Close()
// Wait on goroutines updating right at the end, once everything is
// already closed. We want everything else in the Conn to be
@@ -2781,20 +2806,6 @@ func (c *Conn) ReSTUN(why string) {
}
}
func (c *Conn) initialBind() error {
if runtime.GOOS == "js" {
return nil
}
if err := c.bindSocket(&c.pconn4, "udp4", keepCurrentPort); err != nil {
return fmt.Errorf("magicsock: initialBind IPv4 failed: %w", err)
}
c.portMapper.SetLocalPort(c.LocalPort())
if err := c.bindSocket(&c.pconn6, "udp6", keepCurrentPort); err != nil {
c.logf("magicsock: ignoring IPv6 bind failure: %v", err)
}
return nil
}
// listenPacket opens a packet listener.
// The network must be "udp4" or "udp6".
func (c *Conn) listenPacket(network string, port uint16) (nettype.PacketConn, error) {
@@ -2812,17 +2823,17 @@ func (c *Conn) listenPacket(network string, port uint16) (nettype.PacketConn, er
// The caller is responsible for informing the portMapper of any changes.
// If curPortFate is set to dropCurrentPort, no attempt is made to reuse
// the current port.
func (c *Conn) bindSocket(rucPtr **RebindingUDPConn, network string, curPortFate currentPortFate) error {
if *rucPtr == nil {
*rucPtr = new(RebindingUDPConn)
}
ruc := *rucPtr
func (c *Conn) bindSocket(ruc *RebindingUDPConn, network string, curPortFate currentPortFate) error {
// Hold the ruc lock the entire time, so that the close+bind is atomic
// from the perspective of ruc receive functions.
ruc.mu.Lock()
defer ruc.mu.Unlock()
if runtime.GOOS == "js" {
ruc.setConnLocked(newBlockForeverConn())
return nil
}
if debugAlwaysDERP {
c.logf("disabled %v per TS_DEBUG_ALWAYS_USE_DERP", network)
ruc.setConnLocked(newBlockForeverConn())
@@ -2887,16 +2898,13 @@ const (
// rebind closes and re-binds the UDP sockets.
// We consider it successful if we manage to bind the IPv4 socket.
func (c *Conn) rebind(curPortFate currentPortFate) error {
if runtime.GOOS == "js" {
return nil
if err := c.bindSocket(&c.pconn6, "udp6", curPortFate); err != nil {
c.logf("magicsock: Rebind ignoring IPv6 bind failure: %v", err)
}
if err := c.bindSocket(&c.pconn4, "udp4", curPortFate); err != nil {
return fmt.Errorf("magicsock: Rebind IPv4 failed: %w", err)
}
c.portMapper.SetLocalPort(c.LocalPort())
if err := c.bindSocket(&c.pconn6, "udp6", curPortFate); err != nil {
c.logf("magicsock: Rebind ignoring IPv6 bind failure: %v", err)
}
return nil
}
@@ -2983,11 +2991,13 @@ type RebindingUDPConn struct {
mu sync.Mutex // held while changing pconn (and pconnAtomic)
pconn nettype.PacketConn
port uint16
}
func (c *RebindingUDPConn) setConnLocked(p nettype.PacketConn) {
c.pconn = p
c.pconnAtomic.Store(p)
c.port = uint16(c.localAddrLocked().Port)
}
// currentConn returns c's current pconn, acquiring c.mu in the process.
@@ -3049,6 +3059,12 @@ func (c *RebindingUDPConn) ReadFromNetaddr(b []byte) (n int, ipp netip.AddrPort,
}
}
func (c *RebindingUDPConn) Port() uint16 {
c.mu.Lock()
defer c.mu.Unlock()
return c.port
}
func (c *RebindingUDPConn) LocalAddr() *net.UDPAddr {
c.mu.Lock()
defer c.mu.Unlock()
@@ -3073,6 +3089,7 @@ func (c *RebindingUDPConn) closeLocked() error {
if c.pconn == nil {
return errNilPConn
}
c.port = 0
return c.pconn.Close()
}
@@ -4192,4 +4209,8 @@ var (
// metricDERPHomeChange is how many times our DERP home region DI has
// changed from non-zero to a different non-zero.
metricDERPHomeChange = clientmetric.NewCounter("derp_home_change")
// Disco packets received bpf read path
metricRecvDiscoPacketIPv4 = clientmetric.NewCounter("magicsock_disco_recv_bpf_ipv4")
metricRecvDiscoPacketIPv6 = clientmetric.NewCounter("magicsock_disco_recv_bpf_ipv6")
)

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !linux
// +build !linux
package magicsock
import (
"errors"
"io"
)
func (c *Conn) listenRawDisco(family string) (io.Closer, error) {
return nil, errors.New("raw disco listening not supported on this OS")
}

View File

@@ -0,0 +1,290 @@
// 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 magicsock
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/netip"
"time"
"unsafe"
"golang.org/x/net/bpf"
"golang.org/x/sys/unix"
"tailscale.com/envknob"
"tailscale.com/net/netns"
"tailscale.com/types/key"
)
const (
udpHeaderSize = 8
ipv6FragmentHeaderSize = 8
)
// Enable/disable using raw sockets to receive disco traffic.
var debugDisableRawDisco = envknob.Bool("TS_DEBUG_DISABLE_RAW_DISCO")
// These are our BPF filters that we use for testing packets.
var (
magicsockFilterV4 = []bpf.Instruction{
// For raw UDPv4 sockets, BPF receives the entire IP packet to
// inspect.
// Disco packets are so small they should never get
// fragmented, and we don't want to handle reassembly.
bpf.LoadAbsolute{Off: 6, Size: 2},
// More Fragments bit set means this is part of a fragmented packet.
bpf.JumpIf{Cond: bpf.JumpBitsSet, Val: 0x2000, SkipTrue: 7, SkipFalse: 0},
// Non-zero fragment offset with MF=0 means this is the last
// fragment of packet.
bpf.JumpIf{Cond: bpf.JumpBitsSet, Val: 0x1fff, SkipTrue: 6, SkipFalse: 0},
// Load IP header length into X register.
bpf.LoadMemShift{Off: 0},
// Get the first 4 bytes of the UDP packet, compare with our magic number
bpf.LoadIndirect{Off: udpHeaderSize, Size: 4},
bpf.JumpIf{Cond: bpf.JumpEqual, Val: discoMagic1, SkipTrue: 0, SkipFalse: 3},
// Compare the next 2 bytes
bpf.LoadIndirect{Off: udpHeaderSize + 4, Size: 2},
bpf.JumpIf{Cond: bpf.JumpEqual, Val: uint32(discoMagic2), SkipTrue: 0, SkipFalse: 1},
// Accept the whole packet
bpf.RetConstant{Val: 0xFFFFFFFF},
// Skip the packet
bpf.RetConstant{Val: 0x0},
}
// IPv6 is more complicated to filter, since we can have 0-to-N
// extension headers following the IPv6 header. Since BPF can't
// loop, we can't really parse these in a general way; instead, we
// simply handle the case where we have no extension headers; any
// packets with headers will be skipped. IPv6 extension headers
// are sufficiently uncommon that we're willing to accept false
// negatives here.
//
// The "proper" way to handle this would be to do minimal parsing in
// BPF and more in-depth parsing of all IPv6 packets in userspace, but
// on systems with a high volume of UDP that would be unacceptably slow
// and thus we'd rather be conservative here and possibly not receive
// disco packets rather than slow down the system.
magicsockFilterV6 = []bpf.Instruction{
// For raw UDPv6 sockets, BPF receives _only_ the UDP header onwards, not an entire IP packet.
//
// https://stackoverflow.com/questions/24514333/using-bpf-with-sock-dgram-on-linux-machine
// https://blog.cloudflare.com/epbf_sockets_hop_distance/
//
// This is especially confusing because this *isn't* true for
// IPv4; see the following code from the 'ping' utility that
// corroborates this:
//
// https://github.com/iputils/iputils/blob/1ab5fa/ping/ping.c#L1667-L1676
// https://github.com/iputils/iputils/blob/1ab5fa/ping/ping6_common.c#L933-L941
// Compare with our magic number. Start by loading and
// comparing the first 4 bytes of the UDP payload.
bpf.LoadAbsolute{Off: udpHeaderSize, Size: 4},
bpf.JumpIf{Cond: bpf.JumpEqual, Val: discoMagic1, SkipTrue: 0, SkipFalse: 3},
// Compare the next 2 bytes
bpf.LoadAbsolute{Off: udpHeaderSize + 4, Size: 2},
bpf.JumpIf{Cond: bpf.JumpEqual, Val: discoMagic2, SkipTrue: 0, SkipFalse: 1},
// Accept the whole packet
bpf.RetConstant{Val: 0xFFFFFFFF},
// Skip the packet
bpf.RetConstant{Val: 0x0},
}
testDiscoPacket = []byte{
// Disco magic
0x54, 0x53, 0xf0, 0x9f, 0x92, 0xac,
// Sender key
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
// Nonce
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
}
)
// listenRawDisco starts listening for disco packets on the given
// address family, which must be "ip4" or "ip6", using a raw socket
// and BPF filter.
// https://github.com/tailscale/tailscale/issues/3824
func (c *Conn) listenRawDisco(family string) (io.Closer, error) {
if debugDisableRawDisco {
return nil, errors.New("raw disco listening disabled by debug flag")
}
// https://github.com/tailscale/tailscale/issues/5607
if !netns.UseSocketMark() {
return nil, errors.New("raw disco listening disabled, SO_MARK unavailable")
}
var (
network string
addr string
testAddr string
prog []bpf.Instruction
)
switch family {
case "ip4":
network = "ip4:17"
addr = "0.0.0.0"
testAddr = "127.0.0.1:1"
prog = magicsockFilterV4
case "ip6":
network = "ip6:17"
addr = "::"
testAddr = "[::1]:1"
prog = magicsockFilterV6
default:
return nil, fmt.Errorf("unsupported address family %q", family)
}
asm, err := bpf.Assemble(prog)
if err != nil {
return nil, fmt.Errorf("assembling filter: %w", err)
}
pc, err := net.ListenPacket(network, addr)
if err != nil {
return nil, fmt.Errorf("creating packet conn: %w", err)
}
if err := setBPF(pc, asm); err != nil {
pc.Close()
return nil, fmt.Errorf("installing BPF filter: %w", err)
}
// If all the above succeeds, we should be ready to receive. Just
// out of paranoia, check that we do receive a well-formed disco
// packet.
tc, err := net.ListenPacket("udp", net.JoinHostPort(addr, "0"))
if err != nil {
pc.Close()
return nil, fmt.Errorf("creating disco test socket: %w", err)
}
defer tc.Close()
if _, err := tc.(*net.UDPConn).WriteToUDPAddrPort(testDiscoPacket, netip.MustParseAddrPort(testAddr)); err != nil {
pc.Close()
return nil, fmt.Errorf("writing disco test packet: %w", err)
}
pc.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
var buf [1500]byte
for {
n, _, err := pc.ReadFrom(buf[:])
if err != nil {
pc.Close()
return nil, fmt.Errorf("reading during raw disco self-test: %w", err)
}
if n < udpHeaderSize {
continue
}
if !bytes.Equal(buf[udpHeaderSize:n], testDiscoPacket) {
continue
}
break
}
pc.SetReadDeadline(time.Time{})
go c.receiveDisco(pc, family == "ip6")
return pc, nil
}
func (c *Conn) receiveDisco(pc net.PacketConn, isIPV6 bool) {
var buf [1500]byte
for {
n, src, err := pc.ReadFrom(buf[:])
if errors.Is(err, net.ErrClosed) {
return
} else if err != nil {
c.logf("disco raw reader failed: %v", err)
return
}
if n < udpHeaderSize {
// Too small to be a valid UDP datagram, drop.
continue
}
dstPort := binary.BigEndian.Uint16(buf[2:4])
if dstPort == 0 {
c.logf("[unexpected] disco raw: received packet for port 0")
}
var acceptPort uint16
if isIPV6 {
acceptPort = c.pconn6.Port()
} else {
acceptPort = c.pconn4.Port()
}
if acceptPort == 0 {
// This should only typically happen if the receiving address family
// was recently disabled.
c.logf("[v1] disco raw: dropping packet for port %d as acceptPort=0", dstPort)
continue
}
if dstPort != acceptPort {
c.logf("[v1] disco raw: dropping packet for port %d", dstPort)
continue
}
srcIP, ok := netip.AddrFromSlice(src.(*net.IPAddr).IP)
if !ok {
c.logf("[unexpected] PacketConn.ReadFrom returned not-an-IP %v in from", src)
continue
}
srcPort := binary.BigEndian.Uint16(buf[:2])
if srcIP.Is4() {
metricRecvDiscoPacketIPv4.Add(1)
} else {
metricRecvDiscoPacketIPv6.Add(1)
}
c.handleDiscoMessage(buf[udpHeaderSize:n], netip.AddrPortFrom(srcIP, srcPort), key.NodePublic{})
}
}
// setBPF installs filter as the BPF filter on conn.
// Ideally we would just use SetBPF as implemented in x/net/ipv4,
// but x/net/ipv6 doesn't implement it. And once you've written
// this code once, it turns out to be address family agnostic, so
// we might as well use it on both and get to use a net.PacketConn
// directly for both families instead of being stuck with
// different types.
func setBPF(conn net.PacketConn, filter []bpf.RawInstruction) error {
sc, err := conn.(*net.IPConn).SyscallConn()
if err != nil {
return err
}
prog := &unix.SockFprog{
Len: uint16(len(filter)),
Filter: (*unix.SockFilter)(unsafe.Pointer(&filter[0])),
}
var setErr error
err = sc.Control(func(fd uintptr) {
setErr = unix.SetsockoptSockFprog(int(fd), unix.SOL_SOCKET, unix.SO_ATTACH_FILTER, prog)
})
if err != nil {
return err
}
if setErr != nil {
return err
}
return nil
}

View File

@@ -32,6 +32,7 @@ import (
"golang.zx2c4.com/wireguard/tun/tuntest"
"tailscale.com/derp"
"tailscale.com/derp/derphttp"
"tailscale.com/disco"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netaddr"
"tailscale.com/net/stun/stuntest"
@@ -1799,3 +1800,21 @@ func TestBlockForeverConnUnblocks(t *testing.T) {
t.Fatal("timeout")
}
}
func TestDiscoMagicMatches(t *testing.T) {
// Convert our disco magic number into a uint32 and uint16 to test
// against. We panic on an incorrect length here rather than try to be
// generic with our BPF instructions below.
//
// Note that BPF uses network byte order (big-endian) when loading data
// from a packet, so that is what we use to generate our magic numbers.
if len(disco.Magic) != 6 {
t.Fatalf("expected disco.Magic to be of length 6")
}
if m1 := binary.BigEndian.Uint32([]byte(disco.Magic[:4])); m1 != discoMagic1 {
t.Errorf("first 4 bytes of disco magic don't match, got %v want %v", discoMagic1, m1)
}
if m2 := binary.BigEndian.Uint16([]byte(disco.Magic[4:6])); m2 != discoMagic2 {
t.Errorf("last 2 bytes of disco magic don't match, got %v want %v", discoMagic2, m2)
}
}

View File

@@ -7,13 +7,11 @@ package monitor
import (
"context"
"errors"
"net/netip"
"strings"
"sync"
"time"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
"tailscale.com/net/netaddr"
"tailscale.com/net/tsaddr"
"tailscale.com/types/logger"
)
@@ -133,7 +131,7 @@ func (m *winMon) Receive() (message, error) {
// unicastAddressChanged is the callback we register with Windows to call when unicast address changes.
func (m *winMon) unicastAddressChanged(_ winipcfg.MibNotificationType, row *winipcfg.MibUnicastIPAddressRow) {
what := "addr"
if ip, ok := netip.AddrFromSlice(row.Address.IP()); ok && tsaddr.IsTailscaleIP(ip.Unmap()) {
if ip := row.Address.Addr(); ip.IsValid() && tsaddr.IsTailscaleIP(ip.Unmap()) {
what = "tsaddr"
}
@@ -144,8 +142,8 @@ func (m *winMon) unicastAddressChanged(_ winipcfg.MibNotificationType, row *wini
// routeChanged is the callback we register with Windows to call when route changes.
func (m *winMon) routeChanged(_ winipcfg.MibNotificationType, row *winipcfg.MibIPforwardRow2) {
what := "route"
ipn := row.DestinationPrefix.IPNet()
if cidr, ok := netaddr.FromStdIPNet(&ipn); ok && tsaddr.IsTailscaleIP(cidr.Addr()) {
ip := row.DestinationPrefix.Prefix().Addr().Unmap()
if ip.IsValid() && tsaddr.IsTailscaleIP(ip) {
what = "tsroute"
}
// start a goroutine to finish our work, to return to Windows out of this callback

View File

@@ -743,47 +743,64 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
ns.removeSubnetAddress(dialIP)
}
}()
var wq waiter.Queue
ep, err := r.CreateEndpoint(&wq)
if err != nil {
ns.logf("CreateEndpoint error for %s: %v", stringifyTEI(reqDetails), err)
r.Complete(true) // sends a RST
return
// We can't actually create the endpoint or complete the inbound
// request until we're sure that the connection can be handled by this
// endpoint. This function sets up the TCP connection and should be
// called immediately before a connection is handled.
createConn := func() *gonet.TCPConn {
ep, err := r.CreateEndpoint(&wq)
if err != nil {
ns.logf("CreateEndpoint error for %s: %v", stringifyTEI(reqDetails), err)
r.Complete(true) // sends a RST
return nil
}
r.Complete(false)
// SetKeepAlive so that idle connections to peers that have forgotten about
// the connection or gone completely offline eventually time out.
// Applications might be setting this on a forwarded connection, but from
// userspace we can not see those, so the best we can do is to always
// perform them with conservative timing.
// TODO(tailscale/tailscale#4522): Netstack defaults match the Linux
// defaults, and results in a little over two hours before the socket would
// be closed due to keepalive. A shorter default might be better, or seeking
// a default from the host IP stack. This also might be a useful
// user-tunable, as in userspace mode this can have broad implications such
// as lingering connections to fork style daemons. On the other side of the
// fence, the long duration timers are low impact values for battery powered
// peers.
ep.SocketOptions().SetKeepAlive(true)
// The ForwarderRequest.CreateEndpoint above asynchronously
// starts the TCP handshake. Note that the gonet.TCPConn
// methods c.RemoteAddr() and c.LocalAddr() will return nil
// until the handshake actually completes. But we have the
// remote address in reqDetails instead, so we don't use
// gonet.TCPConn.RemoteAddr. The byte copies in both
// directions to/from the gonet.TCPConn in forwardTCP will
// block until the TCP handshake is complete.
return gonet.NewTCPConn(&wq, ep)
}
r.Complete(false)
// SetKeepAlive so that idle connections to peers that have forgotten about
// the connection or gone completely offline eventually time out.
// Applications might be setting this on a forwarded connection, but from
// userspace we can not see those, so the best we can do is to always
// perform them with conservative timing.
// TODO(tailscale/tailscale#4522): Netstack defaults match the Linux
// defaults, and results in a little over two hours before the socket would
// be closed due to keepalive. A shorter default might be better, or seeking
// a default from the host IP stack. This also might be a useful
// user-tunable, as in userspace mode this can have broad implications such
// as lingering connections to fork style daemons. On the other side of the
// fence, the long duration timers are low impact values for battery powered
// peers.
ep.SocketOptions().SetKeepAlive(true)
// The ForwarderRequest.CreateEndpoint above asynchronously
// starts the TCP handshake. Note that the gonet.TCPConn
// methods c.RemoteAddr() and c.LocalAddr() will return nil
// until the handshake actually completes. But we have the
// remote address in reqDetails instead, so we don't use
// gonet.TCPConn.RemoteAddr. The byte copies in both
// directions to/from the gonet.TCPConn in forwardTCP will
// block until the TCP handshake is complete.
c := gonet.NewTCPConn(&wq, ep)
// DNS
if reqDetails.LocalPort == 53 && (dialIP == magicDNSIP || dialIP == magicDNSIPv6) {
c := createConn()
if c == nil {
return
}
go ns.dns.HandleTCPConn(c, netip.AddrPortFrom(clientRemoteIP, reqDetails.RemotePort))
return
}
if ns.lb != nil {
if reqDetails.LocalPort == 22 && ns.processSSH() && ns.isLocalIP(dialIP) {
c := createConn()
if c == nil {
return
}
if err := ns.lb.HandleSSHConn(c); err != nil {
ns.logf("ssh error: %v", err)
}
@@ -791,6 +808,11 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
}
if port, ok := ns.lb.GetPeerAPIPort(dialIP); ok {
if reqDetails.LocalPort == port && ns.isLocalIP(dialIP) {
c := createConn()
if c == nil {
return
}
src := netip.AddrPortFrom(clientRemoteIP, reqDetails.RemotePort)
dst := netip.AddrPortFrom(dialIP, port)
ns.lb.ServePeerAPIConnection(src, dst, c)
@@ -798,12 +820,20 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
}
}
if reqDetails.LocalPort == 80 && (dialIP == magicDNSIP || dialIP == magicDNSIPv6) {
c := createConn()
if c == nil {
return
}
ns.lb.HandleQuad100Port80Conn(c)
return
}
}
if ns.ForwardTCPIn != nil {
c := createConn()
if c == nil {
return
}
ns.ForwardTCPIn(c, reqDetails.LocalPort)
return
}
@@ -811,11 +841,13 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) {
dialIP = netaddr.IPv4(127, 0, 0, 1)
}
dialAddr := netip.AddrPortFrom(dialIP, uint16(reqDetails.LocalPort))
ns.forwardTCP(c, clientRemoteIP, &wq, dialAddr)
if !ns.forwardTCP(createConn, clientRemoteIP, &wq, dialAddr) {
r.Complete(true) // sends a RST
}
}
func (ns *Impl) forwardTCP(client *gonet.TCPConn, clientRemoteIP netip.Addr, wq *waiter.Queue, dialAddr netip.AddrPort) {
defer client.Close()
func (ns *Impl) forwardTCP(getClient func() *gonet.TCPConn, clientRemoteIP netip.Addr, wq *waiter.Queue, dialAddr netip.AddrPort) (handled bool) {
dialAddrStr := dialAddr.String()
if debugNetstack {
ns.logf("[v2] netstack: forwarding incoming connection to %s", dialAddrStr)
@@ -823,6 +855,7 @@ func (ns *Impl) forwardTCP(client *gonet.TCPConn, clientRemoteIP netip.Addr, wq
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
waitEntry, notifyCh := waiter.NewChannelEntry(waiter.EventHUp) // TODO(bradfitz): right EventMask?
wq.EventRegister(&waitEntry)
defer wq.EventUnregister(&waitEntry)
@@ -840,13 +873,29 @@ func (ns *Impl) forwardTCP(client *gonet.TCPConn, clientRemoteIP netip.Addr, wq
}
cancel()
}()
// Attempt to dial the outbound connection before we accept the inbound one.
var stdDialer net.Dialer
server, err := stdDialer.DialContext(ctx, "tcp", dialAddrStr)
if err != nil {
ns.logf("netstack: could not connect to local server at %s: %v", dialAddrStr, err)
ns.logf("netstack: could not connect to local server at %s: %v", dialAddr.String(), err)
return
}
defer server.Close()
// If we get here, either the getClient call below will succeed and
// return something we can Close, or it will fail and will properly
// respond to the client with a RST. Either way, the caller no longer
// needs to clean up the client connection.
handled = true
// We dialed the connection; we can complete the client's TCP handshake.
client := getClient()
if client == nil {
return
}
defer client.Close()
backendLocalAddr := server.LocalAddr().(*net.TCPAddr)
backendLocalIPPort := netaddr.Unmap(backendLocalAddr.AddrPort())
ns.e.RegisterIPPortIdentity(backendLocalIPPort, clientRemoteIP)
@@ -865,6 +914,7 @@ func (ns *Impl) forwardTCP(client *gonet.TCPConn, clientRemoteIP netip.Addr, wq
ns.logf("proxy connection closed with error: %v", err)
}
ns.logf("[v2] netstack: forwarder connection to %s closed", dialAddrStr)
return
}
func (ns *Impl) acceptUDP(r *udp.ForwarderRequest) {

View File

@@ -6,7 +6,6 @@
package router
import (
"bytes"
"errors"
"fmt"
"log"
@@ -18,12 +17,12 @@ import (
ole "github.com/go-ole/go-ole"
"go4.org/netipx"
"golang.org/x/exp/slices"
"golang.org/x/sys/windows"
"golang.zx2c4.com/wireguard/tun"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
"tailscale.com/health"
"tailscale.com/net/interfaces"
"tailscale.com/net/netaddr"
"tailscale.com/net/tsaddr"
"tailscale.com/util/multierr"
"tailscale.com/wgengine/winnet"
@@ -324,25 +323,23 @@ func configureInterface(cfg *Config, tun *tun.NativeTun) (retErr error) {
// ours where the nexthop is meaningless, you're supposed to use
// one of the local IP addresses of the interface. Find an IPv4
// and IPv6 address we can use for this purpose.
var firstGateway4 *net.IP
var firstGateway6 *net.IP
addresses := make([]*net.IPNet, 0, len(cfg.LocalAddrs))
var firstGateway4 netip.Addr
var firstGateway6 netip.Addr
addresses := make([]netip.Prefix, 0, len(cfg.LocalAddrs))
for _, addr := range cfg.LocalAddrs {
if (addr.Addr().Is4() && ipif4 == nil) || (addr.Addr().Is6() && ipif6 == nil) {
// Can't program addresses for disabled protocol.
continue
}
ipnet := netipx.PrefixIPNet(addr)
addresses = append(addresses, ipnet)
gateway := ipnet.IP
if addr.Addr().Is4() && firstGateway4 == nil {
firstGateway4 = &gateway
} else if addr.Addr().Is6() && firstGateway6 == nil {
firstGateway6 = &gateway
addresses = append(addresses, addr)
if addr.Addr().Is4() && !firstGateway4.IsValid() {
firstGateway4 = addr.Addr()
} else if addr.Addr().Is6() && !firstGateway6.IsValid() {
firstGateway6 = addr.Addr()
}
}
var routes []winipcfg.RouteData
var routes []*winipcfg.RouteData
foundDefault4 := false
foundDefault6 := false
for _, route := range cfg.Routes {
@@ -351,37 +348,33 @@ func configureInterface(cfg *Config, tun *tun.NativeTun) (retErr error) {
continue
}
if route.Addr().Is6() && firstGateway6 == nil {
if route.Addr().Is6() && !firstGateway6.IsValid() {
// Windows won't let us set IPv6 routes without having an
// IPv6 local address set. However, when we've configured
// a default route, we want to forcibly grab IPv6 traffic
// even if the v6 overlay network isn't configured. To do
// that, we add a dummy local IPv6 address to serve as a
// route source.
ipnet := &net.IPNet{tsaddr.Tailscale4To6Placeholder().AsSlice(), net.CIDRMask(128, 128)}
addresses = append(addresses, ipnet)
firstGateway6 = &ipnet.IP
} else if route.Addr().Is4() && firstGateway4 == nil {
ip := tsaddr.Tailscale4To6Placeholder()
addresses = append(addresses, netip.PrefixFrom(ip, ip.BitLen()))
firstGateway6 = ip
} else if route.Addr().Is4() && !firstGateway4.IsValid() {
// TODO: do same dummy behavior as v6?
return errors.New("due to a Windows limitation, one cannot have interface routes without an interface address")
}
ipn := netipx.PrefixIPNet(route)
var gateway net.IP
var gateway netip.Addr
if route.Addr().Is4() {
gateway = *firstGateway4
gateway = firstGateway4
} else if route.Addr().Is6() {
gateway = *firstGateway6
gateway = firstGateway6
}
r := winipcfg.RouteData{
Destination: net.IPNet{
IP: ipn.IP.Mask(ipn.Mask),
Mask: ipn.Mask,
},
NextHop: gateway,
Metric: 0,
r := &winipcfg.RouteData{
Destination: route,
NextHop: gateway,
Metric: 0,
}
if net.IP.Equal(r.Destination.IP, gateway) {
if r.Destination.Addr().Unmap() == gateway {
// no need to add a route for the interface's
// own IP. The kernel does that for us.
// If we try to replace it, we'll fail to
@@ -393,12 +386,12 @@ func configureInterface(cfg *Config, tun *tun.NativeTun) (retErr error) {
if route.Bits() == 0 {
foundDefault4 = true
}
r.NextHop = *firstGateway4
r.NextHop = firstGateway4
} else if route.Addr().Is6() {
if route.Bits() == 0 {
foundDefault6 = true
}
r.NextHop = *firstGateway6
r.NextHop = firstGateway6
}
routes = append(routes, r)
}
@@ -408,18 +401,16 @@ func configureInterface(cfg *Config, tun *tun.NativeTun) (retErr error) {
return fmt.Errorf("syncAddresses: %w", err)
}
sort.Slice(routes, func(i, j int) bool { return routeLess(&routes[i], &routes[j]) })
slices.SortFunc(routes, routeDataLess)
deduplicatedRoutes := []*winipcfg.RouteData{}
for i := 0; i < len(routes); i++ {
// There's only one way to get to a given IP+Mask, so delete
// all matches after the first.
if i > 0 &&
net.IP.Equal(routes[i].Destination.IP, routes[i-1].Destination.IP) &&
bytes.Equal(routes[i].Destination.Mask, routes[i-1].Destination.Mask) {
if i > 0 && routes[i].Destination == routes[i-1].Destination {
continue
}
deduplicatedRoutes = append(deduplicatedRoutes, &routes[i])
deduplicatedRoutes = append(deduplicatedRoutes, routes[i])
}
// Re-read interface after syncAddresses.
@@ -484,28 +475,6 @@ func configureInterface(cfg *Config, tun *tun.NativeTun) (retErr error) {
return errAcc
}
// routeLess reports whether ri should sort before rj.
// The actual sort order doesn't appear to matter. The caller just
// wants them sorted to be able to de-dup.
func routeLess(ri, rj *winipcfg.RouteData) bool {
if v := bytes.Compare(ri.Destination.IP, rj.Destination.IP); v != 0 {
return v == -1
}
if v := bytes.Compare(ri.Destination.Mask, rj.Destination.Mask); v != 0 {
// Narrower masks first
return v == 1
}
if ri.Metric != rj.Metric {
// Lower metrics first
return ri.Metric < rj.Metric
}
if v := bytes.Compare(ri.NextHop, rj.NextHop); v != 0 {
// No nexthop before non-empty nexthop.
return v == -1
}
return false
}
// unwrapIP returns the shortest version of ip.
func unwrapIP(ip net.IP) net.IP {
if ip4 := ip.To4(); ip4 != nil {
@@ -521,40 +490,40 @@ func v4Mask(m net.IPMask) net.IPMask {
return m
}
func netCompare(a, b net.IPNet) int {
aip, bip := unwrapIP(a.IP), unwrapIP(b.IP)
v := bytes.Compare(aip, bip)
func netCompare(a, b netip.Prefix) int {
aip, bip := a.Addr().Unmap(), b.Addr().Unmap()
v := aip.Compare(bip)
if v != 0 {
return v
}
amask, bmask := a.Mask, b.Mask
if len(aip) == 4 {
amask = v4Mask(a.Mask)
bmask = v4Mask(b.Mask)
if a.Bits() == b.Bits() {
return 0
}
// narrower first
return -bytes.Compare(amask, bmask)
if a.Bits() > b.Bits() {
return -1
}
return 1
}
func sortNets(a []*net.IPNet) {
sort.Slice(a, func(i, j int) bool {
return netCompare(*a[i], *a[j]) == -1
func sortNets(s []netip.Prefix) {
sort.Slice(s, func(i, j int) bool {
return netCompare(s[i], s[j]) == -1
})
}
// deltaNets returns the changes to turn a into b.
func deltaNets(a, b []*net.IPNet) (add, del []*net.IPNet) {
add = make([]*net.IPNet, 0, len(b))
del = make([]*net.IPNet, 0, len(a))
func deltaNets(a, b []netip.Prefix) (add, del []netip.Prefix) {
add = make([]netip.Prefix, 0, len(b))
del = make([]netip.Prefix, 0, len(a))
sortNets(a)
sortNets(b)
i := 0
j := 0
for i < len(a) && j < len(b) {
switch netCompare(*a[i], *b[j]) {
switch netCompare(a[i], b[j]) {
case -1:
// a < b, delete
del = append(del, a[i])
@@ -576,28 +545,21 @@ func deltaNets(a, b []*net.IPNet) (add, del []*net.IPNet) {
return
}
func isIPv6LinkLocal(in *net.IPNet) bool {
return len(in.IP) == 16 && in.IP.IsLinkLocalUnicast()
func isIPv6LinkLocal(a netip.Prefix) bool {
return a.Addr().Is6() && a.Addr().IsLinkLocalUnicast()
}
// ipAdapterUnicastAddressToIPNet converts windows.IpAdapterUnicastAddress to net.IPNet.
func ipAdapterUnicastAddressToIPNet(u *windows.IpAdapterUnicastAddress) *net.IPNet {
ip := u.Address.IP()
w := 32
if ip.To4() == nil {
w = 128
}
return &net.IPNet{
IP: ip,
Mask: net.CIDRMask(int(u.OnLinkPrefixLength), w),
}
// ipAdapterUnicastAddressToPrefix converts windows.IpAdapterUnicastAddress to netip.Prefix
func ipAdapterUnicastAddressToPrefix(u *windows.IpAdapterUnicastAddress) netip.Prefix {
ip, _ := netip.AddrFromSlice(u.Address.IP())
return netip.PrefixFrom(ip.Unmap(), int(u.OnLinkPrefixLength))
}
// unicastIPNets returns all unicast net.IPNet for ifc interface.
func unicastIPNets(ifc *winipcfg.IPAdapterAddresses) []*net.IPNet {
nets := make([]*net.IPNet, 0)
func unicastIPNets(ifc *winipcfg.IPAdapterAddresses) []netip.Prefix {
var nets []netip.Prefix
for addr := ifc.FirstUnicastAddress; addr != nil; addr = addr.Next {
nets = append(nets, ipAdapterUnicastAddressToIPNet(addr))
nets = append(nets, ipAdapterUnicastAddressToPrefix(addr))
}
return nets
}
@@ -612,13 +574,13 @@ func unicastIPNets(ifc *winipcfg.IPAdapterAddresses) []*net.IPNet {
// DNS locally or remotely and from being picked as a source address for
// outgoing packets with unspecified sources. See #4647 and
// https://web.archive.org/web/20200912120956/https://devblogs.microsoft.com/scripting/use-powershell-to-change-ip-behavior-with-skipassource/
func syncAddresses(ifc *winipcfg.IPAdapterAddresses, want []*net.IPNet) error {
func syncAddresses(ifc *winipcfg.IPAdapterAddresses, want []netip.Prefix) error {
var erracc error
got := unicastIPNets(ifc)
add, del := deltaNets(got, want)
ll := make([]*net.IPNet, 0)
ll := make([]netip.Prefix, 0)
for _, a := range del {
// do not delete link-local addresses, and collect them for later
// applying SkipAsSource.
@@ -627,29 +589,29 @@ func syncAddresses(ifc *winipcfg.IPAdapterAddresses, want []*net.IPNet) error {
continue
}
err := ifc.LUID.DeleteIPAddress(*a)
err := ifc.LUID.DeleteIPAddress(a)
if err != nil {
erracc = fmt.Errorf("deleting IP %q: %w", *a, err)
erracc = fmt.Errorf("deleting IP %q: %w", a, err)
}
}
for _, a := range add {
err := ifc.LUID.AddIPAddress(*a)
err := ifc.LUID.AddIPAddress(a)
if err != nil {
erracc = fmt.Errorf("adding IP %q: %w", *a, err)
erracc = fmt.Errorf("adding IP %q: %w", a, err)
}
}
for _, a := range ll {
mib, err := ifc.LUID.IPAddress(a.IP)
mib, err := ifc.LUID.IPAddress(a.Addr())
if err != nil {
erracc = fmt.Errorf("setting skip-as-source on IP %q: unable to retrieve MIB: %w", *a, err)
erracc = fmt.Errorf("setting skip-as-source on IP %q: unable to retrieve MIB: %w", a, err)
continue
}
if !mib.SkipAsSource {
mib.SkipAsSource = true
if err := mib.Set(); err != nil {
erracc = fmt.Errorf("setting skip-as-source on IP %q: unable to set MIB: %w", *a, err)
erracc = fmt.Errorf("setting skip-as-source on IP %q: unable to set MIB: %w", a, err)
}
}
}
@@ -657,20 +619,27 @@ func syncAddresses(ifc *winipcfg.IPAdapterAddresses, want []*net.IPNet) error {
return erracc
}
func routeDataLess(a, b *winipcfg.RouteData) bool {
return routeDataCompare(a, b) < 0
}
func routeDataCompare(a, b *winipcfg.RouteData) int {
v := bytes.Compare(a.Destination.IP, b.Destination.IP)
v := a.Destination.Addr().Compare(b.Destination.Addr())
if v != 0 {
return v
}
// Narrower masks first
v = bytes.Compare(a.Destination.Mask, b.Destination.Mask)
if v != 0 {
return -v
b1, b2 := a.Destination.Bits(), b.Destination.Bits()
if b1 != b2 {
if b1 > b2 {
return -1
}
return 1
}
// No nexthop before non-empty nexthop
v = bytes.Compare(a.NextHop, b.NextHop)
v = a.NextHop.Compare(b.NextHop)
if v != 0 {
return v
}
@@ -685,17 +654,11 @@ func routeDataCompare(a, b *winipcfg.RouteData) int {
return 0
}
func sortRouteData(a []*winipcfg.RouteData) {
sort.Slice(a, func(i, j int) bool {
return routeDataCompare(a[i], a[j]) < 0
})
}
func deltaRouteData(a, b []*winipcfg.RouteData) (add, del []*winipcfg.RouteData) {
add = make([]*winipcfg.RouteData, 0, len(b))
del = make([]*winipcfg.RouteData, 0, len(a))
sortRouteData(a)
sortRouteData(b)
slices.SortFunc(a, routeDataLess)
slices.SortFunc(b, routeDataLess)
i := 0
j := 0
@@ -751,15 +714,15 @@ func getAllInterfaceRoutes(ifc *winipcfg.IPAdapterAddresses) ([]*winipcfg.RouteD
rd := make([]*winipcfg.RouteData, 0, len(routes4)+len(routes6))
for _, r := range routes4 {
rd = append(rd, &winipcfg.RouteData{
Destination: r.DestinationPrefix.IPNet(),
NextHop: r.NextHop.IP(),
Destination: r.DestinationPrefix.Prefix(),
NextHop: r.NextHop.Addr(),
Metric: r.Metric,
})
}
for _, r := range routes6 {
rd = append(rd, &winipcfg.RouteData{
Destination: r.DestinationPrefix.IPNet(),
NextHop: r.NextHop.IP(),
Destination: r.DestinationPrefix.Prefix(),
NextHop: r.NextHop.Addr(),
Metric: r.Metric,
})
}
@@ -777,8 +740,8 @@ func filterRoutes(routes []*winipcfg.RouteData, dontDelete []netip.Prefix) []*wi
}
for _, r := range routes {
// We don't want to touch broadcast routes that Windows adds.
nr, ok := netaddr.FromStdIPNet(&r.Destination)
if !ok {
nr := r.Destination
if !nr.IsValid() {
continue
}
if nr.IsSingleIP() {
@@ -789,8 +752,8 @@ func filterRoutes(routes []*winipcfg.RouteData, dontDelete []netip.Prefix) []*wi
}
filtered := make([]*winipcfg.RouteData, 0, len(routes))
for _, r := range routes {
rr, ok := netaddr.FromStdIPNet(&r.Destination)
if ok && ddm[rr] {
rr := r.Destination
if rr.IsValid() && ddm[rr] {
continue
}
filtered = append(filtered, r)

View File

@@ -7,41 +7,30 @@ package router
import (
"fmt"
"math/rand"
"net"
"net/netip"
"reflect"
"strings"
"testing"
"go4.org/netipx"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
)
func randIP() net.IP {
func randIP() netip.Addr {
b := byte(rand.Intn(3))
return net.IP{b, b, b, b}
return netip.AddrFrom4([4]byte{b, b, b, b})
}
func randRouteData() *winipcfg.RouteData {
return &winipcfg.RouteData{
Destination: net.IPNet{
IP: randIP(),
Mask: net.CIDRMask(rand.Intn(3)+1, 32),
},
NextHop: randIP(),
Metric: uint32(rand.Intn(3)),
Destination: netip.PrefixFrom(randIP(), rand.Intn(30)+1),
NextHop: randIP(),
Metric: uint32(rand.Intn(3)),
}
}
func TestRouteLess(t *testing.T) {
type D = winipcfg.RouteData
ipnet := func(s string) net.IPNet {
ipp, err := netip.ParsePrefix(s)
if err != nil {
t.Fatalf("error parsing test data %q: %v", s, err)
}
return *netipx.PrefixIPNet(ipp)
}
ipnet := netip.MustParsePrefix
tests := []struct {
ri, rj *winipcfg.RouteData
want bool
@@ -72,76 +61,51 @@ func TestRouteLess(t *testing.T) {
want: true,
},
{
ri: &D{Destination: ipnet("1.1.0.0/16"), Metric: 1, NextHop: net.ParseIP("3.3.3.3")},
rj: &D{Destination: ipnet("1.1.0.0/16"), Metric: 1, NextHop: net.ParseIP("4.4.4.4")},
ri: &D{Destination: ipnet("1.1.0.0/16"), Metric: 1, NextHop: netip.MustParseAddr("3.3.3.3")},
rj: &D{Destination: ipnet("1.1.0.0/16"), Metric: 1, NextHop: netip.MustParseAddr("4.4.4.4")},
want: true,
},
}
for i, tt := range tests {
got := routeLess(tt.ri, tt.rj)
got := routeDataLess(tt.ri, tt.rj)
if got != tt.want {
t.Errorf("%v. less = %v; want %v", i, got, tt.want)
}
back := routeLess(tt.rj, tt.ri)
back := routeDataLess(tt.rj, tt.ri)
if back && got {
t.Errorf("%v. less both ways", i)
}
}
}
func TestRouteLessConsistent(t *testing.T) {
func TestRouteDataLessConsistent(t *testing.T) {
for i := 0; i < 10000; i++ {
ri := randRouteData()
rj := randRouteData()
if routeLess(ri, rj) && routeLess(rj, ri) {
if routeDataLess(ri, rj) && routeDataLess(rj, ri) {
t.Fatalf("both compare less to each other:\n\t%#v\nand\n\t%#v", ri, rj)
}
}
}
func equalNetIPs(a, b []*net.IPNet) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if netCompare(*a[i], *b[i]) != 0 {
return false
}
}
return true
}
func ipnet4(ip string, bits int) *net.IPNet {
return &net.IPNet{
IP: net.ParseIP(ip),
Mask: net.CIDRMask(bits, 32),
}
}
// each cidr can end in "[4]" to mean To4 form.
func nets(cidrs ...string) (ret []*net.IPNet) {
func nets(cidrs ...string) (ret []netip.Prefix) {
for _, s := range cidrs {
to4 := strings.HasSuffix(s, "[4]")
if to4 {
s = strings.TrimSuffix(s, "[4]")
}
ip, ipNet, err := net.ParseCIDR(s)
if err != nil {
panic(fmt.Sprintf("Bogus CIDR %q in test", s))
}
if to4 {
ip = ip.To4()
}
ipNet.IP = ip
ret = append(ret, ipNet)
ret = append(ret, netip.MustParsePrefix(s))
}
return
}
func nilIfEmpty[E any](s []E) []E {
if len(s) == 0 {
return nil
}
return s
}
func TestDeltaNets(t *testing.T) {
tests := []struct {
a, b []*net.IPNet
wantAdd, wantDel []*net.IPNet
a, b []netip.Prefix
wantAdd, wantDel []netip.Prefix
}{
{
a: nets("1.2.3.4/24", "1.2.3.4/31", "1.2.3.3/32", "10.0.1.1/32", "100.0.1.1/32"),
@@ -161,30 +125,16 @@ func TestDeltaNets(t *testing.T) {
},
{
a: nets("100.84.36.11/32", "fe80::99d0:ec2d:b2e7:536b/64"),
b: nets("100.84.36.11/32[4]"),
b: nets("100.84.36.11/32"),
wantDel: nets("fe80::99d0:ec2d:b2e7:536b/64"),
},
{
a: []*net.IPNet{
{
IP: net.ParseIP("1.2.3.4"),
Mask: net.IPMask{0xff, 0xff, 0xff, 0xff},
},
},
b: []*net.IPNet{
{
IP: net.ParseIP("1.2.3.4"),
Mask: net.IPMask{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff},
},
},
},
}
for i, tt := range tests {
add, del := deltaNets(tt.a, tt.b)
if !equalNetIPs(add, tt.wantAdd) {
if !reflect.DeepEqual(nilIfEmpty(add), nilIfEmpty(tt.wantAdd)) {
t.Errorf("[%d] add:\n got: %v\n want: %v\n", i, add, tt.wantAdd)
}
if !equalNetIPs(del, tt.wantDel) {
if !reflect.DeepEqual(nilIfEmpty(del), nilIfEmpty(tt.wantDel)) {
t.Errorf("[%d] del:\n got: %v\n want: %v\n", i, del, tt.wantDel)
}
}
@@ -210,35 +160,40 @@ func equalRouteDatas(a, b []*winipcfg.RouteData) bool {
return true
}
func ipnet4(ip string, bits int) netip.Prefix {
return netip.PrefixFrom(netip.MustParseAddr(ip), bits)
}
func TestFilterRoutes(t *testing.T) {
var h0 net.IP
var h0 netip.Addr
in := []*winipcfg.RouteData{
// LinkLocal and Loopback routes.
{*ipnet4("169.254.0.0", 16), h0, 1},
{*ipnet4("169.254.255.255", 32), h0, 1},
{*ipnet4("127.0.0.0", 8), h0, 1},
{*ipnet4("127.255.255.255", 32), h0, 1},
{ipnet4("169.254.0.0", 16), h0, 1},
{ipnet4("169.254.255.255", 32), h0, 1},
{ipnet4("127.0.0.0", 8), h0, 1},
{ipnet4("127.255.255.255", 32), h0, 1},
// Local LAN routes.
{*ipnet4("192.168.0.0", 24), h0, 1},
{*ipnet4("192.168.0.255", 32), h0, 1},
{*ipnet4("192.168.1.0", 25), h0, 1},
{*ipnet4("192.168.1.127", 32), h0, 1},
{ipnet4("192.168.0.0", 24), h0, 1},
{ipnet4("192.168.0.255", 32), h0, 1},
{ipnet4("192.168.1.0", 25), h0, 1},
{ipnet4("192.168.1.127", 32), h0, 1},
// Some random other route.
{*ipnet4("192.168.2.23", 32), h0, 1},
{ipnet4("192.168.2.23", 32), h0, 1},
// Our own tailscale address.
{*ipnet4("100.100.100.100", 32), h0, 1},
{ipnet4("100.100.100.100", 32), h0, 1},
// Other tailscale addresses.
{*ipnet4("100.100.100.101", 32), h0, 1},
{*ipnet4("100.100.100.102", 32), h0, 1},
{ipnet4("100.100.100.101", 32), h0, 1},
{ipnet4("100.100.100.102", 32), h0, 1},
}
want := []*winipcfg.RouteData{
{*ipnet4("169.254.0.0", 16), h0, 1},
{*ipnet4("127.0.0.0", 8), h0, 1},
{*ipnet4("192.168.0.0", 24), h0, 1},
{*ipnet4("192.168.1.0", 25), h0, 1},
{*ipnet4("192.168.2.23", 32), h0, 1},
{*ipnet4("100.100.100.101", 32), h0, 1},
{*ipnet4("100.100.100.102", 32), h0, 1},
{ipnet4("169.254.0.0", 16), h0, 1},
{ipnet4("127.0.0.0", 8), h0, 1},
{ipnet4("192.168.0.0", 24), h0, 1},
{ipnet4("192.168.1.0", 25), h0, 1},
{ipnet4("192.168.2.23", 32), h0, 1},
{ipnet4("100.100.100.101", 32), h0, 1},
{ipnet4("100.100.100.102", 32), h0, 1},
}
got := filterRoutes(in, mustCIDRs("100.100.100.100/32"))
@@ -248,29 +203,29 @@ func TestFilterRoutes(t *testing.T) {
}
func TestDeltaRouteData(t *testing.T) {
var h0 net.IP
h1 := net.ParseIP("99.99.99.99")
h2 := net.ParseIP("99.99.9.99")
var h0 netip.Addr
h1 := netip.MustParseAddr("99.99.99.99")
h2 := netip.MustParseAddr("99.99.9.99")
a := []*winipcfg.RouteData{
{*ipnet4("1.2.3.4", 32), h0, 1},
{*ipnet4("1.2.3.4", 24), h1, 2},
{*ipnet4("1.2.3.4", 24), h2, 1},
{*ipnet4("1.2.3.5", 32), h0, 1},
{ipnet4("1.2.3.4", 32), h0, 1},
{ipnet4("1.2.3.4", 24), h1, 2},
{ipnet4("1.2.3.4", 24), h2, 1},
{ipnet4("1.2.3.5", 32), h0, 1},
}
b := []*winipcfg.RouteData{
{*ipnet4("1.2.3.5", 32), h0, 1},
{*ipnet4("1.2.3.4", 24), h1, 2},
{*ipnet4("1.2.3.4", 24), h2, 2},
{ipnet4("1.2.3.5", 32), h0, 1},
{ipnet4("1.2.3.4", 24), h1, 2},
{ipnet4("1.2.3.4", 24), h2, 2},
}
add, del := deltaRouteData(a, b)
wantAdd := []*winipcfg.RouteData{
{*ipnet4("1.2.3.4", 24), h2, 2},
{ipnet4("1.2.3.4", 24), h2, 2},
}
wantDel := []*winipcfg.RouteData{
{*ipnet4("1.2.3.4", 32), h0, 1},
{*ipnet4("1.2.3.4", 24), h2, 1},
{ipnet4("1.2.3.4", 32), h0, 1},
{ipnet4("1.2.3.4", 24), h2, 1},
}
if !equalRouteDatas(add, wantAdd) {

View File

@@ -50,13 +50,21 @@ const (
// Empirically, most of the documentation on packet marks on the
// internet gives the impression that the marks are 16 bits
// wide. Based on this, we theorize that the upper two bytes are
// relatively unused in the wild, and so we consume bits starting at
// the 17th.
// relatively unused in the wild, and so we consume bits 16:23 (the
// third byte).
//
// The constants are in the iptables/iproute2 string format for
// matching and setting the bits, so they can be directly embedded in
// commands.
const (
// The mask for reading/writing the 'firewall mask' bits on a packet.
// See the comment on the const block on why we only use the third byte.
//
// We claim bits 16:23 entirely. For now we only use the lower four
// bits, leaving the higher 4 bits for future use.
tailscaleFwmarkMask = "0xff0000"
tailscaleFwmarkMaskNum = 0xff0000
// Packet is from Tailscale and to a subnet route destination, so
// is allowed to be routed through this machine.
tailscaleSubnetRouteMark = "0x40000"
@@ -104,6 +112,10 @@ type linuxRouter struct {
ipRuleAvailable bool // whether kernel was built with IP_MULTIPLE_TABLES
v6Available bool
v6NATAvailable bool
fwmaskWorks bool // whether we can use 'ip rule...fwmark <mark>/<mask>'
// ipPolicyPrefBase is the base priority at which ip rules are installed.
ipPolicyPrefBase int
ipt4 netfilterRunner
ipt6 netfilterRunner
@@ -163,6 +175,7 @@ func newUserspaceRouterAdvanced(logf logger.Logf, tunname string, linkMon *monit
cmd: cmd,
ipRuleFixLimiter: rate.NewLimiter(rate.Every(5*time.Second), 10),
ipPolicyPrefBase: 5200,
}
if r.useIPCommand() {
r.ipRuleAvailable = (cmd.run("ip", "rule") == nil)
@@ -176,9 +189,126 @@ func newUserspaceRouterAdvanced(logf logger.Logf, tunname string, linkMon *monit
}
}
// To be a good denizen of the 4-byte 'fwmark' bitspace on every packet, we try to
// only use the third byte. However, support for masking to part of the fwmark bitspace
// was only added to busybox in 1.33.0. As such, we want to detect older versions and
// not issue such a stanza.
var err error
if r.fwmaskWorks, err = ipCmdSupportsFwmask(); err != nil {
r.logf("failed to determine ip command fwmask support: %v", err)
}
if r.fwmaskWorks {
r.logf("[v1] ip command supports fwmark masks")
} else {
r.logf("[v1] ip command does NOT support fwmark masks")
}
// A common installation of OpenWRT involves use of the 'mwan3' package.
// This package installs ip-tables rules like:
// -A mwan3_fallback_policy -m mark --mark 0x0/0x3f00 -j MARK --set-xmark 0x100/0x3f00
//
// which coupled with an ip rule:
// 2001: from all fwmark 0x100/0x3f00 lookup 1
//
// has the effect of gobbling tailscale packets, because tailscale by default installs
// its policy routing rules at priority 52xx.
//
// As such, if we are running on openWRT, detect a mwan3 config, AND detect a rule
// with a preference 2001 (corresponding to the first interface wman3 manages), we
// shift the priority of our policies to 13xx. This effectively puts us betwen mwan3's
// permit-by-src-ip rules and mwan3 lookup of its own routing table which would drop
// the packet.
isMWAN3, err := checkOpenWRTUsingMWAN3()
if err != nil {
r.logf("error checking mwan3 installation: %v", err)
} else if isMWAN3 {
r.ipPolicyPrefBase = 1300
r.logf("mwan3 on openWRT detected, switching policy base priority to 1300")
}
return r, nil
}
// ipCmdSupportsFwmask returns true if the system 'ip' binary supports using a
// fwmark stanza with a mask specified. To our knowledge, everything except busybox
// pre-1.33 supports this.
func ipCmdSupportsFwmask() (bool, error) {
ipPath, err := exec.LookPath("ip")
if err != nil {
return false, fmt.Errorf("lookpath: %v", err)
}
stat, err := os.Lstat(ipPath)
if err != nil {
return false, fmt.Errorf("lstat: %v", err)
}
if stat.Mode()&os.ModeSymlink == 0 {
// Not a symlink, so can't be busybox. Must be regular ip utility.
return true, nil
}
linkDest, err := os.Readlink(ipPath)
if err != nil {
return false, err
}
if !strings.Contains(strings.ToLower(linkDest), "busybox") {
// Not busybox, presumably supports fwmark masks.
return true, nil
}
// If we got this far, the ip utility is a busybox version with an
// unknown version.
// We run `ip --version` and look for the busybox banner (which
// is a stable 'BusyBox vX.Y.Z (<builddate>)' string) to determine
// the version.
out, err := exec.Command("ip", "--version").CombinedOutput()
if err != nil {
return false, err
}
major, minor, _, err := busyboxParseVersion(string(out))
if err != nil {
return false, nil
}
// Support for masks added in 1.33.0.
switch {
case major > 1:
return true, nil
case major == 1 && minor >= 33:
return true, nil
default:
return false, nil
}
}
func busyboxParseVersion(output string) (major, minor, patch int, err error) {
bannerStart := strings.Index(output, "BusyBox v")
if bannerStart < 0 {
return 0, 0, 0, errors.New("missing BusyBox banner")
}
bannerEnd := bannerStart + len("BusyBox v")
end := strings.Index(output[bannerEnd:], " ")
if end < 0 {
return 0, 0, 0, errors.New("missing end delimiter")
}
elements := strings.Split(output[bannerEnd:bannerEnd+end], ".")
if len(elements) < 3 {
return 0, 0, 0, fmt.Errorf("expected 3 version elements, got %d", len(elements))
}
if major, err = strconv.Atoi(elements[0]); err != nil {
return 0, 0, 0, fmt.Errorf("parsing major: %v", err)
}
if minor, err = strconv.Atoi(elements[1]); err != nil {
return 0, 0, 0, fmt.Errorf("parsing minor: %v", err)
}
if patch, err = strconv.Atoi(elements[2]); err != nil {
return 0, 0, 0, fmt.Errorf("parsing patch: %v", err)
}
return major, minor, patch, nil
}
func useAmbientCaps() bool {
if distro.Get() != distro.Synology {
return false
@@ -219,7 +349,7 @@ func (r *linuxRouter) useIPCommand() bool {
// about the priority number. We could just do this in response to any netlink
// change. Filtering by known priority ranges cuts back on some logspam.
func (r *linuxRouter) onIPRuleDeleted(table uint8, priority uint32) {
if priority < 5200 || priority >= 5300 {
if int(priority) < r.ipPolicyPrefBase || int(priority) >= (r.ipPolicyPrefBase+100) {
// Not our rule.
return
}
@@ -870,6 +1000,8 @@ var (
)
// ipRules are the policy routing rules that Tailscale uses.
// The priority is the value represented here added to r.ipPolicyPrefBase,
// which is usually 5200.
//
// NOTE(apenwarr): We leave spaces between each pref number.
// This is so the sysadmin can override by inserting rules in
@@ -886,14 +1018,14 @@ var ipRules = []netlink.Rule{
// Packets from us, tagged with our fwmark, first try the kernel's
// main routing table.
{
Priority: 5210,
Priority: 10,
Mark: tailscaleBypassMarkNum,
Table: mainRouteTable.num,
},
// ...and then we try the 'default' table, for correctness,
// even though it's been empty on every Linux system I've ever seen.
{
Priority: 5230,
Priority: 30,
Mark: tailscaleBypassMarkNum,
Table: defaultRouteTable.num,
},
@@ -901,7 +1033,7 @@ var ipRules = []netlink.Rule{
// then packets from us should be aborted rather than falling through
// to the tailscale routes, because that would create routing loops.
{
Priority: 5250,
Priority: 50,
Mark: tailscaleBypassMarkNum,
Type: unix.RTN_UNREACHABLE,
},
@@ -911,7 +1043,7 @@ var ipRules = []netlink.Rule{
// it takes precedence over all the others, ie. VPN routes always
// beat non-VPN routes.
{
Priority: 5270,
Priority: 70,
Table: tailscaleRouteTable.num,
},
// If that didn't match, then non-fwmark packets fall through to the
@@ -928,14 +1060,18 @@ func (r *linuxRouter) justAddIPRules() error {
}
var errAcc error
for _, family := range r.addrFamilies() {
for _, ru := range ipRules {
// Note: r is a value type here; safe to mutate it.
ru.Family = family.netlinkInt()
ru.Mask = -1
if ru.Mark != 0 {
ru.Mask = tailscaleFwmarkMaskNum
}
ru.Goto = -1
ru.SuppressIfgroup = -1
ru.SuppressPrefixlen = -1
ru.Flow = -1
ru.Priority += r.ipPolicyPrefBase
err := netlink.RuleAdd(&ru)
if errors.Is(err, errEEXIST) {
@@ -954,19 +1090,23 @@ func (r *linuxRouter) addIPRulesWithIPCommand() error {
rg := newRunGroup(nil, r.cmd)
for _, family := range r.addrFamilies() {
for _, r := range ipRules {
for _, rule := range ipRules {
args := []string{
"ip", family.dashArg(),
"rule", "add",
"pref", strconv.Itoa(r.Priority),
"pref", strconv.Itoa(rule.Priority + r.ipPolicyPrefBase),
}
if r.Mark != 0 {
args = append(args, "fwmark", fmt.Sprintf("0x%x", r.Mark))
if rule.Mark != 0 {
if r.fwmaskWorks {
args = append(args, "fwmark", fmt.Sprintf("0x%x/%s", rule.Mark, tailscaleFwmarkMask))
} else {
args = append(args, "fwmark", fmt.Sprintf("0x%x", rule.Mark))
}
}
if r.Table != 0 {
args = append(args, "table", mustRouteTable(r.Table).ipCmdArg())
if rule.Table != 0 {
args = append(args, "table", mustRouteTable(rule.Table).ipCmdArg())
}
if r.Type == unix.RTN_UNREACHABLE {
if rule.Type == unix.RTN_UNREACHABLE {
args = append(args, "type", "unreachable")
}
rg.Run(args...)
@@ -1011,6 +1151,7 @@ func (r *linuxRouter) delIPRules() error {
ru.Goto = -1
ru.SuppressIfgroup = -1
ru.SuppressPrefixlen = -1
ru.Priority += r.ipPolicyPrefBase
err := netlink.RuleDel(&ru)
if errors.Is(err, errENOENT) {
@@ -1040,14 +1181,14 @@ func (r *linuxRouter) delIPRulesWithIPCommand() error {
// That leaves us some flexibility to change these values in later
// versions without having ongoing hacks for every possible
// combination.
for _, r := range ipRules {
for _, rule := range ipRules {
args := []string{
"ip", family.dashArg(),
"rule", "del",
"pref", strconv.Itoa(r.Priority),
"pref", strconv.Itoa(rule.Priority + r.ipPolicyPrefBase),
}
if r.Table != 0 {
args = append(args, "table", mustRouteTable(r.Table).ipCmdArg())
if rule.Table != 0 {
args = append(args, "table", mustRouteTable(rule.Table).ipCmdArg())
} else {
args = append(args, "type", "unreachable")
}
@@ -1141,11 +1282,11 @@ func (r *linuxRouter) addNetfilterBase4() error {
// POSTROUTING. So instead, we match on the inbound interface in
// filter/FORWARD, and set a packet mark that nat/POSTROUTING can
// use to effectively run that same test again.
args = []string{"-i", r.tunname, "-j", "MARK", "--set-mark", tailscaleSubnetRouteMark}
args = []string{"-i", r.tunname, "-j", "MARK", "--set-mark", tailscaleSubnetRouteMark + "/" + tailscaleFwmarkMask}
if err := r.ipt4.Append("filter", "ts-forward", args...); err != nil {
return fmt.Errorf("adding %v in v4/filter/ts-forward: %w", args, err)
}
args = []string{"-m", "mark", "--mark", tailscaleSubnetRouteMark, "-j", "ACCEPT"}
args = []string{"-m", "mark", "--mark", tailscaleSubnetRouteMark + "/" + tailscaleFwmarkMask, "-j", "ACCEPT"}
if err := r.ipt4.Append("filter", "ts-forward", args...); err != nil {
return fmt.Errorf("adding %v in v4/filter/ts-forward: %w", args, err)
}
@@ -1167,11 +1308,11 @@ func (r *linuxRouter) addNetfilterBase6() error {
// TODO: only allow traffic from Tailscale's ULA range to come
// from tailscale0.
args := []string{"-i", r.tunname, "-j", "MARK", "--set-mark", tailscaleSubnetRouteMark}
args := []string{"-i", r.tunname, "-j", "MARK", "--set-mark", tailscaleSubnetRouteMark + "/" + tailscaleFwmarkMask}
if err := r.ipt6.Append("filter", "ts-forward", args...); err != nil {
return fmt.Errorf("adding %v in v6/filter/ts-forward: %w", args, err)
}
args = []string{"-m", "mark", "--mark", tailscaleSubnetRouteMark, "-j", "ACCEPT"}
args = []string{"-m", "mark", "--mark", tailscaleSubnetRouteMark + "/" + tailscaleFwmarkMask, "-j", "ACCEPT"}
if err := r.ipt6.Append("filter", "ts-forward", args...); err != nil {
return fmt.Errorf("adding %v in v6/filter/ts-forward: %w", args, err)
}
@@ -1343,7 +1484,7 @@ func (r *linuxRouter) addSNATRule() error {
return nil
}
args := []string{"-m", "mark", "--mark", tailscaleSubnetRouteMark, "-j", "MASQUERADE"}
args := []string{"-m", "mark", "--mark", tailscaleSubnetRouteMark + "/" + tailscaleFwmarkMask, "-j", "MASQUERADE"}
if err := r.ipt4.Append("nat", "ts-postrouting", args...); err != nil {
return fmt.Errorf("adding %v in v4/nat/ts-postrouting: %w", args, err)
}
@@ -1362,7 +1503,7 @@ func (r *linuxRouter) delSNATRule() error {
return nil
}
args := []string{"-m", "mark", "--mark", tailscaleSubnetRouteMark, "-j", "MASQUERADE"}
args := []string{"-m", "mark", "--mark", tailscaleSubnetRouteMark + "/" + tailscaleFwmarkMask, "-j", "MASQUERADE"}
if err := r.ipt4.Delete("nat", "ts-postrouting", args...); err != nil {
return fmt.Errorf("deleting %v in v4/nat/ts-postrouting: %w", args, err)
}
@@ -1548,6 +1689,39 @@ func checkIPRuleSupportsV6(logf logger.Logf) error {
return netlink.RuleAdd(rule)
}
// Checks if the running openWRT system is using mwan3, based on the heuristic
// of the config file being present as well as a policy rule with a specific
// priority (2000 + 1 - first interface mwan3 manages) and non-zero mark.
func checkOpenWRTUsingMWAN3() (bool, error) {
if distro.Get() != distro.OpenWrt {
return false, nil
}
if _, err := os.Stat("/etc/config/mwan3"); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
rules, err := netlink.RuleList(netlink.FAMILY_V4)
if err != nil {
return false, err
}
for _, r := range rules {
// We want to match on a rule like this:
// 2001: from all fwmark 0x100/0x3f00 lookup 1
//
// We dont match on the mask because it can vary, or the
// table because I'm not sure if it can vary.
if r.Priority == 2001 && r.Mark != 0 {
return true, nil
}
}
return false, nil
}
func nlAddrOfPrefix(p netip.Prefix) *netlink.Addr {
return &netlink.Addr{
IPNet: netipx.PrefixIPNet(p),

View File

@@ -25,13 +25,13 @@ import (
func TestRouterStates(t *testing.T) {
basic := `
ip rule add -4 pref 5210 fwmark 0x80000 table main
ip rule add -4 pref 5230 fwmark 0x80000 table default
ip rule add -4 pref 5250 fwmark 0x80000 type unreachable
ip rule add -4 pref 5210 fwmark 0x80000/0xff0000 table main
ip rule add -4 pref 5230 fwmark 0x80000/0xff0000 table default
ip rule add -4 pref 5250 fwmark 0x80000/0xff0000 type unreachable
ip rule add -4 pref 5270 table 52
ip rule add -6 pref 5210 fwmark 0x80000 table main
ip rule add -6 pref 5230 fwmark 0x80000 table default
ip rule add -6 pref 5250 fwmark 0x80000 type unreachable
ip rule add -6 pref 5210 fwmark 0x80000/0xff0000 table main
ip rule add -6 pref 5230 fwmark 0x80000/0xff0000 table default
ip rule add -6 pref 5250 fwmark 0x80000/0xff0000 type unreachable
ip rule add -6 pref 5270 table 52
`
states := []struct {
@@ -101,22 +101,22 @@ ip route add 10.0.0.0/8 dev tailscale0 table 52
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
`v4/filter/FORWARD -j ts-forward
v4/filter/INPUT -j ts-input
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v4/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
v4/filter/ts-forward -o tailscale0 -j ACCEPT
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN
v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
v4/nat/POSTROUTING -j ts-postrouting
v4/nat/ts-postrouting -m mark --mark 0x40000 -j MASQUERADE
v4/nat/ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE
v6/filter/FORWARD -j ts-forward
v6/filter/INPUT -j ts-input
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v6/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v6/filter/ts-forward -o tailscale0 -j ACCEPT
v6/nat/POSTROUTING -j ts-postrouting
v6/nat/ts-postrouting -m mark --mark 0x40000 -j MASQUERADE
v6/nat/ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE
`,
},
{
@@ -133,8 +133,8 @@ ip route add 10.0.0.0/8 dev tailscale0 table 52
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
`v4/filter/FORWARD -j ts-forward
v4/filter/INPUT -j ts-input
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v4/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
v4/filter/ts-forward -o tailscale0 -j ACCEPT
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
@@ -143,8 +143,8 @@ v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
v4/nat/POSTROUTING -j ts-postrouting
v6/filter/FORWARD -j ts-forward
v6/filter/INPUT -j ts-input
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v6/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v6/filter/ts-forward -o tailscale0 -j ACCEPT
v6/nat/POSTROUTING -j ts-postrouting
`,
@@ -166,8 +166,8 @@ ip route add 10.0.0.0/8 dev tailscale0 table 52
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
`v4/filter/FORWARD -j ts-forward
v4/filter/INPUT -j ts-input
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v4/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
v4/filter/ts-forward -o tailscale0 -j ACCEPT
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
@@ -176,8 +176,8 @@ v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
v4/nat/POSTROUTING -j ts-postrouting
v6/filter/FORWARD -j ts-forward
v6/filter/INPUT -j ts-input
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v6/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v6/filter/ts-forward -o tailscale0 -j ACCEPT
v6/nat/POSTROUTING -j ts-postrouting
`,
@@ -196,8 +196,8 @@ ip route add 10.0.0.0/8 dev tailscale0 table 52
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
`v4/filter/FORWARD -j ts-forward
v4/filter/INPUT -j ts-input
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v4/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
v4/filter/ts-forward -o tailscale0 -j ACCEPT
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
@@ -206,8 +206,8 @@ v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
v4/nat/POSTROUTING -j ts-postrouting
v6/filter/FORWARD -j ts-forward
v6/filter/INPUT -j ts-input
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v6/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v6/filter/ts-forward -o tailscale0 -j ACCEPT
v6/nat/POSTROUTING -j ts-postrouting
`,
@@ -225,15 +225,15 @@ up
ip addr add 100.101.102.104/10 dev tailscale0
ip route add 10.0.0.0/8 dev tailscale0 table 52
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
`v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v4/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
`v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
v4/filter/ts-forward -o tailscale0 -j ACCEPT
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN
v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v6/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v6/filter/ts-forward -o tailscale0 -j ACCEPT
`,
},
@@ -251,8 +251,8 @@ ip route add 10.0.0.0/8 dev tailscale0 table 52
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
`v4/filter/FORWARD -j ts-forward
v4/filter/INPUT -j ts-input
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v4/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
v4/filter/ts-forward -o tailscale0 -j ACCEPT
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
@@ -261,8 +261,8 @@ v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
v4/nat/POSTROUTING -j ts-postrouting
v6/filter/FORWARD -j ts-forward
v6/filter/INPUT -j ts-input
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v6/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v6/filter/ts-forward -o tailscale0 -j ACCEPT
v6/nat/POSTROUTING -j ts-postrouting
`,
@@ -283,8 +283,8 @@ ip route add 100.100.100.100/32 dev tailscale0 table 52
ip route add throw 10.0.0.0/8 table 52` + basic +
`v4/filter/FORWARD -j ts-forward
v4/filter/INPUT -j ts-input
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v4/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
v4/filter/ts-forward -o tailscale0 -j ACCEPT
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
@@ -293,8 +293,8 @@ v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
v4/nat/POSTROUTING -j ts-postrouting
v6/filter/FORWARD -j ts-forward
v6/filter/INPUT -j ts-input
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000
v6/filter/ts-forward -m mark --mark 0x40000 -j ACCEPT
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
v6/filter/ts-forward -o tailscale0 -j ACCEPT
v6/nat/POSTROUTING -j ts-postrouting
`,
@@ -811,3 +811,31 @@ func TestCheckIPRuleSupportsV6(t *testing.T) {
// Some machines running our tests might not have IPv6.
t.Logf("Got: %v", err)
}
func TestBusyboxParseVersion(t *testing.T) {
input := `BusyBox v1.34.1 (2022-09-01 16:10:29 UTC) multi-call binary.
BusyBox is copyrighted by many authors between 1998-2015.
Licensed under GPLv2. See source distribution for detailed
copyright notices.
Usage: busybox [function [arguments]...]
or: busybox --list[-full]
or: busybox --show SCRIPT
or: busybox --install [-s] [DIR]
or: function [arguments]...
BusyBox is a multi-call binary that combines many common Unix
utilities into a single executable. Most people will create a
link to busybox for each function they wish to use and BusyBox
will act like whatever it was invoked as.
`
v1, v2, v3, err := busyboxParseVersion(input)
if err != nil {
t.Fatalf("busyboxParseVersion() failed: %v", err)
}
if got, want := fmt.Sprintf("%d.%d.%d", v1, v2, v3), "1.34.1"; got != want {
t.Errorf("version = %q, want %q", got, want)
}
}

View File

@@ -1005,6 +1005,7 @@ func (e *userspaceEngine) getStatus() (*Status, error) {
closing := e.closing
peerKeys := make([]key.NodePublic, len(e.peerSequence))
copy(peerKeys, e.peerSequence)
localAddrs := append([]tailcfg.Endpoint(nil), e.endpoints...)
e.mu.Unlock()
if closing {
@@ -1020,7 +1021,7 @@ func (e *userspaceEngine) getStatus() (*Status, error) {
return &Status{
AsOf: time.Now(),
LocalAddrs: append([]tailcfg.Endpoint(nil), e.endpoints...),
LocalAddrs: localAddrs,
Peers: peers,
DERPs: derpConns,
}, nil

View File

@@ -22,38 +22,28 @@ var (
func getPeerStatsOffset(name string) uintptr {
peerType := reflect.TypeOf(device.Peer{})
sf, ok := peerType.FieldByName("stats")
field, ok := peerType.FieldByName(name)
if !ok {
panic("no stats field in device.Peer")
panic("no " + name + " field in device.Peer")
}
if sf.Type.Kind() != reflect.Struct {
panic("stats field is not a struct")
if s := field.Type.String(); s != "atomic.Int64" && s != "atomic.Uint64" {
panic("unexpected type " + s + " of field " + name + " in device.Peer")
}
base := sf.Offset
st := sf.Type
field, ok := st.FieldByName(name)
if !ok {
panic("no " + name + " field in device.Peer.stats")
}
if field.Type.Kind() != reflect.Int64 && field.Type.Kind() != reflect.Uint64 {
panic("unexpected kind of " + name + " field in device.Peer.stats")
}
return base + field.Offset
return field.Offset
}
// PeerLastHandshakeNano returns the last handshake time in nanoseconds since the
// unix epoch.
func PeerLastHandshakeNano(peer *device.Peer) int64 {
return atomic.LoadInt64((*int64)(unsafe.Add(unsafe.Pointer(peer), offHandshake)))
return (*atomic.Int64)(unsafe.Add(unsafe.Pointer(peer), offHandshake)).Load()
}
// PeerRxBytes returns the number of bytes received from this peer.
func PeerRxBytes(peer *device.Peer) uint64 {
return atomic.LoadUint64((*uint64)(unsafe.Add(unsafe.Pointer(peer), offRxBytes)))
return (*atomic.Uint64)(unsafe.Add(unsafe.Pointer(peer), offRxBytes)).Load()
}
// PeerTxBytes returns the number of bytes sent to this peer.
func PeerTxBytes(peer *device.Peer) uint64 {
return atomic.LoadUint64((*uint64)(unsafe.Add(unsafe.Pointer(peer), offTxBytes)))
return (*atomic.Uint64)(unsafe.Add(unsafe.Pointer(peer), offTxBytes)).Load()
}

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