Compare commits

...

168 Commits

Author SHA1 Message Date
Naman Sood
cd4e7e51a7 use net.JoinHostPort
Signed-off-by: Naman Sood <mail@nsood.in>
2021-04-21 14:44:07 -04:00
Naman Sood
6bb159d5fa wgengine/netstack: log ForwarderRequest in readable form, only in debug mode
Fixes #1757

Signed-off-by: Naman Sood <mail@nsood.in>
2021-04-21 14:31:46 -04:00
Brad Fitzpatrick
91c9c33036 cmd/tailscaled: don't block ipnserver startup behind engine init on Windows
With this change, the ipnserver's safesocket.Listen (the localhost
tcp.Listen) happens right away, before any synchronous
TUN/DNS/Engine/etc setup work, which might be slow, especially on
early boot on Windows.

Because the safesocket.Listen starts up early, that means localhost
TCP dials (the safesocket.Connect from the GUI) complete successfully
and thus the GUI avoids the MessageBox error. (I verified that
pacifies it, even without a Listener.Accept; I'd feared that Windows
localhost was maybe special and avoided the normal listener backlog).

Once the GUI can then connect immediately without errors, the various
timeouts then matter less, because the backend is no longer trying to
race against the GUI's timeout. So keep retrying on errors for a
minute, or 10 minutes if the system just booted in the past 10
minutes.

This should fix the problem with Windows 10 desktops auto-logging in
and starting the Tailscale frontend which was then showing a
MessageBox error about failing to connect to tailscaled, which was
slow coming up because the Windows networking stack wasn't up
yet. Fingers crossed.

Fixes #1313 (previously #1187, etc)

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-20 22:26:27 -07:00
Alex Brainman
7d8f082ff7 .github/workflows: add --race tests on Linux and Windows
Updates #50
Updates #833

Signed-off-by: Alex Brainman <alex.brainman@gmail.com>
2021-04-20 21:50:26 -07:00
Alex Brainman
7689213aaa cmd/tailscaled: add subcommands to install and remove tailscaled Windows service
This change implements Windows version of install-system-daemon and
uninstall-system-daemon subcommands. When running the commands the
user will install or remove Tailscale Windows service.

Updates #1232

Signed-off-by: Alex Brainman <alex.brainman@gmail.com>
2021-04-20 21:40:59 -07:00
David Anderson
6fd9e28bd0 ipn/ipnlocal: add arpa suffixes to MagicDNS for reverse lookups.
This used to not be necessary, because MagicDNS always did full proxying.
But with split DNS, we need to know which names to route to our resolver,
otherwise reverse lookups break.

This captures the entire CGNAT range, as well as our Tailscale ULA.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-20 18:05:17 -07:00
David Anderson
89c81c26c5 net/dns: fix resolved match domains when no nameservers are provided.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-20 17:10:39 -07:00
David Anderson
4be26b269f net/dns: correctly capture all traffic in non-split configs.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-20 16:57:46 -07:00
David Anderson
ca283ac899 net/dns: remove config in openresolv when given an empty DNS config.
Part of #1720.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-20 16:19:34 -07:00
David Anderson
48d4f14652 ipn/ipnlocal: only set authoritative domains when using MagicDNS.
Otherwise, the existence of authoritative domains forces full
DNS proxying even when no other DNS config is present.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-20 15:52:19 -07:00
David Anderson
53213114ec net/dns: make debian_resolvconf correctly clear DNS configs.
More of #1720.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-20 15:51:14 -07:00
David Anderson
3b1ab78954 net/dns: restore resolv.conf when given an empty config in directManager.
Fixes #1720.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-20 15:14:40 -07:00
Brad Fitzpatrick
f99e63bb17 ipn: don't Logout when Windows GUI disconnects
Logout used to be a no-op, so the ipnserver previously synthensized a Logout
on disconnect. Now that Logout actually invalidates the node key that was
forcing all GUI closes to log people out.

Instead, add a method to LocalBackend to specifically mean "the
Windows GUI closed, please forget all the state".

Fixes tailscale/corp#1591 (ignoring the notification issues, tracked elsewhere)

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-20 13:14:10 -07:00
David Anderson
158328ba24 net/dns: remove ForceSplitDNSForTesting.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-20 12:50:08 -07:00
David Anderson
1e5c608fae ipn/ipnlocal: plumb fallback DNS in as a workaround for split DNS issues.
Cause of #1743.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-20 12:49:48 -07:00
David Anderson
28ba20d733 tailcfg: add FallbackResolvers to DNSConfig.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-20 12:49:48 -07:00
Brad Fitzpatrick
3d0599fca0 ipn{,/ipnlocal}: in direct file receive mode, don't rename partial file
Let caller (macOS) do it so Finder progress bar can be dismissed
without races.

Updates tailscale/corp#1575

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-20 12:40:42 -07:00
Josh Bleecher Snyder
48e30bb8de wgengine/magicsock: remove named return
Doesn't add anything.

Signed-off-by: Josh Bleecher Snyder <josharian@gmail.com>
2021-04-20 10:12:07 -07:00
Josh Bleecher Snyder
a2a2c0ce1c wgengine/magicsock: fix two comments
Signed-off-by: Josh Bleecher Snyder <josharian@gmail.com>
2021-04-20 10:12:07 -07:00
Josh Bleecher Snyder
b1e624ef04 wgengine/magicsock: remove unnecessary type assertions
Signed-off-by: Josh Bleecher Snyder <josharian@gmail.com>
2021-04-20 10:12:07 -07:00
Josh Bleecher Snyder
98714e784b wgengine/magicsock: improve Rebind logging
We were accidentally logging oldPort -> oldPort.

Log oldPort as well as c.port; if we failed to get the preferred port
in a previous rebind, oldPort might differ from c.port.

Signed-off-by: Josh Bleecher Snyder <josharian@gmail.com>
2021-04-20 10:12:07 -07:00
Josh Bleecher Snyder
15ceacc4c5 wgengine/magicsock: accept a host and port instead of an addr in listenPacket
This simplifies call sites and prevents accidental failure to use net.JoinHostPort.

Signed-off-by: Josh Bleecher Snyder <josharian@gmail.com>
2021-04-20 10:12:07 -07:00
Brad Fitzpatrick
f42ded7acf cmd/tailscale/cli: relax & improve the running-as-CLI check for macOS
On macOS, we link the CLI into the GUI executable so it can be included in
the Mac App Store build.

You then need to run it like:

/Applications/Tailscale.app/Contents/MacOS/Tailscale <command>

But our old detection of whether you're running that Tailscale binary
in CLI mode wasn't accurate and often bit people. For instance, when
they made a typo, it then launched in GUI mode and broke their
existing GUI connection (starting a new IPNExtension) and took down
their network.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-20 09:18:33 -07:00
Brad Fitzpatrick
a58fbb4da9 ipn/ipnlocal: only fix peerapiListener on Windows when running
It's just logspam otherwise.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-20 09:18:33 -07:00
Maisem Ali
36fa29feec ipn/ipnlocal: restrict local lan access to linux machines.
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2021-04-20 08:11:06 -07:00
Brad Fitzpatrick
8570f82c8b ipn/ipnlocal: finish/fix up filename validation & encoding on disk
It used to just store received files URL-escaped on disk, but that was
a half done lazy implementation, and pushed the burden to callers to
validate and write things to disk in an unescaped way.

Instead, do all the validation in the receive handler and only
accept filenames that are UTF-8 and in the intersection of valid
names that all platforms support.

Fixes tailscale/corp#1594

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-19 22:39:53 -07:00
Brad Fitzpatrick
7f8519c88f version: simplify iOS detection now that we require Go 1.16
See https://golang.org/doc/go1.16#darwin

No need for build tag tricks anymore.
2021-04-19 21:59:55 -07:00
Brad Fitzpatrick
cad8df500c ipn/ipnlocal: add some more peerapi handlePeerPut tests
Updates tailscale/corp#1594

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-19 21:01:04 -07:00
Brad Fitzpatrick
0d1550898e ipn/ipnlocal: add some peerapi tests
Updates tailscale/corp#1594

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-19 20:28:53 -07:00
Josh Bleecher Snyder
f72a120016 go.mod: upgrade to latest wireguard-go
Pull in minor upstream changes.

Signed-off-by: Josh Bleecher Snyder <josharian@gmail.com>
2021-04-19 13:30:43 -07:00
Naman Sood
71b7e48547 net/tsaddr: expand ephemeral nodes range to /64
Signed-off-by: Naman Sood <mail@nsood.in>
2021-04-19 15:54:53 -04:00
Brad Fitzpatrick
e9d24341e0 tailcfg, control/controlclient: accept nil MapResponse.Node (mapver 18)
All MapResponse fields can not be omitted and are tagged "omitempty".

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-19 11:53:21 -07:00
Brad Fitzpatrick
97204fdc52 safesocket: remove/update some old TODOs
Windows auth is done by looking at the owner of the TCP connection.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-19 11:35:02 -07:00
Brad Fitzpatrick
8f3e453356 ipn, cmd/tailscale/cli: add pref to configure sudo-free operator user
From discussion with @danderson.

Fixes #1684 (in a different way)

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-19 10:12:41 -07:00
Brad Fitzpatrick
3739cf22b0 tailcfg, control/controlclient: allow empty MapResponse.Domain (mapver17)
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-19 09:31:21 -07:00
Brad Fitzpatrick
5092cffd1f control/controlclient: add start of some MapResponse->NetworkMap tests
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-18 20:27:51 -07:00
Brad Fitzpatrick
aef3c0350c control/controlclient: break direct.go into map.go (+tests), add mapSession
So the NetworkMap-from-incremental-MapResponses can be tested easily.

And because direct.go was getting too big.

No change in behavior at this point. Just movement.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-18 19:56:03 -07:00
Brad Fitzpatrick
6d64107f26 types/netmap: remove some old TODOs
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-18 19:30:13 -07:00
Brad Fitzpatrick
49808ae6ea ipn{,/ipnlocal}, cmd/tailscale/cli: don't check pref reverts on initial up
The ipn.NewPrefs func returns a populated ipn.Prefs for historical
reasons. It's not used or as important as it once was, but it hasn't
yet been removed. Meanwhile, it contains some default values that are
used on some platforms. Notably, for this bug (#1725), Windows/Mac use
its Prefs.RouteAll true value (to accept subnets), but Linux users
have always gotten a "false" value for that, because that's what
cmd/tailscale's CLI default flag is _for all operating systems_.  That
meant that "tailscale up" was rightfully reporting that the user was
changing an implicit setting: RouteAll was changing from true with
false with the user explicitly saying so.

An obvious fix might be to change ipn.NewPrefs to return
Prefs.RouteAll == false on some platforms, but the logic is
complicated by darwin: we want RouteAll true on windows, android, ios,
and the GUI mac app, but not the CLI tailscaled-on-macOS mode. But
even if we used build tags (e.g. the "redo" build tag) to determine
what the default is, that then means we have duplicated and differing
"defaults" between both the CLI up flags and ipn.NewPrefs. Furthering
that complication didn't seem like a good idea.

So, changing the NewPrefs defaults is too invasive at this stage of
the release, as is removing the NewPrefs func entirely.

Instead, tweak slightly the semantics of the ipn.Prefs.ControlURL
field. This now defines that a ControlURL of the empty string means
both "we're uninitialized" and also "just use the default".

Then, once we have the "empty-string-means-unintialized" semantics,
use that to suppress "tailscale up"'s recent implicit-setting-revert
checking safety net, if we've never initialized Tailscale yet.

And update/add tests.

Fixes #1725

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-18 08:12:18 -07:00
Brad Fitzpatrick
4df6e62fbc ipn: add DefaultControlURL const, replace few literals with it
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-18 07:48:53 -07:00
Brad Fitzpatrick
f1d45bc4bb cmd/tailscale/cli: pull out prefsFromUpArgs for testability, add tests
Will add more tests later but this locks in all the existing warnings
and errors at least, and some of the existing non-error behavior.

Mostly I want this to exist before I actually fix #1725.

Updates #1725

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-17 20:59:33 -07:00
Brad Fitzpatrick
4948ff6ecb cmd/tailscale/cli: treat nil and non-nil zero length slices as equiv prefs
Updates #1725

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-17 19:17:18 -07:00
Brad Fitzpatrick
eb6115e295 cmd/tailscaled: let SOCKS5 dial non-Tailscale addrs in userspace mode
Fixes #1617

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-16 16:20:31 -07:00
Naman Sood
b85d80b37f net/tsaddr: add new IP range for ephemeral nodes in Tailscale ULA (#1715)
Signed-off-by: Naman Sood <mail@nsood.in>
2021-04-16 14:47:55 -04:00
Brad Fitzpatrick
b993d9802a ipn/ipnlocal, etc: require file sharing capability to send/recv files
tailscale/corp#1582

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-16 10:58:19 -07:00
Brad Fitzpatrick
2f422434aa cmd/tailscale/cli: wait on the right contexts in up
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-16 10:27:56 -07:00
Brad Fitzpatrick
6da812b4cf cmd/tailscale/cli: avoid a spammy log message on SIGINT
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-16 08:00:31 -07:00
Brad Fitzpatrick
670838c45f tailcfg, control/controlclient: (mapver 16) add Node.Online, MapResponse.OnlineChange
And fix PeerSeenChange bug where it was ignored unless there were
other peer changes.

Updates tailscale/corp#1574

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-15 20:07:24 -07:00
David Anderson
7055f870f8 control/controlclient: only use a single DNS label as the hostname.
Fixes #971

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-15 17:08:58 -07:00
Maisem Ali
4f3203556d wgengine/router: add the Tailscale ULA route on darwin.
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2021-04-15 17:07:50 -07:00
Ross Zurowski
c748c20fba cmd/tailscale: fix command descriptions (#1710)
Signed-off-by: Ross Zurowski <ross@rosszurowski.com>
2021-04-15 18:33:23 -04:00
Brad Fitzpatrick
b34fbb24e8 logtail: reduce PublicID.UnmarshalText from 2 allocs to 0
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-15 10:42:12 -07:00
David Anderson
bb0710d51d net/dns: add debugging traces to DNS manager selection on linux.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-14 15:52:41 -07:00
David Anderson
4b70c7b717 net/dns: fix inverted test for NetworkManager.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-14 15:52:22 -07:00
David Anderson
4849a4d3c8 net/dns: error out on linux if /etc/resolv.conf can't be read.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-14 15:35:32 -07:00
David Anderson
1f9b73a531 net/dns: fix freebsd DNS manager selection.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-14 15:34:59 -07:00
Naman Sood
5ea53891fe cmd/tailscaled: populate netstack variable to use dialer in SOCKS5
Signed-off-by: Naman Sood <mail@nsood.in>
2021-04-14 13:13:10 -04:00
Brad Fitzpatrick
d6a95d807a ipn/ipnlocal: advertise netstack to control server
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-14 09:38:33 -07:00
AdamKorcz
2243bb48c2 stun fuzzer: Small fix
Signed-off-by: AdamKorcz <adam@adalogics.com>
2021-04-14 08:17:46 -07:00
Brad Fitzpatrick
75b99555f3 cmd/tailscale/cli: let ip take a peername
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-14 08:13:16 -07:00
Brad Fitzpatrick
762180595d ipn/ipnstate: add PeerStatus.TailscaleIPs slice, deprecate TailAddr
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-14 08:12:31 -07:00
Brad Fitzpatrick
c2ca2ac8c4 net/dns: fix FreeBSD build
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-13 17:38:17 -07:00
David Anderson
84bd50329a net/dns: fix staticheck.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-13 17:28:37 -07:00
David Anderson
d6bb11b5bf net/dns: implement correct manager detection on linux.
Part of #953.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-13 17:19:00 -07:00
David Anderson
9ef932517b net/dns: fix NM's GetBaseConfig when no configs exist.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-13 17:19:00 -07:00
David Anderson
fe3b1ab747 net/dns: refactor dbus connection setup in resolved manager.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-13 17:19:00 -07:00
Brad Fitzpatrick
2df6372b67 portlist: de-dup services on same (proto, port) on both IPv4/IPv6
Fixes #1703

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-13 14:40:33 -07:00
Brad Fitzpatrick
a8d95a18b2 cmd/tailscale/cli: add up --unattended for Windows
RELNOTE=Windows CLI behavior change: ForceDaemon now off by default

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-13 11:40:31 -07:00
Brad Fitzpatrick
34d2f5a3d9 tailcfg: add Endpoint, EndpointType, MapRequest.EndpointType
Track endpoints internally with a new tailcfg.Endpoint type that
includes a typed netaddr.IPPort (instead of just a string) and
includes a type for how that endpoint was discovered (STUN, local,
etc).

Use []tailcfg.Endpoint instead of []string internally.

At the last second, send it to the control server as the existing
[]string for endpoints, but also include a new parallel
MapRequest.EndpointType []tailcfg.EndpointType, so the control server
can start filtering out less-important endpoint changes from
new-enough clients. Notably, STUN-discovered endpoints can be filtered
out from 1.6+ clients, as they can discover them amongst each other
via CallMeMaybe disco exchanges started over DERP. And STUN endpoints
change a lot, causing a lot of MapResposne updates. But portmapped
endpoints are worth keeping for now, as they they work right away
without requiring the firewall traversal extra RTT dance.

End result will be less control->client bandwidth. (despite negligible
increase in client->control bandwidth)

Updates tailscale/corp#1543

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-13 10:12:14 -07:00
Brad Fitzpatrick
b91f3c4191 ipn/ipnlocal: fix peerapi printf arg mismatch
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-13 08:56:26 -07:00
Brad Fitzpatrick
a08d978476 cmd/tailscale/cli: make push get peerapi base via localapi, not TSMP ping
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-13 08:50:16 -07:00
Brad Fitzpatrick
1dc2cf4835 cmd/tailscale/cli: add push --targets to list possible targets
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-13 08:36:14 -07:00
Brad Fitzpatrick
1f4cf1a4f4 ipn/localapi: only require read access to list file targets
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-13 08:35:16 -07:00
Brad Fitzpatrick
d17f96b586 cmd/tailscale/cli: restore SIGINT/SIGTERM on context cancel
This fixes Ctrl-C not interrupting "tailscale push".

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-13 08:34:42 -07:00
Brad Fitzpatrick
db5e269463 client/tailscale/apitype: move local API types to new apitype package
They were scattered/duplicated in misc places before.

It can't be in the client package itself for circular dep reasons.

This new package is basically tailcfg but for localhost
communications, instead of to control.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-13 08:13:46 -07:00
Maisem Ali
1b9d8771dc ipn/ipnlocal,wgengine/router,cmd/tailscale: add flag to allow local lan access when routing traffic via an exit node.
For #1527

Signed-off-by: Maisem Ali <maisem@tailscale.com>
2021-04-12 17:29:01 -07:00
David Anderson
854d5d36a1 net/dns: return error from NewOSManager, use it to initialize NM.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-12 15:51:37 -07:00
Maisem Ali
4d142ebe06 derp: handle net.ErrClosed in TestSendFreeze
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2021-04-12 15:23:36 -07:00
Brad Fitzpatrick
8e75c8504c ipn/ipnlocal: in direct file mode, don't readdir
And don't even allow attempts at Open/Delete.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 15:12:47 -07:00
Brad Fitzpatrick
9972c02b60 cmd/tailscale/cli: don't let up change prefs based on implicit flag values
This changes the behavior of "tailscale up".

Previously "tailscale up" always did a new Start and reset all the settings.

Now "tailscale up" with no flags just brings the world [back] up.
(The opposite of "tailscale down").

But with flags, "tailscale up" now only is allowed to change
preferences if they're explicitly named in the flags. Otherwise it's
an error. Or you need to use --reset to explicitly nuke everything.

RELNOTE=tailscale up change

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 14:39:47 -07:00
David Anderson
9aa33b43e6 net/dns: support split and unsplit DNS in NetworkManager.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-12 14:34:08 -07:00
Brad Fitzpatrick
f5742b0647 ipn/ipnlocal: add LocalBackend.SetDirectFileRoot
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 14:29:45 -07:00
Brad Fitzpatrick
64c80129f1 types/netmap: add some docs/warning to NetworkMap
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 12:49:25 -07:00
Brad Fitzpatrick
ccb322db04 tailcfg, control/controlclient: make nil MapResponse.DNSConfig mean unchanged (mapver15)
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 12:46:32 -07:00
Brad Fitzpatrick
a3113a793a ipn: add hostname to Prefs.Pretty output
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 10:45:33 -07:00
Brad Fitzpatrick
4c3f7c06fc ipn/ipnlocal: be consistent in not logging when no notify registered
Some paths already didn't. And in the future I hope to shut all the
notify funcs down end-to-end when nothing is connected (as in the
common case in tailscaled).  Then we can save some JSON encoding work.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 10:38:51 -07:00
Brad Fitzpatrick
7c0e58c537 ipn/ipnlocal: remove redundant notify nil check
send does it.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 10:38:51 -07:00
Brad Fitzpatrick
d9ee9a0d3f ipn: set BackendServer's notify earlier; don't require Start
We've been slowly making Start less special and making IPN a
multi-connection "watch" bus of changes, but this Start specialness
had remained.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 10:38:51 -07:00
Brad Fitzpatrick
8e4d1e3f2c ipn: include err in Notify decode fatal path
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 10:38:51 -07:00
Brad Fitzpatrick
d5d70ae9ea wgengine/monitor: reduce Linux log spam on down
Fixes #1689

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 10:38:51 -07:00
Brad Fitzpatrick
c0befee188 portlist: use windows OpenCurrentProcessToken, not GetCurrentProcessToken
The latter only works on Windows 8+.

Also add a TODO to get do this all more efficiently.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 09:23:52 -07:00
Brad Fitzpatrick
e619296ece portlist: filter out all of 127.0.0.0/8, not just 127.0.0.1/32
Per user private bug report.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 09:17:39 -07:00
Brad Fitzpatrick
f325aa7e38 portlist: exclude services bound to IPv6 loopback address
Fixes #1683

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-12 09:07:16 -07:00
David Anderson
87eb8384f5 net/dns: fix up NetworkManager configurator a bit.
Clear LLMNR and mdns flags, update reasoning for our settings,
and set our override priority harder than before when we want
to be primary resolver.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-11 23:19:50 -07:00
Brad Fitzpatrick
303805a389 ipn/localapi: require write access to PATCH prefs
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-11 21:31:20 -07:00
David Anderson
3d81e6260b net/dns: set resolved DefaultRoute setting according to split-dns mode.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-11 20:53:28 -07:00
David Anderson
cca230cc23 net/dns: fix staticcheck errors.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-11 20:53:28 -07:00
Brad Fitzpatrick
79109f4965 ipn/ipnlocal: use PATCH for EditPrefs, not POST
Addendum to earlier 00d641d9fc.

Reserve POST for SetPrefs in the future.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-11 20:49:07 -07:00
Brad Fitzpatrick
4b47393e0c net/dns: pacify staticcheck for now
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-11 20:43:15 -07:00
David Anderson
a7340c2015 net/dns: support split DNS in systemd-resolved.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-11 18:14:23 -07:00
Brad Fitzpatrick
00d641d9fc ipn/localapi: move EditPrefs to localapi
Follow-up/revision to recent 53cfff109b which
added EditPrefs.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-11 16:11:43 -07:00
David Anderson
84430cdfa1 net/dns: improve NetworkManager detection, using more DBus.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-11 15:22:06 -07:00
David Anderson
9a48bac8ad net/dns: rename resolvconf.go to debian_resolvconf.go.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-10 23:31:14 -07:00
David Anderson
9831f1b183 net/dns: also include 'tail' and 'base' files when fixing up resolv.conf.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-10 23:01:11 -07:00
David Anderson
e43afe9140 net/dns: implement prior config reading for debian resolvconf.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-10 22:37:13 -07:00
David Anderson
143e5dd087 net/dns: rename script variable.
Debian resolvconf is not legacy, it's alive and well,
just historically before the other implementations.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-10 21:28:27 -07:00
David Anderson
55b39fa945 net/dns: add documentation to openresolv's config fetch.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-10 20:21:05 -07:00
David Anderson
61b361bac0 net/dns: teach the openresolv manager to read DNS config.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-10 19:37:11 -07:00
David Anderson
19eca34f47 wgengine/router: fix FreeBSD configuration failure on the v6 /48.
On FreeBSD, we add the interface IP as a /48 to work around a kernel
bug, so we mustn't then try to add a /48 route to the Tailscale ULA,
since that will fail as a dupe.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-10 19:36:26 -07:00
David Anderson
58760f7b82 net/dns: split resolvconfManager into a debian and an openresolv manager.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-10 18:55:05 -07:00
David Anderson
5480189313 net/dns: implement a DNS override workaround for legacy resolvconf.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-10 17:58:13 -07:00
David Anderson
1a371b93be util/dnsname: add FQDN type, use throughout codebase.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-10 17:58:13 -07:00
AdamKorcz
7a1813fd24 Added 2 fuzzers
Signed-off-by: AdamKorcz <adam@adalogics.com>
2021-04-10 11:40:10 -07:00
Daniel Chung
5e90037f1a api.md: clarify response behaviour for ACL POST endpoint
Signed-off-by: Daniel Chung <daniel@tailscale.com>
2021-04-10 11:38:12 -07:00
Simon Kirillov
a64b57e2fb control/controlclient: create tls client config with server hostname instead of serverURL.Host
Signed-off-by: Simon Kirillov <svkirillov3@gmail.com>
2021-04-10 11:20:10 -07:00
Brad Fitzpatrick
958782c737 cmd/{tailscale,tailscaled}: use netstack for subnet routing on Synology
Updates #707
Fixes #451
Fixes tailscale/tailscale-synology#52 (just make it work by default)

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-09 18:44:33 -07:00
Brad Fitzpatrick
3b451509dd cmd/tailscale/cli: make advertise-{routes,exit-node} available unconditionally
It was only Linux and BSDs before, but now with netstack mode, it also works on
Windows and darwin. It's not worth limiting it to certain platforms.

Tailscaled itself can complain/fail if it doesn't like the settings
for the mode/OS it's operating under.

Updates #707

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-09 18:37:24 -07:00
Brad Fitzpatrick
83402e2753 cmd/tailscale/cli: show nicer status output when logged out
Also nicer output when running "down".

Fixes #1680

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-09 18:25:22 -07:00
Brad Fitzpatrick
5c5acadb2a portlist: unexport SameInodes method
Signed-off-by: Brad Fitzpatrick <brad@danga.com>
2021-04-09 15:16:36 -07:00
Brad Fitzpatrick
3167e55ddf ipn/{ipnlocal,localapi}, cmd/tailscale: add logout command
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-09 13:26:35 -07:00
Brad Fitzpatrick
11127666b2 ipn/ipnlocal: fix deadlock from 227f73284
Sigh.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-09 13:23:34 -07:00
Brad Fitzpatrick
227f73284f ipn/ipnlocal: eagerly announce peerapi service(s) on change
We were previously only doing it as a side effect of the port poller,
which doesn't run on e.g. iOS.

Updates tailscale/corp#1559
2021-04-09 12:10:52 -07:00
Brad Fitzpatrick
fe23506471 ipn/ipnlocal: avoid unneeded initPeerAPIListener work if no changes
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-09 11:13:46 -07:00
Brad Fitzpatrick
20e7646b8d ipn/ipnlocal: always set Notify.FilesWaiting, set IncomingFiles non-nil when empty 2021-04-09 07:59:36 -07:00
Brad Fitzpatrick
b0af15ff5c portlist: remove some old TODOs
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-09 07:50:00 -07:00
David Anderson
e638a4d86b net/dns: make directManager support split DNS, and work in sandboxes.
Fixes #1495, #683.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-09 02:52:21 -07:00
David Anderson
2685260ba1 net/dns: add temporary fallback to quad-9 resolver for split-DNS testing.
This allows split-DNS configurations to not break clients on OSes that
haven't yet been ported to understand split DNS, by falling back to quad-9
as a global resolver when handed an "impossible to implement"
split-DNS config.

Part of #953. Needs to be removed before shipping 1.8.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-08 23:26:22 -07:00
David Anderson
b9e194c14b net/dns: add missing FQDN qualification.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-08 23:07:16 -07:00
Brad Fitzpatrick
c50c3f0313 tailcfg: document new RegisterRequest.Expiry behavior
Deployed to control server.

For upcoming "logout" command and fixes.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-08 22:27:14 -07:00
David Anderson
b74a8994ca net/dns: make FQDN dot style consistent in more places.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-08 22:03:32 -07:00
Brad Fitzpatrick
6d01d3bece ipn/ipnlocal: provide IPN bus updates as files arrive
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-08 20:09:52 -07:00
David Anderson
2f398106e2 ipn/ipnlocal: allow setting MagicDNS without DefaultResolvers.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-08 16:23:33 -07:00
David Anderson
fad21af01c tailcfg: add DNS routes and advanced resolver config.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-08 15:29:08 -07:00
Brad Fitzpatrick
6a7912e37a cmd/tailscale/cli: add debug mode to push slowly for testing
Also set Content-Length when known, and fail explicitly on sending
directories for now.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-08 15:01:12 -07:00
Brad Fitzpatrick
a9a3d3b4c1 ipn/ipnlocal: don't filter by time in FileTargets
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-08 14:02:07 -07:00
David Anderson
6def647514 net/dns/resolver: don't avoid tailscale routes for DNS forwarding.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-08 12:20:42 -07:00
Brad Fitzpatrick
597c19ff4e control/controlclient: refactor some internals
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-07 21:20:17 -07:00
Brad Fitzpatrick
71432c6449 ipn/ipnlocal: some more variable renames
Missed in earlier commit.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-07 21:17:33 -07:00
Brad Fitzpatrick
e86b7752ef ipn/ipnlocal: rename some variables to be consistent
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-07 21:12:28 -07:00
David Anderson
4a64d2a603 net/dns: some post-review cleanups.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-07 15:40:31 -07:00
David Anderson
720c1ad0f0 net/dns: insert OS base config when emulating split DNS.
Part of #953.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-07 15:40:31 -07:00
David Anderson
e560be6443 net/dns: sort matchDomains to avoid test flake.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-07 15:40:31 -07:00
David Anderson
68f76e9aa1 net/dns: add GetBaseConfig to OSConfigurator interface.
Part of #953, required to make split DNS work on more basic
platforms.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-07 15:40:31 -07:00
David Anderson
fe9cd61d71 net/dns: add tests for DNS config generation.
Part of #953.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-07 15:40:31 -07:00
David Anderson
0ba6d03768 net/dns/resolver: add a test helper to get at the resolver config.
Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-07 15:40:31 -07:00
David Anderson
da4cc8bbb4 net/dns: handle all possible translations of high-level DNS config.
With this change, all OSes can sort-of do split DNS, except that the
default upstream is hardcoded to 8.8.8.8 pending further plumbing.
Additionally, Windows 8-10 can do split DNS fully correctly, without
the 8.8.8.8 hack.

Part of #953.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-07 15:40:31 -07:00
Brad Fitzpatrick
939861773d net/tstun: accept peerapi connections through the filter
Fixes tailscale/corp#1545

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-07 12:29:20 -07:00
Brad Fitzpatrick
950fc28887 ipn, paths, cmd/tailscaled: remove LegacyConfigPath, relaynode migration
It is time.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-07 10:15:45 -07:00
Brad Fitzpatrick
d581ee2536 ipn: remove Options.HTTPTestClient, move to LocalBackend
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-07 09:20:51 -07:00
Brad Fitzpatrick
50b309c1eb ipn/localapi, cmd/tailscale: add API to get prefs, CLI debug command to show
Updates #1436

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-07 08:28:11 -07:00
Brad Fitzpatrick
03be116997 client/tailscale: factor out some helpers to reduce boilerplate
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-07 08:19:36 -07:00
Brad Fitzpatrick
d4b609e138 cmd/tailscale/cli: fix bug in earlier tailscale debug --local-creds addition
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-07 08:01:04 -07:00
Adrian Dewhurst
3f456ba2e7 control/controlclient: return correct certificate
When searching for the matching client identity, the returned
certificate chain was accidentally set to that of the last identity
returned by the certificate store instead of the one corresponding to
the selected identity.

Also, add some extra error checking for invalid certificate chains, just
in case.

Signed-off-by: Adrian Dewhurst <adrian@tailscale.com>
2021-04-07 09:42:13 -04:00
Brad Fitzpatrick
799973a68d ipn: move Options.Notify to its own method
We already had SetNotifyCallback elsewhere on controlclient, so use
that name.

Baby steps towards some CLI refactor work.

Updates tailscale/tailscale#1436
2021-04-06 22:12:40 -07:00
Brad Fitzpatrick
d488678fdc cmd/tailscaled, wgengine{,/netstack}: add netstack hybrid mode, add to Windows
For #707

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-06 21:37:28 -07:00
Brad Fitzpatrick
1f99f889e1 ipn/{ipnlocal,localapi}: add localapi handler to dial/proxy file PUTs
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-06 21:31:50 -07:00
Denton Gentry
3089081349 monitor/polling: reduce Cloud Run polling interval.
Cloud Run's routes never change at runtime. Don't poll it for
route changes very often.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2021-04-06 17:21:16 -07:00
Denton Gentry
224e60cef2 hostifo: update LXC, add Cloud Run.
Recent LXC support no longer has "lxc" in /proc/1/cgroup:
    # cat /proc/1/cgroup
    12:freezer:/
    11:rdma:/
    10:cpuset:/
    9:pids:/
    8:blkio:/
    7:devices:/
    6:perf_event:/
    5:net_cls,net_prio:/
    4:memory:/
    3:hugetlb:/
    2:cpu,cpuacct:/
    1:name=systemd:/init.scope
    0::/init.scope

Look for fuse.lxcfs in /proc.mounts in addition:
    # grep lxc /proc/mounts
    lxcfs /proc/cpuinfo fuse.lxcfs ...
    lxcfs /proc/diskstats fuse.lxcfs ...
    lxcfs /proc/loadavg fuse.lxcfs ...
    lxcfs /proc/meminfo fuse.lxcfs ...
    lxcfs /proc/stat fuse.lxcfs ...
    lxcfs /proc/swaps fuse.lxcfs ...
    lxcfs /proc/uptime fuse.lxcfs ...
    lxcfs /sys/devices/system/cpu/online fuse.lxcfs ...

Add Knative detection by looking for the environment variables
which are part of its container contract.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2021-04-06 17:21:16 -07:00
Maisem Ali
57756ef673 net/nettest: make nettest.NewConn pass x/net/nettest.TestConn.
Signed-off-by: Maisem Ali <maisem@tailscale.com>
2021-04-06 15:34:29 -07:00
David Anderson
e0e677a8f6 net/dns: split out search domains and match domains in OSConfig.
It seems that all the setups that support split DNS understand
this distinction, and it's an important one when translating
high-level configuration.

Part of #953.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-06 15:27:09 -07:00
David Anderson
a8dcda9c9a net/dns: start of compat hacks for Windows 7.
Correctly reports that Win7 cannot do split DNS, and has a helper to
discover the "base" resolvers for the system.

Part of #953

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-06 15:27:09 -07:00
Brad Fitzpatrick
ea9e68280d cmd/tailscale/cli: add debug command to print localapi curl command 2021-04-06 14:05:49 -07:00
Brad Fitzpatrick
d717499ac4 ipn/localapi: add API for getting file targets
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2021-04-06 11:01:30 -07:00
David Anderson
3e915ac783 net/dns: implement OS-level split DNS for Windows.
Part of #953.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-05 22:53:10 -07:00
David Anderson
c16a926bf2 net/dns: set OSConfig.Primary.
OS implementations are going to support split DNS soon.
Until they're all in place, hardcode Primary=true to get
the old behavior.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-05 22:53:10 -07:00
David Anderson
bc4381447f net/tstun: return the real interface name at device creation.
This is usually the same as the requested interface, but on some
unixes can vary based on device number allocation, and on Windows
it's the GUID instead of the pretty name, since everything relating
to configuration wants the GUID.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-05 22:53:10 -07:00
David Crawshaw
d2f838c058 ipn/localapi: 404 on bad endpoints
Confused us for a while!

Signed-off-by: David Crawshaw <crawshaw@tailscale.com>
2021-04-05 15:09:54 -07:00
David Anderson
de6dc4c510 net/dns: add a Primary field to OSConfig.
Currently ignored.

Signed-off-by: David Anderson <danderson@tailscale.com>
2021-04-05 13:05:47 -07:00
121 changed files with 7983 additions and 2236 deletions

48
.github/workflows/linux-race.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Linux race
on:
push:
branches:
- main
pull_request:
branches:
- '*'
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: 1.16
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v1
- name: Basic build
run: go build ./cmd/...
- name: Run tests with -race flag on linux
run: go test -race ./...
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

52
.github/workflows/windows-race.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Windows race
on:
push:
branches:
- main
pull_request:
branches:
- '*'
jobs:
test:
runs-on: windows-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: 1.16.x
- name: Checkout code
uses: actions/checkout@v2
- name: Restore Cache
uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Test with -race flag
run: go test -race ./...
- uses: k0kubun/action-slack@v2.0.0
with:
payload: |
{
"attachments": [{
"text": "${{ job.status }}: ${{ github.workflow }} <https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks|${{ env.COMMIT_DATE }} #${{ env.COMMIT_NUMBER_OF_DAY }}> " +
"(<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|" + "${{ github.sha }}".substring(0, 10) + ">) " +
"of ${{ github.repository }}@" + "${{ github.ref }}".split('/').reverse()[0] + " by ${{ github.event.head_commit.committer.name }}",
"color": "danger"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
if: failure() && github.event_name == 'push'

39
api.md
View File

@@ -367,10 +367,11 @@ Etag: "e0b2816b418b3f266309d94426ac7668ab3c1fa87798785bf82f1085cc2f6d9c"
#### `POST /api/v2/tailnet/:tailnet/acl` - set ACL for a tailnet
Sets the ACL for the given tailnet. HuJSON and JSON are both accepted inputs. An `If-Match` header can be set to avoid missed updates.
Sets the ACL for the given domain.
HuJSON and JSON are both accepted inputs.
An `If-Match` header can be set to avoid missed updates.
Returns error for invalid ACLs.
Returns error if using an `If-Match` header and the ETag does not match.
Returns the updated ACL in JSON or HuJSON according to the `Accept` header on success. Otherwise, errors are returned for incorrectly defined ACLs, ACLs with failing tests on attempted updates, and mismatched `If-Match` header and ETag.
##### Parameters
@@ -380,7 +381,17 @@ Returns error if using an `If-Match` header and the ETag does not match.
`Accept` - Sets the return type of the updated ACL. Response is parsed `JSON` if `application/json` is explicitly named, otherwise HuJSON will be returned.
###### POST Body
ACL JSON or HuJSON (see https://tailscale.com/kb/1018/acls)
The POST body should be a JSON or [HuJSON](https://github.com/tailscale/hujson#hujson---human-json) formatted JSON object.
An ACL policy may contain the following top-level properties:
* `Groups` - Static groups of users which can be used for ACL rules.
* `Hosts` - Hostname aliases to use in place of IP addresses or subnets.
* `ACLs` - Access control lists.
* `TagOwners` - Defines who is allowed to use which tags.
* `Tests` - Run on ACL updates to check correct functionality of defined ACLs.
See https://tailscale.com/kb/1018/acls for more information on those properties.
##### Example
```
@@ -411,7 +422,7 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl' \
}'
```
Response
Response:
```
// Example/default ACLs for unrestricted connections.
{
@@ -436,9 +447,25 @@ Response
}
```
Failed test error response:
```
{
"message": "test(s) failed",
"data": [
{
"user": "user1@example.com",
"errors": [
"address \"user2@example.com:400\": want: Accept, got: Drop"
]
}
]
}
```
<a name=tailnet-acl-preview-post></a>
#### `POST /api/v2/tailnet/:tailnet/acl/preview` - preview rule matches on an ACL for a resource
Determines what rules match for a user on an ACL without saving the ACL to the server.
##### Parameters
@@ -477,7 +504,7 @@ curl 'https://api.tailscale.com/api/v2/tailnet/example.com/acl?user=user1@exampl
}'
```
Response
Response:
```
{"matches":[{"users":["*"],"ports":["*:*"],"lineNumber":19}],"user":"user1@example.com"}
```

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package apitype contains types for the Tailscale local API.
package apitype
import "tailscale.com/tailcfg"
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
type WhoIsResponse struct {
Node *tailcfg.Node
UserProfile *tailcfg.UserProfile
}
// FileTarget is a node to which files can be sent, and the PeerAPI
// URL base to do so via.
type FileTarget struct {
Node *tailcfg.Node
// PeerAPI is the http://ip:port URL base of the node's peer API,
// without any path (not even a single slash).
PeerAPIURL string
}
type WaitingFile struct {
Name string
Size int64
}

View File

@@ -6,6 +6,7 @@
package tailscale
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -18,10 +19,11 @@ import (
"strconv"
"strings"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/paths"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
)
// TailscaledSocket is the tailscaled Unix socket.
@@ -64,9 +66,22 @@ func DoLocalRequest(req *http.Request) (*http.Response, error) {
return tsClient.Do(req)
}
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
func WhoIs(ctx context.Context, remoteAddr string) (*tailcfg.WhoIsResponse, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr), nil)
type errorJSON struct {
Error string
}
// bestError returns either err, or if body contains a valid JSON
// object of type errorJSON, its non-empty error body.
func bestError(err error, body []byte) error {
var j errorJSON
if err := json.Unmarshal(body, &j); err == nil && j.Error != "" {
return errors.New(j.Error)
}
return err
}
func send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, "http://local-tailscaled.sock"+path, body)
if err != nil {
return nil, err
}
@@ -75,60 +90,48 @@ func WhoIs(ctx context.Context, remoteAddr string) (*tailcfg.WhoIsResponse, erro
return nil, err
}
defer res.Body.Close()
slurp, _ := ioutil.ReadAll(res.Body)
if res.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %s: %s", res.Status, slurp)
slurp, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
r := new(tailcfg.WhoIsResponse)
if err := json.Unmarshal(slurp, r); err != nil {
if max := 200; len(slurp) > max {
slurp = slurp[:max]
if res.StatusCode != wantStatus {
err := fmt.Errorf("HTTP %s: %s (expected %v)", res.Status, slurp, wantStatus)
return nil, bestError(err, slurp)
}
return slurp, nil
}
func get200(ctx context.Context, path string) ([]byte, error) {
return send(ctx, "GET", path, 200, nil)
}
// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port.
func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) {
body, err := get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr))
if err != nil {
return nil, err
}
r := new(apitype.WhoIsResponse)
if err := json.Unmarshal(body, r); err != nil {
if max := 200; len(body) > max {
body = append(body[:max], "..."...)
}
return nil, fmt.Errorf("failed to parse JSON WhoIsResponse from %q", slurp)
return nil, fmt.Errorf("failed to parse JSON WhoIsResponse from %q", body)
}
return r, nil
}
// Goroutines returns a dump of the Tailscale daemon's current goroutines.
func Goroutines(ctx context.Context) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/goroutines", nil)
if err != nil {
return nil, err
}
res, err := DoLocalRequest(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %s: %s", res.Status, body)
}
return body, nil
return get200(ctx, "/localapi/v0/goroutines")
}
// BugReport logs and returns a log marker that can be shared by the user with support.
func BugReport(ctx context.Context, note string) (string, error) {
u := fmt.Sprintf("http://local-tailscaled.sock/localapi/v0/bugreport?note=%s", url.QueryEscape(note))
req, err := http.NewRequestWithContext(ctx, "POST", u, nil)
body, err := send(ctx, "POST", "/localapi/v0/bugreport?note="+url.QueryEscape(note), 200, nil)
if err != nil {
return "", err
}
res, err := DoLocalRequest(req)
if err != nil {
return "", err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", err
}
if res.StatusCode != 200 {
return "", fmt.Errorf("HTTP %s: %s", res.Status, body)
}
return strings.TrimSpace(string(body)), nil
}
@@ -143,67 +146,32 @@ func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) {
}
func status(ctx context.Context, queryString string) (*ipnstate.Status, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/status"+queryString, nil)
body, err := get200(ctx, "/localapi/v0/status"+queryString)
if err != nil {
return nil, err
}
res, err := DoLocalRequest(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
body, _ := ioutil.ReadAll(res.Body)
return nil, fmt.Errorf("HTTP %s: %s", res.Status, body)
}
st := new(ipnstate.Status)
if err := json.NewDecoder(res.Body).Decode(st); err != nil {
if err := json.Unmarshal(body, st); err != nil {
return nil, err
}
return st, nil
}
type WaitingFile struct {
Name string
Size int64
}
func WaitingFiles(ctx context.Context) ([]WaitingFile, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/files/", nil)
func WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) {
body, err := get200(ctx, "/localapi/v0/files/")
if err != nil {
return nil, err
}
res, err := DoLocalRequest(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
body, _ := ioutil.ReadAll(res.Body)
return nil, fmt.Errorf("HTTP %s: %s", res.Status, body)
}
var wfs []WaitingFile
if err := json.NewDecoder(res.Body).Decode(&wfs); err != nil {
var wfs []apitype.WaitingFile
if err := json.Unmarshal(body, &wfs); err != nil {
return nil, err
}
return wfs, nil
}
func DeleteWaitingFile(ctx context.Context, baseName string) error {
req, err := http.NewRequestWithContext(ctx, "DELETE", "http://local-tailscaled.sock/localapi/v0/files/"+url.PathEscape(baseName), nil)
if err != nil {
return err
}
res, err := DoLocalRequest(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
body, _ := ioutil.ReadAll(res.Body)
return fmt.Errorf("expected 204 No Content; got HTTP %s: %s", res.Status, body)
}
return nil
_, err := send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil)
return err
}
func GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) {
@@ -227,25 +195,27 @@ func GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, siz
return res.Body, res.ContentLength, nil
}
func FileTargets(ctx context.Context) ([]apitype.FileTarget, error) {
body, err := get200(ctx, "/localapi/v0/file-targets")
if err != nil {
return nil, err
}
var fts []apitype.FileTarget
if err := json.Unmarshal(body, &fts); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
return fts, nil
}
func CheckIPForwarding(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/check-ip-forwarding", nil)
body, err := get200(ctx, "/localapi/v0/check-ip-forwarding")
if err != nil {
return err
}
res, err := DoLocalRequest(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != 200 {
body, _ := ioutil.ReadAll(res.Body)
res.Body.Close()
return fmt.Errorf("HTTP %s: %s", res.Status, body)
}
var jres struct {
Warning string
}
if err := json.NewDecoder(res.Body).Decode(&jres); err != nil {
if err := json.Unmarshal(body, &jres); err != nil {
return fmt.Errorf("invalid JSON from check-ip-forwarding: %w", err)
}
if jres.Warning != "" {
@@ -253,3 +223,36 @@ func CheckIPForwarding(ctx context.Context) error {
}
return nil
}
func GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
body, err := get200(ctx, "/localapi/v0/prefs")
if err != nil {
return nil, err
}
var p ipn.Prefs
if err := json.Unmarshal(body, &p); err != nil {
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
}
return &p, nil
}
func EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
mpj, err := json.Marshal(mp)
if err != nil {
return nil, err
}
body, err := send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, bytes.NewReader(mpj))
if err != nil {
return nil, err
}
var p ipn.Prefs
if err := json.Unmarshal(body, &p); err != nil {
return nil, fmt.Errorf("invalid prefs JSON: %w", err)
}
return &p, nil
}
func Logout(ctx context.Context) error {
_, err := send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil)
return err
}

View File

@@ -18,7 +18,7 @@ import (
"strings"
"tailscale.com/client/tailscale"
"tailscale.com/tailcfg"
"tailscale.com/client/tailscale/apitype"
)
var (
@@ -107,7 +107,7 @@ type tmplData struct {
IP string // "100.2.3.4"
}
func tailscaleIP(who *tailcfg.WhoIsResponse) string {
func tailscaleIP(who *apitype.WhoIsResponse) string {
if who == nil {
return ""
}

View File

@@ -15,6 +15,7 @@ import (
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"syscall"
"text/tabwriter"
@@ -24,21 +25,44 @@ import (
"tailscale.com/ipn"
"tailscale.com/paths"
"tailscale.com/safesocket"
"tailscale.com/syncs"
)
// ActLikeCLI reports whether a GUI application should act like the
// CLI based on os.Args, GOOS, the context the process is running in
// (pty, parent PID), etc.
func ActLikeCLI() bool {
if len(os.Args) < 2 {
// This function is only used on macOS.
if runtime.GOOS != "darwin" {
return false
}
switch os.Args[1] {
case "up", "down", "status", "netcheck", "ping", "version",
"debug",
"-V", "--version", "-h", "--help":
// Escape hatch to let people force running the macOS
// GUI Tailscale binary as the CLI.
if v, _ := strconv.ParseBool(os.Getenv("TAILSCALE_BE_CLI")); v {
return true
}
// If our parent is launchd, we're definitely not
// being run as a CLI.
if os.Getppid() == 1 {
return false
}
// Looking at the environment of the GUI Tailscale app (ps eww
// $PID), empirically none of these environment variables are
// present. But all or some of these should be present with
// Terminal.all and bash or zsh.
for _, e := range []string{
"SHLVL",
"TERM",
"TERM_PROGRAM",
"PS1",
} {
if os.Getenv(e) != "" {
return true
}
}
return false
}
@@ -64,6 +88,7 @@ change in the future.
Subcommands: []*ffcli.Command{
upCmd,
downCmd,
logoutCmd,
netcheckCmd,
ipCmd,
statusCmd,
@@ -108,6 +133,8 @@ var rootArgs struct {
socket string
}
var gotSignal syncs.AtomicBool
func connect(ctx context.Context) (net.Conn, *ipn.BackendClient, context.Context, context.CancelFunc) {
c, err := safesocket.Connect(rootArgs.socket, 41112)
if err != nil {
@@ -125,7 +152,14 @@ func connect(ctx context.Context) (net.Conn, *ipn.BackendClient, context.Context
go func() {
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
<-interrupt
select {
case <-interrupt:
case <-ctx.Done():
// Context canceled elsewhere.
signal.Reset(syscall.SIGINT, syscall.SIGTERM)
return
}
gotSignal.Set(true)
c.Close()
cancel()
}()
@@ -143,7 +177,9 @@ func pump(ctx context.Context, bc *ipn.BackendClient, conn net.Conn) {
if ctx.Err() != nil {
return
}
log.Printf("ReadMsg: %v\n", err)
if !gotSignal.Get() {
log.Printf("ReadMsg: %v\n", err)
}
break
}
bc.GotNotifyMsg(msg)

View File

@@ -0,0 +1,336 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package cli
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"strings"
"testing"
"inet.af/netaddr"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/types/logger"
"tailscale.com/types/preftype"
)
// Test that checkForAccidentalSettingReverts's updateMaskedPrefsFromUpFlag can handle
// all flags. This will panic if a new flag creeps in that's unhandled.
func TestUpdateMaskedPrefsFromUpFlag(t *testing.T) {
mp := new(ipn.MaskedPrefs)
upFlagSet.VisitAll(func(f *flag.Flag) {
updateMaskedPrefsFromUpFlag(mp, f.Name)
})
}
func TestCheckForAccidentalSettingReverts(t *testing.T) {
f := func(flags ...string) map[string]bool {
m := make(map[string]bool)
for _, f := range flags {
m[f] = true
}
return m
}
tests := []struct {
name string
flagSet map[string]bool
curPrefs *ipn.Prefs
mp *ipn.MaskedPrefs
want string
}{
{
name: "bare_up_means_up",
flagSet: f(),
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: false,
Hostname: "foo",
},
mp: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
WantRunning: true,
},
WantRunningSet: true,
},
want: "",
},
{
name: "losing_hostname",
flagSet: f("accept-dns"),
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: false,
Hostname: "foo",
CorpDNS: true,
},
mp: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: true,
CorpDNS: true,
},
ControlURLSet: true,
WantRunningSet: true,
CorpDNSSet: true,
},
want: `'tailscale up' without --reset requires all preferences with changing values to be explicitly mentioned; --hostname is not specified but its default value of "" differs from current value "foo"`,
},
{
name: "hostname_changing_explicitly",
flagSet: f("hostname"),
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: false,
Hostname: "foo",
},
mp: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: true,
Hostname: "bar",
},
ControlURLSet: true,
WantRunningSet: true,
HostnameSet: true,
},
want: "",
},
{
name: "hostname_changing_empty_explicitly",
flagSet: f("hostname"),
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: false,
Hostname: "foo",
},
mp: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
WantRunning: true,
Hostname: "",
},
ControlURLSet: true,
WantRunningSet: true,
HostnameSet: true,
},
want: "",
},
{
name: "empty_slice_equals_nil_slice",
flagSet: f("hostname"),
curPrefs: &ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AdvertiseRoutes: []netaddr.IPPrefix{},
},
mp: &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ControlURL: ipn.DefaultControlURL,
AdvertiseRoutes: nil,
},
ControlURLSet: true,
},
want: "",
},
{
// Issue 1725: "tailscale up --authkey=..." (or other non-empty flags) works from
// a fresh server's initial prefs.
name: "up_with_default_prefs",
flagSet: f("authkey"),
curPrefs: ipn.NewPrefs(),
mp: &ipn.MaskedPrefs{
Prefs: *defaultPrefsFromUpArgs(t),
WantRunningSet: true,
},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
if err := checkForAccidentalSettingReverts(tt.flagSet, tt.curPrefs, tt.mp); err != nil {
got = err.Error()
}
if got != tt.want {
t.Errorf("unexpected result\n got: %s\nwant: %s\n", got, tt.want)
}
})
}
}
func defaultPrefsFromUpArgs(t testing.TB) *ipn.Prefs {
upFlagSet.Parse(nil) // populates upArgs
if upFlagSet.Lookup("netfilter-mode") == nil && upArgs.netfilterMode == "" {
// This flag is not compiled on on-Linux platforms,
// but prefsFromUpArgs requires it be populated.
upArgs.netfilterMode = defaultNetfilterMode()
}
prefs, err := prefsFromUpArgs(upArgs, logger.Discard, new(ipnstate.Status), "linux")
if err != nil {
t.Fatalf("defaultPrefsFromUpArgs: %v", err)
}
prefs.WantRunning = true
return prefs
}
func TestPrefsFromUpArgs(t *testing.T) {
tests := []struct {
name string
args upArgsT
goos string // runtime.GOOS; empty means linux
st *ipnstate.Status // or nil
want *ipn.Prefs
wantErr string
wantWarn string
}{
{
name: "zero",
goos: "windows",
args: upArgsT{},
want: &ipn.Prefs{
WantRunning: true,
NoSNAT: true,
NetfilterMode: preftype.NetfilterOn, // silly, but default from ipn.NewPref currently
},
},
{
name: "error_advertise_route_invalid_ip",
args: upArgsT{
advertiseRoutes: "foo",
},
wantErr: `"foo" is not a valid IP address or CIDR prefix`,
},
{
name: "error_advertise_route_unmasked_bits",
args: upArgsT{
advertiseRoutes: "1.2.3.4/16",
},
wantErr: `1.2.3.4/16 has non-address bits set; expected 1.2.0.0/16`,
},
{
name: "error_exit_node_bad_ip",
args: upArgsT{
exitNodeIP: "foo",
},
wantErr: `invalid IP address "foo" for --exit-node: unable to parse IP`,
},
{
name: "error_exit_node_allow_lan_without_exit_node",
args: upArgsT{
exitNodeAllowLANAccess: true,
},
wantErr: `--exit-node-allow-lan-access can only be used with --exit-node`,
},
{
name: "error_tag_prefix",
args: upArgsT{
advertiseTags: "foo",
},
wantErr: `tag: "foo": tags must start with 'tag:'`,
},
{
name: "error_long_hostname",
args: upArgsT{
hostname: strings.Repeat("a", 300),
},
wantErr: `hostname too long: 300 bytes (max 256)`,
},
{
name: "error_linux_netfilter_empty",
args: upArgsT{
netfilterMode: "",
},
wantErr: `invalid value --netfilter-mode=""`,
},
{
name: "error_linux_netfilter_bogus",
args: upArgsT{
netfilterMode: "bogus",
},
wantErr: `invalid value --netfilter-mode="bogus"`,
},
{
name: "error_exit_node_ip_is_self_ip",
args: upArgsT{
exitNodeIP: "100.105.106.107",
},
st: &ipnstate.Status{
TailscaleIPs: []netaddr.IP{netaddr.MustParseIP("100.105.106.107")},
},
wantErr: `cannot use 100.105.106.107 as the exit node as it is a local IP address to this machine, did you mean --advertise-exit-node?`,
},
{
name: "warn_linux_netfilter_nodivert",
goos: "linux",
args: upArgsT{
netfilterMode: "nodivert",
},
wantWarn: "netfilter=nodivert; add iptables calls to ts-* chains manually.",
want: &ipn.Prefs{
WantRunning: true,
NetfilterMode: preftype.NetfilterNoDivert,
NoSNAT: true,
},
},
{
name: "warn_linux_netfilter_off",
goos: "linux",
args: upArgsT{
netfilterMode: "off",
},
wantWarn: "netfilter=off; configure iptables yourself.",
want: &ipn.Prefs{
WantRunning: true,
NetfilterMode: preftype.NetfilterOff,
NoSNAT: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var warnBuf bytes.Buffer
warnf := func(format string, a ...interface{}) {
fmt.Fprintf(&warnBuf, format, a...)
}
goos := tt.goos
if goos == "" {
goos = "linux"
}
st := tt.st
if st == nil {
st = new(ipnstate.Status)
}
got, err := prefsFromUpArgs(tt.args, warnf, st, goos)
gotErr := fmt.Sprint(err)
if tt.wantErr != "" {
if tt.wantErr != gotErr {
t.Errorf("wrong error.\n got error: %v\nwant error: %v\n", gotErr, tt.wantErr)
}
return
}
if err != nil {
t.Fatal(err)
}
if tt.want == nil {
t.Fatal("tt.want is nil")
}
if !got.Equals(tt.want) {
jgot, _ := json.MarshalIndent(got, "", "\t")
jwant, _ := json.MarshalIndent(tt.want, "", "\t")
if bytes.Equal(jgot, jwant) {
t.Logf("prefs differ only in non-JSON-visible ways (nil/non-nil zero-length arrays)")
}
t.Errorf("wrong prefs\n got: %s\nwant: %s\n\ngot: %s\nwant: %s\n",
got.Pretty(), tt.want.Pretty(),
jgot, jwant,
)
}
})
}
}

View File

@@ -13,11 +13,14 @@ import (
"io"
"log"
"os"
"runtime"
"strings"
"github.com/peterbourgon/ff/v2/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/paths"
"tailscale.com/safesocket"
)
var debugCmd = &ffcli.Command{
@@ -27,23 +30,55 @@ var debugCmd = &ffcli.Command{
fs := flag.NewFlagSet("debug", flag.ExitOnError)
fs.BoolVar(&debugArgs.goroutines, "daemon-goroutines", false, "If true, dump the tailscaled daemon's goroutines")
fs.BoolVar(&debugArgs.ipn, "ipn", false, "If true, subscribe to IPN notifications")
fs.BoolVar(&debugArgs.prefs, "prefs", false, "If true, dump active prefs")
fs.BoolVar(&debugArgs.pretty, "pretty", false, "If true, pretty-print output (for --prefs)")
fs.BoolVar(&debugArgs.netMap, "netmap", true, "whether to include netmap in --ipn mode")
fs.BoolVar(&debugArgs.localCreds, "local-creds", false, "print how to connect to local tailscaled")
fs.StringVar(&debugArgs.file, "file", "", "get, delete:NAME, or NAME")
return fs
})(),
}
var debugArgs struct {
localCreds bool
goroutines bool
ipn bool
netMap bool
file string
prefs bool
pretty bool
}
func runDebug(ctx context.Context, args []string) error {
if len(args) > 0 {
return errors.New("unknown arguments")
}
if debugArgs.localCreds {
port, token, err := safesocket.LocalTCPPortAndToken()
if err == nil {
fmt.Printf("curl -u:%s http://localhost:%d/localapi/v0/status\n", token, port)
return nil
}
if runtime.GOOS == "windows" {
fmt.Printf("curl http://localhost:41112/localapi/v0/status\n")
return nil
}
fmt.Printf("curl --unix-socket %s http://foo/localapi/v0/status\n", paths.DefaultTailscaledSocket())
return nil
}
if debugArgs.prefs {
prefs, err := tailscale.GetPrefs(ctx)
if err != nil {
return err
}
if debugArgs.pretty {
fmt.Println(prefs.Pretty())
} else {
j, _ := json.MarshalIndent(prefs, "", "\t")
fmt.Println(string(j))
}
return nil
}
if debugArgs.goroutines {
goroutines, err := tailscale.Goroutines(ctx)
if err != nil {

View File

@@ -8,7 +8,7 @@ import (
"context"
"fmt"
"log"
"time"
"os"
"github.com/peterbourgon/ff/v2/ffcli"
"tailscale.com/client/tailscale"
@@ -33,39 +33,14 @@ func runDown(ctx context.Context, args []string) error {
return fmt.Errorf("error fetching current status: %w", err)
}
if st.BackendState == "Stopped" {
log.Printf("already stopped")
fmt.Fprintf(os.Stderr, "Tailscale was already stopped.\n")
return nil
}
log.Printf("was in state %q", st.BackendState)
c, bc, ctx, cancel := connect(ctx)
defer cancel()
timer := time.AfterFunc(5*time.Second, func() {
log.Fatalf("timeout running stop")
})
defer timer.Stop()
bc.SetNotifyCallback(func(n ipn.Notify) {
if n.ErrMessage != nil {
log.Fatal(*n.ErrMessage)
}
if n.State != nil {
log.Printf("now in state %q", *n.State)
if *n.State == ipn.Stopped {
cancel()
}
return
}
})
bc.EditPrefs(&ipn.MaskedPrefs{
_, err = tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
WantRunning: false,
},
WantRunningSet: true,
})
pump(ctx, bc, c)
return nil
return err
}

View File

@@ -11,13 +11,16 @@ import (
"fmt"
"github.com/peterbourgon/ff/v2/ffcli"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/ipn/ipnstate"
)
var ipCmd = &ffcli.Command{
Name: "ip",
ShortUsage: "ip [-4] [-6]",
ShortHelp: "Show this machine's current Tailscale IP address(es)",
ShortUsage: "ip [-4] [-6] [peername]",
ShortHelp: "Show current Tailscale IP address(es)",
LongHelp: "Shows the Tailscale IP address of the current machine without an argument. With an argument, it shows the IP of a named peer.",
Exec: runIP,
FlagSet: (func() *flag.FlagSet {
fs := flag.NewFlagSet("ip", flag.ExitOnError)
@@ -33,9 +36,14 @@ var ipArgs struct {
}
func runIP(ctx context.Context, args []string) error {
if len(args) > 0 {
if len(args) > 1 {
return errors.New("unknown arguments")
}
var of string
if len(args) == 1 {
of = args[0]
}
v4, v6 := ipArgs.want4, ipArgs.want6
if v4 && v6 {
return errors.New("tailscale up -4 and -6 are mutually exclusive")
@@ -47,11 +55,24 @@ func runIP(ctx context.Context, args []string) error {
if err != nil {
return err
}
if len(st.TailscaleIPs) == 0 {
ips := st.TailscaleIPs
if of != "" {
ip, err := tailscaleIPFromArg(ctx, of)
if err != nil {
return err
}
peer, ok := peerMatchingIP(st, ip)
if !ok {
return fmt.Errorf("no peer found with IP %v", ip)
}
ips = peer.TailscaleIPs
}
if len(ips) == 0 {
return fmt.Errorf("no current Tailscale IPs; state: %v", st.BackendState)
}
match := false
for _, ip := range st.TailscaleIPs {
for _, ip := range ips {
if ip.Is4() && v4 || ip.Is6() && v6 {
match = true
fmt.Println(ip)
@@ -67,3 +88,18 @@ func runIP(ctx context.Context, args []string) error {
}
return nil
}
func peerMatchingIP(st *ipnstate.Status, ipStr string) (ps *ipnstate.PeerStatus, ok bool) {
ip, err := netaddr.ParseIP(ipStr)
if err != nil {
return
}
for _, ps = range st.Peer {
for _, pip := range ps.TailscaleIPs {
if ip == pip {
return ps, true
}
}
}
return nil, false
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package cli
import (
"context"
"log"
"strings"
"github.com/peterbourgon/ff/v2/ffcli"
"tailscale.com/client/tailscale"
)
var logoutCmd = &ffcli.Command{
Name: "logout",
ShortUsage: "logout [flags]",
ShortHelp: "Disconnect from Tailscale and expire current node key",
LongHelp: strings.TrimSpace(`
"tailscale logout" brings the network down and invalidates
the current node key, forcing a future use of it to cause
a reauthentication.
`),
Exec: runLogout,
}
func runLogout(ctx context.Context, args []string) error {
if len(args) > 0 {
log.Fatalf("too many non-flag arguments: %q", args)
}
return tailscale.Logout(ctx)
}

View File

@@ -154,7 +154,10 @@ func tailscaleIPFromArg(ctx context.Context, hostOrIP string) (ip string, err er
}
for _, ps := range st.Peer {
if hostOrIP == dnsOrQuoteHostname(st, ps) || hostOrIP == ps.DNSName {
return ps.TailAddr, nil
if len(ps.TailscaleIPs) == 0 {
return "", errors.New("node found but lacks an IP")
}
return ps.TailscaleIPs[0].String(), nil
}
}

View File

@@ -13,16 +13,17 @@ import (
"io"
"log"
"mime"
"net"
"net/http"
"net/url"
"os"
"strconv"
"time"
"unicode/utf8"
"github.com/peterbourgon/ff/v2/ffcli"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"golang.org/x/time/rate"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
)
var pushCmd = &ffcli.Command{
@@ -34,6 +35,7 @@ var pushCmd = &ffcli.Command{
fs := flag.NewFlagSet("push", flag.ExitOnError)
fs.StringVar(&pushArgs.name, "name", "", "alternate filename to use, especially useful when <file> is \"-\" (stdin)")
fs.BoolVar(&pushArgs.verbose, "verbose", false, "verbose output")
fs.BoolVar(&pushArgs.targets, "targets", false, "list possible push targets")
return fs
})(),
}
@@ -41,11 +43,15 @@ var pushCmd = &ffcli.Command{
var pushArgs struct {
name string
verbose bool
targets bool
}
func runPush(ctx context.Context, args []string) error {
if pushArgs.targets {
return runPushTargets(ctx, args)
}
if len(args) != 2 || args[0] == "" {
return errors.New("usage: push <hostname-or-IP> <file>")
return errors.New("usage: push <hostname-or-IP> <file>\n push --targets")
}
var ip string
@@ -55,13 +61,18 @@ func runPush(ctx context.Context, args []string) error {
return err
}
peerAPIPort, err := discoverPeerAPIPort(ctx, ip)
peerAPIBase, lastSeen, err := discoverPeerAPIBase(ctx, ip)
if err != nil {
return err
}
if !lastSeen.IsZero() && time.Since(lastSeen) > lastSeenOld {
fmt.Fprintf(os.Stderr, "# warning: %s last seen %v ago\n", hostOrIP, time.Since(lastSeen).Round(time.Minute))
}
var fileContents io.Reader
var name = pushArgs.name
var contentLength int64 = -1
if fileArg == "-" {
fileContents = os.Stdin
if name == "" {
@@ -76,17 +87,30 @@ func runPush(ctx context.Context, args []string) error {
return err
}
defer f.Close()
fileContents = f
fi, err := f.Stat()
if err != nil {
return err
}
if fi.IsDir() {
return errors.New("directories not supported")
}
contentLength = fi.Size()
fileContents = io.LimitReader(f, contentLength)
if name == "" {
name = fileArg
}
if slow, _ := strconv.ParseBool(os.Getenv("TS_DEBUG_SLOW_PUSH")); slow {
fileContents = &slowReader{r: fileContents}
}
}
dstURL := "http://" + net.JoinHostPort(ip, fmt.Sprint(peerAPIPort)) + "/v0/put/" + url.PathEscape(name)
dstURL := peerAPIBase + "/v0/put/" + url.PathEscape(name)
req, err := http.NewRequestWithContext(ctx, "PUT", dstURL, fileContents)
if err != nil {
return err
}
req.ContentLength = contentLength
if pushArgs.verbose {
log.Printf("sending to %v ...", dstURL)
}
@@ -102,51 +126,28 @@ func runPush(ctx context.Context, args []string) error {
return errors.New(res.Status)
}
func discoverPeerAPIPort(ctx context.Context, ip string) (port uint16, err error) {
c, bc, ctx, cancel := connect(ctx)
defer cancel()
prc := make(chan *ipnstate.PingResult, 2)
bc.SetNotifyCallback(func(n ipn.Notify) {
if n.ErrMessage != nil {
log.Fatal(*n.ErrMessage)
}
if pr := n.PingResult; pr != nil && pr.IP == ip {
prc <- pr
}
})
go pump(ctx, bc, c)
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
discoPings := 0
timer := time.NewTimer(10 * time.Second)
defer timer.Stop()
sendPings := func() {
bc.Ping(ip, false)
bc.Ping(ip, true)
func discoverPeerAPIBase(ctx context.Context, ipStr string) (base string, lastSeen time.Time, err error) {
ip, err := netaddr.ParseIP(ipStr)
if err != nil {
return "", time.Time{}, err
}
sendPings()
for {
select {
case <-ticker.C:
sendPings()
case <-timer.C:
return 0, fmt.Errorf("timeout contacting %v; it offline?", ip)
case pr := <-prc:
if p := pr.PeerAPIPort; p != 0 {
return p, nil
fts, err := tailscale.FileTargets(ctx)
if err != nil {
return "", time.Time{}, err
}
for _, ft := range fts {
n := ft.Node
for _, a := range n.Addresses {
if a.IP != ip {
continue
}
discoPings++
if discoPings == 3 {
return 0, fmt.Errorf("%v is online, but seems to be running an old Tailscale version", ip)
if n.LastSeen != nil {
lastSeen = *n.LastSeen
}
case <-ctx.Done():
return 0, ctx.Err()
return ft.PeerAPIURL, lastSeen, nil
}
}
return "", time.Time{}, errors.New("target seems to be running an old Tailscale version")
}
const maxSniff = 4 << 20
@@ -171,3 +172,47 @@ func pickStdinFilename() (name string, r io.Reader, err error) {
}
return "stdin" + ext(sniff), io.MultiReader(bytes.NewReader(sniff), os.Stdin), nil
}
type slowReader struct {
r io.Reader
rl *rate.Limiter
}
func (r *slowReader) Read(p []byte) (n int, err error) {
const burst = 4 << 10
plen := len(p)
if plen > burst {
plen = burst
}
if r.rl == nil {
r.rl = rate.NewLimiter(rate.Limit(1<<10), burst)
}
n, err = r.r.Read(p[:plen])
r.rl.WaitN(context.Background(), n)
return
}
const lastSeenOld = 20 * time.Minute
func runPushTargets(ctx context.Context, args []string) error {
if len(args) > 0 {
return errors.New("invalid arguments with --targets")
}
fts, err := tailscale.FileTargets(ctx)
if err != nil {
return err
}
for _, ft := range fts {
n := ft.Node
var ago string
if n.LastSeen == nil {
ago = "\tnode never seen"
} else {
if d := time.Since(*n.LastSeen); d > lastSeenOld {
ago = fmt.Sprintf("\tlast seen %v ago", d.Round(time.Minute))
}
}
fmt.Printf("%s\t%s%s\n", n.Addresses[0].IP, n.ComputedName, ago)
}
return nil
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/peterbourgon/ff/v2/ffcli"
"github.com/toqueteos/webbrowser"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
@@ -106,9 +107,24 @@ func runStatus(ctx context.Context, args []string) error {
return err
}
if st.BackendState == ipn.Stopped.String() {
switch st.BackendState {
default:
fmt.Fprintf(os.Stderr, "unexpected state: %s\n", st.BackendState)
os.Exit(1)
case ipn.Stopped.String():
fmt.Println("Tailscale is stopped.")
os.Exit(1)
case ipn.NeedsLogin.String():
fmt.Println("Logged out.")
if st.AuthURL != "" {
fmt.Printf("\nLog in at: %s\n", st.AuthURL)
}
os.Exit(1)
case ipn.NeedsMachineAuth.String():
fmt.Println("Machine is not yet authorized by tailnet admin.")
os.Exit(1)
case ipn.Running.String():
// Run below.
}
var buf bytes.Buffer
@@ -116,7 +132,7 @@ func runStatus(ctx context.Context, args []string) error {
printPS := func(ps *ipnstate.PeerStatus) {
active := peerActive(ps)
f("%-15s %-20s %-12s %-7s ",
ps.TailAddr,
firstIPString(ps.TailscaleIPs),
dnsOrQuoteHostname(st, ps),
ownerLogin(st, ps),
ps.OS,
@@ -201,3 +217,10 @@ func ownerLogin(st *ipnstate.Status, ps *ipnstate.PeerStatus) string {
}
return u.LoginName
}
func firstIPString(v []netaddr.IP) string {
if len(v) == 0 {
return ""
}
return v[0].String()
}

View File

@@ -9,18 +9,23 @@ import (
"errors"
"flag"
"fmt"
"log"
"os"
"reflect"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"github.com/go-multierror/multierror"
"github.com/peterbourgon/ff/v2/ffcli"
"inet.af/netaddr"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/preftype"
"tailscale.com/version/distro"
)
@@ -28,40 +33,56 @@ import (
var upCmd = &ffcli.Command{
Name: "up",
ShortUsage: "up [flags]",
ShortHelp: "Connect to your Tailscale network",
ShortHelp: "Connect to Tailscale, logging in if needed",
LongHelp: strings.TrimSpace(`
"tailscale up" connects this machine to your Tailscale network,
triggering authentication if necessary.
The flags passed to this command are specific to this machine. If you don't
specify any flags, options are reset to their default.
With no flags, "tailscale up" brings the network online without
changing any settings. (That is, it's the opposite of "tailscale
down").
If flags are specified, the flags must be the complete set of desired
settings. An error is returned if any setting would be changed as a
result of an unspecified flag's default value, unless the --reset
flag is also used.
`),
FlagSet: (func() *flag.FlagSet {
upf := flag.NewFlagSet("up", flag.ExitOnError)
upf.StringVar(&upArgs.server, "login-server", "https://login.tailscale.com", "base URL of control server")
upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", false, "accept routes advertised by other Tailscale nodes")
upf.BoolVar(&upArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel")
upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, "install host routes to other Tailscale nodes")
upf.StringVar(&upArgs.exitNodeIP, "exit-node", "", "Tailscale IP of the exit node for internet traffic")
upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
upf.BoolVar(&upArgs.forceReauth, "force-reauth", false, "force reauthentication")
upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "ACL tags to request (comma-separated, e.g. \"tag:eng,tag:montreal,tag:ssh\")")
upf.StringVar(&upArgs.authKey, "authkey", "", "node authorization key")
upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
if runtime.GOOS == "linux" || isBSD(runtime.GOOS) {
upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\")")
upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
}
if runtime.GOOS == "linux" {
upf.BoolVar(&upArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes")
upf.StringVar(&upArgs.netfilterMode, "netfilter-mode", defaultNetfilterMode(), "netfilter mode (one of on, nodivert, off)")
}
return upf
})(),
Exec: runUp,
FlagSet: upFlagSet,
Exec: runUp,
}
var upFlagSet = (func() *flag.FlagSet {
upf := flag.NewFlagSet("up", flag.ExitOnError)
upf.BoolVar(&upArgs.forceReauth, "force-reauth", false, "force reauthentication")
upf.BoolVar(&upArgs.reset, "reset", false, "reset unspecified settings to their default values")
upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server")
upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", false, "accept routes advertised by other Tailscale nodes")
upf.BoolVar(&upArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel")
upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, "install host routes to other Tailscale nodes")
upf.StringVar(&upArgs.exitNodeIP, "exit-node", "", "Tailscale IP of the exit node for internet traffic")
upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request; each must start with \"tag:\" (e.g. \"tag:eng,tag:montreal,tag:ssh\")")
upf.StringVar(&upArgs.authKey, "authkey", "", "node authorization key")
upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\")")
upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
if safesocket.PlatformUsesPeerCreds() {
upf.StringVar(&upArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
}
if runtime.GOOS == "linux" {
upf.BoolVar(&upArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes")
upf.StringVar(&upArgs.netfilterMode, "netfilter-mode", defaultNetfilterMode(), "netfilter mode (one of on, nodivert, off)")
}
if runtime.GOOS == "windows" {
upf.BoolVar(&upArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)")
}
return upf
})()
func defaultNetfilterMode() string {
if distro.Get() == distro.Synology {
return "off"
@@ -69,26 +90,28 @@ func defaultNetfilterMode() string {
return "on"
}
var upArgs struct {
server string
acceptRoutes bool
acceptDNS bool
singleRoutes bool
exitNodeIP string
shieldsUp bool
forceReauth bool
advertiseRoutes string
advertiseDefaultRoute bool
advertiseTags string
snat bool
netfilterMode string
authKey string
hostname string
type upArgsT struct {
reset bool
server string
acceptRoutes bool
acceptDNS bool
singleRoutes bool
exitNodeIP string
exitNodeAllowLANAccess bool
shieldsUp bool
forceReauth bool
forceDaemon bool
advertiseRoutes string
advertiseDefaultRoute bool
advertiseTags string
snat bool
netfilterMode string
authKey string
hostname string
opUser string
}
func isBSD(s string) bool {
return s == "dragonfly" || s == "freebsd" || s == "netbsd" || s == "openbsd"
}
var upArgs upArgsT
func warnf(format string, args ...interface{}) {
fmt.Printf("Warning: "+format+"\n", args...)
@@ -99,27 +122,13 @@ var (
ipv6default = netaddr.MustParseIPPrefix("::/0")
)
func runUp(ctx context.Context, args []string) error {
if len(args) > 0 {
log.Fatalf("too many non-flag arguments: %q", args)
}
if distro.Get() == distro.Synology {
notSupported := "not yet supported on Synology; see https://github.com/tailscale/tailscale/issues/451"
if upArgs.advertiseRoutes != "" {
return errors.New("--advertise-routes is " + notSupported)
}
if upArgs.acceptRoutes {
return errors.New("--accept-routes is " + notSupported)
}
if upArgs.exitNodeIP != "" {
return errors.New("--exit-node is " + notSupported)
}
if upArgs.netfilterMode != "off" {
return errors.New("--netfilter-mode values besides \"off\" " + notSupported)
}
}
// prefsFromUpArgs returns the ipn.Prefs for the provided args.
//
// Note that the parameters upArgs and warnf are named intentionally
// to shadow the globals to prevent accidental misuse of them. This
// function exists for testing and should have no side effects or
// outside interactions (e.g. no making Tailscale local API calls).
func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goos string) (*ipn.Prefs, error) {
routeMap := map[netaddr.IPPrefix]bool{}
var default4, default6 bool
if upArgs.advertiseRoutes != "" {
@@ -127,10 +136,10 @@ func runUp(ctx context.Context, args []string) error {
for _, s := range advroutes {
ipp, err := netaddr.ParseIPPrefix(s)
if err != nil {
fatalf("%q is not a valid IP address or CIDR prefix", s)
return nil, fmt.Errorf("%q is not a valid IP address or CIDR prefix", s)
}
if ipp != ipp.Masked() {
fatalf("%s has non-address bits set; expected %s", ipp, ipp.Masked())
return nil, fmt.Errorf("%s has non-address bits set; expected %s", ipp, ipp.Masked())
}
if ipp == ipv4default {
default4 = true
@@ -140,20 +149,15 @@ func runUp(ctx context.Context, args []string) error {
routeMap[ipp] = true
}
if default4 && !default6 {
fatalf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv4default, ipv6default)
return nil, fmt.Errorf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv4default, ipv6default)
} else if default6 && !default4 {
fatalf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv6default, ipv4default)
return nil, fmt.Errorf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv6default, ipv4default)
}
}
if upArgs.advertiseDefaultRoute {
routeMap[netaddr.MustParseIPPrefix("0.0.0.0/0")] = true
routeMap[netaddr.MustParseIPPrefix("::/0")] = true
}
if len(routeMap) > 0 {
if err := tailscale.CheckIPForwarding(context.Background()); err != nil {
warnf("%v", err)
}
}
routes := make([]netaddr.IPPrefix, 0, len(routeMap))
for r := range routeMap {
routes = append(routes, r)
@@ -170,7 +174,17 @@ func runUp(ctx context.Context, args []string) error {
var err error
exitNodeIP, err = netaddr.ParseIP(upArgs.exitNodeIP)
if err != nil {
fatalf("invalid IP address %q for --exit-node: %v", upArgs.exitNodeIP, err)
return nil, fmt.Errorf("invalid IP address %q for --exit-node: %v", upArgs.exitNodeIP, err)
}
} else if upArgs.exitNodeAllowLANAccess {
return nil, fmt.Errorf("--exit-node-allow-lan-access can only be used with --exit-node")
}
if upArgs.exitNodeIP != "" {
for _, ip := range st.TailscaleIPs {
if exitNodeIP == ip {
return nil, fmt.Errorf("cannot use %s as the exit node as it is a local IP address to this machine, did you mean --advertise-exit-node?", upArgs.exitNodeIP)
}
}
}
@@ -180,13 +194,13 @@ func runUp(ctx context.Context, args []string) error {
for _, tag := range tags {
err := tailcfg.CheckTag(tag)
if err != nil {
fatalf("tag: %q: %s", tag, err)
return nil, fmt.Errorf("tag: %q: %s", tag, err)
}
}
}
if len(upArgs.hostname) > 256 {
fatalf("hostname too long: %d bytes (max 256)", len(upArgs.hostname))
return nil, fmt.Errorf("hostname too long: %d bytes (max 256)", len(upArgs.hostname))
}
prefs := ipn.NewPrefs()
@@ -194,6 +208,7 @@ func runUp(ctx context.Context, args []string) error {
prefs.WantRunning = true
prefs.RouteAll = upArgs.acceptRoutes
prefs.ExitNodeIP = exitNodeIP
prefs.ExitNodeAllowLANAccess = upArgs.exitNodeAllowLANAccess
prefs.CorpDNS = upArgs.acceptDNS
prefs.AllowSingleHosts = upArgs.singleRoutes
prefs.ShieldsUp = upArgs.shieldsUp
@@ -201,9 +216,10 @@ func runUp(ctx context.Context, args []string) error {
prefs.AdvertiseTags = tags
prefs.NoSNAT = !upArgs.snat
prefs.Hostname = upArgs.hostname
prefs.ForceDaemon = (runtime.GOOS == "windows")
prefs.ForceDaemon = upArgs.forceDaemon
prefs.OperatorUser = upArgs.opUser
if runtime.GOOS == "linux" {
if goos == "linux" {
switch upArgs.netfilterMode {
case "on":
prefs.NetfilterMode = preftype.NetfilterOn
@@ -214,98 +230,343 @@ func runUp(ctx context.Context, args []string) error {
prefs.NetfilterMode = preftype.NetfilterOff
warnf("netfilter=off; configure iptables yourself.")
default:
fatalf("invalid value --netfilter-mode: %q", upArgs.netfilterMode)
return nil, fmt.Errorf("invalid value --netfilter-mode=%q", upArgs.netfilterMode)
}
}
return prefs, nil
}
func runUp(ctx context.Context, args []string) error {
if len(args) > 0 {
fatalf("too many non-flag arguments: %q", args)
}
st, err := tailscale.Status(ctx)
if err != nil {
fatalf("can't fetch status from tailscaled: %v", err)
}
if distro.Get() == distro.Synology {
notSupported := "not yet supported on Synology; see https://github.com/tailscale/tailscale/issues/451"
if upArgs.acceptRoutes {
return errors.New("--accept-routes is " + notSupported)
}
if upArgs.exitNodeIP != "" {
return errors.New("--exit-node is " + notSupported)
}
if upArgs.netfilterMode != "off" {
return errors.New("--netfilter-mode values besides \"off\" " + notSupported)
}
}
c, bc, ctx, cancel := connect(ctx)
prefs, err := prefsFromUpArgs(upArgs, warnf, st, runtime.GOOS)
if err != nil {
fatalf("%s", err)
}
if len(prefs.AdvertiseRoutes) > 0 {
if err := tailscale.CheckIPForwarding(context.Background()); err != nil {
warnf("%v", err)
}
}
curPrefs, err := tailscale.GetPrefs(ctx)
if err != nil {
return err
}
flagSet := map[string]bool{}
mp := new(ipn.MaskedPrefs)
mp.WantRunningSet = true
mp.Prefs = *prefs
upFlagSet.Visit(func(f *flag.Flag) {
updateMaskedPrefsFromUpFlag(mp, f.Name)
flagSet[f.Name] = true
})
if !upArgs.reset {
if err := checkForAccidentalSettingReverts(flagSet, curPrefs, mp); err != nil {
fatalf("%s", err)
}
}
controlURLChanged := curPrefs.ControlURL != prefs.ControlURL
if controlURLChanged && st.BackendState == ipn.Running.String() && !upArgs.forceReauth {
fatalf("can't change --login-server without --force-reauth")
}
// If we're already running and none of the flags require a
// restart, we can just do an EditPrefs call and change the
// prefs at runtime (e.g. changing hostname, changinged
// advertised tags, routes, etc)
justEdit := st.BackendState == ipn.Running.String() &&
!upArgs.forceReauth &&
!upArgs.reset &&
upArgs.authKey == "" &&
!controlURLChanged
if justEdit {
_, err := tailscale.EditPrefs(ctx, mp)
return err
}
// simpleUp is whether we're running a simple "tailscale up"
// to transition to running from a previously-logged-in but
// down state, without changing any settings.
simpleUp := len(flagSet) == 0 && curPrefs.Persist != nil && curPrefs.Persist.LoginName != ""
// At this point we need to subscribe to the IPN bus to watch
// for state transitions and possible need to authenticate.
c, bc, pumpCtx, cancel := connect(ctx)
defer cancel()
if !prefs.ExitNodeIP.IsZero() {
st, err := tailscale.Status(ctx)
if err != nil {
fatalf("can't fetch status from tailscaled: %v", err)
}
for _, ip := range st.TailscaleIPs {
if prefs.ExitNodeIP == ip {
fatalf("cannot use %s as the exit node as it is a local IP address to this machine, did you mean --advertise-exit-node?", ip)
}
}
}
startingOrRunning := make(chan bool, 1) // gets value once starting or running
gotEngineUpdate := make(chan bool, 1) // gets value upon an engine update
go pump(pumpCtx, bc, c)
var printed bool
printed := !simpleUp
var loginOnce sync.Once
startLoginInteractive := func() { loginOnce.Do(func() { bc.StartLoginInteractive() }) }
bc.SetPrefs(prefs)
opts := ipn.Options{
StateKey: ipn.GlobalDaemonStateKey,
AuthKey: upArgs.authKey,
Notify: func(n ipn.Notify) {
if n.ErrMessage != nil {
msg := *n.ErrMessage
if msg == ipn.ErrMsgPermissionDenied {
switch runtime.GOOS {
case "windows":
msg += " (Tailscale service in use by other user?)"
default:
msg += " (try 'sudo tailscale up [...]')"
}
}
fatalf("backend error: %v\n", msg)
bc.SetNotifyCallback(func(n ipn.Notify) {
if n.Engine != nil {
select {
case gotEngineUpdate <- true:
default:
}
if s := n.State; s != nil {
switch *s {
case ipn.NeedsLogin:
printed = true
startLoginInteractive()
case ipn.NeedsMachineAuth:
printed = true
fmt.Fprintf(os.Stderr, "\nTo authorize your machine, visit (as admin):\n\n\t%s/admin/machines\n\n", upArgs.server)
case ipn.Starting, ipn.Running:
// Done full authentication process
if printed {
// Only need to print an update if we printed the "please click" message earlier.
fmt.Fprintf(os.Stderr, "Success.\n")
}
cancel()
}
if n.ErrMessage != nil {
msg := *n.ErrMessage
if msg == ipn.ErrMsgPermissionDenied {
switch runtime.GOOS {
case "windows":
msg += " (Tailscale service in use by other user?)"
default:
msg += " (try 'sudo tailscale up [...]')"
}
}
if url := n.BrowseToURL; url != nil {
fmt.Fprintf(os.Stderr, "\nTo authenticate, visit:\n\n\t%s\n\n", *url)
fatalf("backend error: %v\n", msg)
}
if s := n.State; s != nil {
switch *s {
case ipn.NeedsLogin:
printed = true
startLoginInteractive()
case ipn.NeedsMachineAuth:
printed = true
fmt.Fprintf(os.Stderr, "\nTo authorize your machine, visit (as admin):\n\n\t%s/admin/machines\n\n", upArgs.server)
case ipn.Starting, ipn.Running:
// Done full authentication process
if printed {
// Only need to print an update if we printed the "please click" message earlier.
fmt.Fprintf(os.Stderr, "Success.\n")
}
select {
case startingOrRunning <- true:
default:
}
cancel()
}
},
}
if url := n.BrowseToURL; url != nil {
printed = true
fmt.Fprintf(os.Stderr, "\nTo authenticate, visit:\n\n\t%s\n\n", *url)
}
})
// Wait for backend client to be connected so we know
// we're subscribed to updates. Otherwise we can miss
// an update upon its transition to running. Do so by causing some traffic
// back to the bus that we then wait on.
bc.RequestEngineStatus()
select {
case <-gotEngineUpdate:
case <-pumpCtx.Done():
return pumpCtx.Err()
}
// On Windows, we still run in mostly the "legacy" way that
// predated the server's StateStore. That is, we send an empty
// StateKey and send the prefs directly. Although the Windows
// supports server mode, though, the transition to StateStore
// is only half complete. Only server mode uses it, and the
// Windows service (~tailscaled) is the one that computes the
// StateKey based on the connection identity. So for now, just
// do as the Windows GUI's always done:
if runtime.GOOS == "windows" {
// The Windows service will set this as needed based
// on our connection's identity.
opts.StateKey = ""
opts.Prefs = prefs
}
// Special case: bare "tailscale up" means to just start
// running, if there's ever been a login.
if simpleUp {
_, err := tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
WantRunning: true,
},
WantRunningSet: true,
})
if err != nil {
return err
}
} else {
bc.SetPrefs(prefs)
// We still have to Start right now because it's the only way to
// set up notifications and whatnot. This causes a bunch of churn
// every time the CLI touches anything.
//
// TODO(danderson): redo the frontend/backend API to assume
// ephemeral frontends that read/modify/write state, once
// Windows/Mac state is moved into backend.
bc.Start(opts)
if upArgs.forceReauth {
printed = true
opts := ipn.Options{
StateKey: ipn.GlobalDaemonStateKey,
AuthKey: upArgs.authKey,
}
// On Windows, we still run in mostly the "legacy" way that
// predated the server's StateStore. That is, we send an empty
// StateKey and send the prefs directly. Although the Windows
// supports server mode, though, the transition to StateStore
// is only half complete. Only server mode uses it, and the
// Windows service (~tailscaled) is the one that computes the
// StateKey based on the connection identity. So for now, just
// do as the Windows GUI's always done:
if runtime.GOOS == "windows" {
// The Windows service will set this as needed based
// on our connection's identity.
opts.StateKey = ""
opts.Prefs = prefs
}
bc.Start(opts)
startLoginInteractive()
}
pump(ctx, bc, c)
return nil
select {
case <-startingOrRunning:
return nil
case <-pumpCtx.Done():
select {
case <-startingOrRunning:
return nil
default:
}
return pumpCtx.Err()
}
}
var (
flagForPref = map[string]string{} // "ExitNodeIP" => "exit-node"
prefsOfFlag = map[string][]string{}
)
func init() {
addPrefFlagMapping("accept-dns", "CorpDNS")
addPrefFlagMapping("accept-routes", "RouteAll")
addPrefFlagMapping("advertise-routes", "AdvertiseRoutes")
addPrefFlagMapping("advertise-tags", "AdvertiseTags")
addPrefFlagMapping("host-routes", "AllowSingleHosts")
addPrefFlagMapping("hostname", "Hostname")
addPrefFlagMapping("login-server", "ControlURL")
addPrefFlagMapping("netfilter-mode", "NetfilterMode")
addPrefFlagMapping("shields-up", "ShieldsUp")
addPrefFlagMapping("snat-subnet-routes", "NoSNAT")
addPrefFlagMapping("exit-node", "ExitNodeIP", "ExitNodeIP")
addPrefFlagMapping("exit-node-allow-lan-access", "ExitNodeAllowLANAccess")
addPrefFlagMapping("unattended", "ForceDaemon")
addPrefFlagMapping("operator", "OperatorUser")
}
func addPrefFlagMapping(flagName string, prefNames ...string) {
prefsOfFlag[flagName] = prefNames
prefType := reflect.TypeOf(ipn.Prefs{})
for _, pref := range prefNames {
flagForPref[pref] = flagName
// Crash at runtime if there's a typo in the prefName.
if _, ok := prefType.FieldByName(pref); !ok {
panic(fmt.Sprintf("invalid ipn.Prefs field %q", pref))
}
}
}
func updateMaskedPrefsFromUpFlag(mp *ipn.MaskedPrefs, flagName string) {
if prefs, ok := prefsOfFlag[flagName]; ok {
for _, pref := range prefs {
reflect.ValueOf(mp).Elem().FieldByName(pref + "Set").SetBool(true)
}
return
}
switch flagName {
case "authkey", "force-reauth", "reset":
// Not pref-related flags.
case "advertise-exit-node":
// This pref is a shorthand for advertise-routes.
default:
panic(fmt.Sprintf("internal error: unhandled flag %q", flagName))
}
}
// checkForAccidentalSettingReverts checks for people running
// "tailscale up" with a subset of the flags they originally ran it
// with.
//
// For example, in Tailscale 1.6 and prior, a user might've advertised
// a tag, but later tried to change just one other setting and forgot
// to mention the tag later and silently wiped it out. We now
// require --reset to change preferences to flag default values when
// the flag is not mentioned on the command line.
//
// curPrefs is what's currently active on the server.
//
// mp is the mask of settings actually set, where mp.Prefs is the new
// preferences to set, including any values set from implicit flags.
func checkForAccidentalSettingReverts(flagSet map[string]bool, curPrefs *ipn.Prefs, mp *ipn.MaskedPrefs) error {
if len(flagSet) == 0 {
// A bare "tailscale up" is a special case to just
// mean bringing the network up without any changes.
return nil
}
if curPrefs.ControlURL == "" {
// Don't validate things on initial "up" before a control URL has been set.
return nil
}
curWithExplicitEdits := curPrefs.Clone()
curWithExplicitEdits.ApplyEdits(mp)
prefType := reflect.TypeOf(ipn.Prefs{})
// Explicit values (current + explicit edit):
ev := reflect.ValueOf(curWithExplicitEdits).Elem()
// Implicit values (what we'd get if we replaced everything with flag defaults):
iv := reflect.ValueOf(&mp.Prefs).Elem()
var errs []error
var didExitNodeErr bool
for i := 0; i < prefType.NumField(); i++ {
prefName := prefType.Field(i).Name
if prefName == "Persist" {
continue
}
flagName, hasFlag := flagForPref[prefName]
if hasFlag && flagSet[flagName] {
continue
}
// Get explicit value and implicit value
ex, im := ev.Field(i), iv.Field(i)
switch ex.Kind() {
case reflect.String, reflect.Slice:
if ex.Kind() == reflect.Slice && ex.Len() == 0 && im.Len() == 0 {
// Treat nil and non-nil empty slices as equivalent.
continue
}
}
exi, imi := ex.Interface(), im.Interface()
if reflect.DeepEqual(exi, imi) {
continue
}
switch flagName {
case "":
errs = append(errs, fmt.Errorf("'tailscale up' without --reset requires all preferences with changing values to be explicitly mentioned; this command would change the value of flagless pref %q", prefName))
case "exit-node":
if !didExitNodeErr {
didExitNodeErr = true
errs = append(errs, errors.New("'tailscale up' without --reset requires all preferences with changing values to be explicitly mentioned; --exit-node is not specified but an exit node is currently configured"))
}
default:
errs = append(errs, fmt.Errorf("'tailscale up' without --reset requires all preferences with changing values to be explicitly mentioned; --%s is not specified but its default value of %v differs from current value %v",
flagName, fmtSettingVal(imi), fmtSettingVal(exi)))
}
}
return multierror.New(errs)
}
func fmtSettingVal(v interface{}) string {
switch v := v.(type) {
case bool:
return strconv.FormatBool(v)
case string, preftype.NetfilterMode:
return fmt.Sprintf("%q", v)
case []string:
return strings.Join(v, ",")
}
return fmt.Sprint(v)
}

View File

@@ -246,7 +246,7 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
// TODO(crawshaw): some of this is very similar to the code in 'tailscale up', can we share anything?
func tailscaleUp(ctx context.Context) (authURL string, retErr error) {
prefs := ipn.NewPrefs()
prefs.ControlURL = "https://login.tailscale.com"
prefs.ControlURL = ipn.DefaultControlURL
prefs.WantRunning = true
prefs.CorpDNS = true
prefs.AllowSingleHosts = true
@@ -259,30 +259,30 @@ func tailscaleUp(ctx context.Context) (authURL string, retErr error) {
c, bc, ctx, cancel := connect(ctx)
defer cancel()
bc.SetNotifyCallback(func(n ipn.Notify) {
if n.ErrMessage != nil {
msg := *n.ErrMessage
if msg == ipn.ErrMsgPermissionDenied {
switch runtime.GOOS {
case "windows":
msg += " (Tailscale service in use by other user?)"
default:
msg += " (try 'sudo tailscale up [...]')"
}
}
retErr = fmt.Errorf("backend error: %v", msg)
cancel()
} else if url := n.BrowseToURL; url != nil {
authURL = *url
cancel()
}
})
bc.SetPrefs(prefs)
opts := ipn.Options{
bc.Start(ipn.Options{
StateKey: ipn.GlobalDaemonStateKey,
Notify: func(n ipn.Notify) {
if n.ErrMessage != nil {
msg := *n.ErrMessage
if msg == ipn.ErrMsgPermissionDenied {
switch runtime.GOOS {
case "windows":
msg += " (Tailscale service in use by other user?)"
default:
msg += " (try 'sudo tailscale up [...]')"
}
}
retErr = fmt.Errorf("backend error: %v", msg)
cancel()
} else if url := n.BrowseToURL; url != nil {
authURL = *url
cancel()
}
},
}
bc.Start(opts)
})
bc.StartLoginInteractive()
pump(ctx, bc, c)

View File

@@ -2,6 +2,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
W 💣 github.com/alexbrainman/sspi from github.com/alexbrainman/sspi/negotiate
W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy
github.com/go-multierror/multierror from tailscale.com/cmd/tailscale/cli
github.com/peterbourgon/ff/v2 from github.com/peterbourgon/ff/v2/ffcli
github.com/peterbourgon/ff/v2/ffcli from tailscale.com/cmd/tailscale/cli
github.com/tcnksm/go-httpstat from tailscale.com/net/netcheck
@@ -14,12 +15,13 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
rsc.io/goversion/version from tailscale.com/version
tailscale.com/atomicfile from tailscale.com/ipn
tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli
tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
tailscale.com/derp from tailscale.com/derp/derphttp
tailscale.com/derp/derphttp from tailscale.com/net/netcheck
tailscale.com/derp/derpmap from tailscale.com/cmd/tailscale/cli
tailscale.com/disco from tailscale.com/derp
tailscale.com/ipn from tailscale.com/cmd/tailscale/cli
tailscale.com/ipn from tailscale.com/cmd/tailscale/cli+
tailscale.com/ipn/ipnstate from tailscale.com/cmd/tailscale/cli+
tailscale.com/metrics from tailscale.com/derp
tailscale.com/net/dnscache from tailscale.com/derp/derphttp

View File

@@ -71,6 +71,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
inet.af/peercred from tailscale.com/ipn/ipnserver
rsc.io/goversion/version from tailscale.com/version
tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnlocal+
tailscale.com/control/controlclient from tailscale.com/ipn/ipnlocal+
tailscale.com/derp from tailscale.com/derp/derphttp+
tailscale.com/derp/derphttp from tailscale.com/net/netcheck+
@@ -163,7 +164,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+
golang.org/x/net/bpf from github.com/mdlayher/netlink+
golang.org/x/net/dns/dnsmessage from net+
golang.org/x/net/http/httpguts from net/http
golang.org/x/net/http/httpguts from net/http+
golang.org/x/net/http/httpproxy from net/http
golang.org/x/net/http2/hpack from net/http
golang.org/x/net/idna from golang.org/x/net/http/httpguts+
@@ -177,7 +178,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
LD golang.org/x/sys/unix from github.com/jsimonetti/rtnetlink/internal/unix+
W golang.org/x/sys/windows from github.com/tailscale/wireguard-go/conn+
W golang.org/x/sys/windows/registry from golang.zx2c4.com/wireguard/windows/tunnel/winipcfg+
W golang.org/x/sys/windows/svc from tailscale.com/cmd/tailscaled
W golang.org/x/sys/windows/svc from tailscale.com/cmd/tailscaled+
W golang.org/x/sys/windows/svc/mgr from tailscale.com/cmd/tailscaled
golang.org/x/term from tailscale.com/logpolicy
golang.org/x/text/secure/bidirule from golang.org/x/net/idna
golang.org/x/text/transform from golang.org/x/text/secure/bidirule+
@@ -216,6 +218,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
debug/elf from rsc.io/goversion/version
debug/macho from rsc.io/goversion/version
debug/pe from rsc.io/goversion/version
embed from tailscale.com/net/dns
encoding from encoding/json+
encoding/asn1 from crypto/x509+
encoding/base64 from encoding/json+
@@ -247,7 +250,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
net from crypto/tls+
net/http from expvar+
net/http/httptrace from github.com/tcnksm/go-httpstat+
net/http/internal from net/http
net/http/httputil from tailscale.com/ipn/localapi
net/http/internal from net/http+
net/http/pprof from tailscale.com/cmd/tailscaled
net/textproto from golang.org/x/net/http/httpguts+
net/url from crypto/x509+

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"errors"
"fmt"
"os"
"time"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
"tailscale.com/logtail/backoff"
"tailscale.com/types/logger"
)
func init() {
installSystemDaemon = installSystemDaemonWindows
uninstallSystemDaemon = uninstallSystemDaemonWindows
}
func installSystemDaemonWindows(args []string) (err error) {
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("failed to connect to Windows service manager: %v", err)
}
service, err := m.OpenService(serviceName)
if err == nil {
service.Close()
return fmt.Errorf("service %q is already installed", serviceName)
}
// no such service; proceed to install the service.
exe, err := os.Executable()
if err != nil {
return err
}
c := mgr.Config{
ServiceType: windows.SERVICE_WIN32_OWN_PROCESS,
StartType: mgr.StartAutomatic,
ErrorControl: mgr.ErrorNormal,
DisplayName: serviceName,
Description: "Connects this computer to others on the Tailscale network.",
}
service, err = m.CreateService(serviceName, exe, c)
if err != nil {
return fmt.Errorf("failed to create %q service: %v", serviceName, err)
}
defer service.Close()
// Exponential backoff is often too aggressive, so use (mostly)
// squares instead.
ra := []mgr.RecoveryAction{
{mgr.ServiceRestart, 1 * time.Second},
{mgr.ServiceRestart, 2 * time.Second},
{mgr.ServiceRestart, 4 * time.Second},
{mgr.ServiceRestart, 9 * time.Second},
{mgr.ServiceRestart, 16 * time.Second},
{mgr.ServiceRestart, 25 * time.Second},
{mgr.ServiceRestart, 36 * time.Second},
{mgr.ServiceRestart, 49 * time.Second},
{mgr.ServiceRestart, 64 * time.Second},
}
const resetPeriodSecs = 60
err = service.SetRecoveryActions(ra, resetPeriodSecs)
if err != nil {
return fmt.Errorf("failed to set service recovery actions: %v", err)
}
return nil
}
func uninstallSystemDaemonWindows(args []string) (ret error) {
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("failed to connect to Windows service manager: %v", err)
}
defer m.Disconnect()
service, err := m.OpenService(serviceName)
if err != nil {
return fmt.Errorf("failed to open %q service: %v", serviceName, err)
}
st, err := service.Query()
if err != nil {
service.Close()
return fmt.Errorf("failed to query service state: %v", err)
}
if st.State != svc.Stopped {
service.Control(svc.Stop)
}
err = service.Delete()
service.Close()
if err != nil {
return fmt.Errorf("failed to delete service: %v", err)
}
bo := backoff.NewBackoff("uninstall", logger.Discard, 30*time.Second)
end := time.Now().Add(15 * time.Second)
for time.Until(end) > 0 {
service, err = m.OpenService(serviceName)
if err != nil {
// service is no longer openable; success!
break
}
service.Close()
bo.BackOff(context.Background(), errors.New("service not deleted"))
}
return nil
}

View File

@@ -29,10 +29,12 @@ import (
"time"
"github.com/go-multierror/multierror"
"inet.af/netaddr"
"tailscale.com/ipn/ipnserver"
"tailscale.com/logpolicy"
"tailscale.com/net/dns"
"tailscale.com/net/socks5"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tstun"
"tailscale.com/paths"
"tailscale.com/types/flagtype"
@@ -230,44 +232,40 @@ func run() error {
}
var ns *netstack.Impl
if useNetstack {
tunDev, magicConn, ok := e.(wgengine.InternalsGetter).GetInternals()
if !ok {
log.Fatalf("%T is not a wgengine.InternalsGetter", e)
}
ns, err = netstack.Create(logf, tunDev, e, magicConn)
if err != nil {
log.Fatalf("netstack.Create: %v", err)
}
if err := ns.Start(); err != nil {
log.Fatalf("failed to start netstack: %v", err)
}
if useNetstack || wrapNetstack {
onlySubnets := wrapNetstack && !useNetstack
ns = mustStartNetstack(logf, e, onlySubnets)
}
if socksListener != nil {
srv := &socks5.Server{
Logf: logger.WithPrefix(logf, "socks5: "),
}
if useNetstack {
srv.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) {
var (
mu sync.Mutex // guards the following field
dns netstack.DNSMap
)
e.AddNetworkMapCallback(func(nm *netmap.NetworkMap) {
mu.Lock()
defer mu.Unlock()
dns = netstack.DNSMapFromNetworkMap(nm)
})
useNetstackForIP := func(ip netaddr.IP) bool {
// TODO(bradfitz): this isn't exactly right.
// We should also support subnets when the
// prefs are configured as such.
return tsaddr.IsTailscaleIP(ip)
}
srv.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) {
ipp, err := dns.Resolve(ctx, addr)
if err != nil {
return nil, err
}
if ns != nil && useNetstackForIP(ipp.IP) {
return ns.DialContextTCP(ctx, addr)
}
} else {
var mu sync.Mutex
var dns netstack.DNSMap
e.AddNetworkMapCallback(func(nm *netmap.NetworkMap) {
mu.Lock()
defer mu.Unlock()
dns = netstack.DNSMapFromNetworkMap(nm)
})
srv.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) {
ipp, err := dns.Resolve(ctx, addr)
if err != nil {
return nil, err
}
var d net.Dialer
return d.DialContext(ctx, network, ipp.String())
}
var d net.Dialer
return d.DialContext(ctx, network, ipp.String())
}
go func() {
log.Fatalf("SOCKS5 server exited: %v", srv.Serve(socksListener))
@@ -300,8 +298,7 @@ func run() error {
Port: 41112,
StatePath: args.statepath,
AutostartStateKey: globalStateKey,
LegacyConfigPath: paths.LegacyConfigPath(),
SurviveDisconnects: true,
SurviveDisconnects: runtime.GOOS != "windows",
DebugMux: debugMux,
}
err = ipnserver.Run(ctx, logf, pol.PublicID.String(), ipnserver.FixedEngine(e), opts)
@@ -331,6 +328,28 @@ func createEngine(logf logger.Logf, linkMon *monitor.Mon) (e wgengine.Engine, us
return nil, false, multierror.New(errs)
}
var wrapNetstack = shouldWrapNetstack()
func shouldWrapNetstack() bool {
if e := os.Getenv("TS_DEBUG_WRAP_NETSTACK"); e != "" {
v, err := strconv.ParseBool(e)
if err != nil {
log.Fatalf("invalid TS_DEBUG_WRAP_NETSTACK value: %v", err)
}
return v
}
if distro.Get() == distro.Synology {
return true
}
switch runtime.GOOS {
case "windows", "darwin":
// Enable on Windows and tailscaled-on-macOS (this doesn't
// affect the GUI clients).
return true
}
return false
}
func tryEngine(logf logger.Logf, linkMon *monitor.Mon, name string) (e wgengine.Engine, useNetstack bool, err error) {
conf := wgengine.Config{
ListenPort: args.port,
@@ -338,7 +357,7 @@ func tryEngine(logf logger.Logf, linkMon *monitor.Mon, name string) (e wgengine.
}
useNetstack = name == "userspace-networking"
if !useNetstack {
dev, err := tstun.New(logf, name)
dev, devName, err := tstun.New(logf, name)
if err != nil {
tstun.Diagnose(logf, name)
return nil, false, err
@@ -349,14 +368,15 @@ func tryEngine(logf logger.Logf, linkMon *monitor.Mon, name string) (e wgengine.
dev.Close()
return nil, false, err
}
conf.Router = r
tunname, err := dev.Name()
d, err := dns.NewOSConfigurator(logf, devName)
if err != nil {
r.Close()
dev.Close()
return nil, false, err
}
conf.DNS = dns.NewOSConfigurator(logf, tunname)
conf.DNS = d
conf.Router = r
if wrapNetstack {
conf.Router = netstack.NewSubnetRouterWrapper(conf.Router)
}
}
e, err = wgengine.NewUserspaceEngine(logf, conf)
if err != nil {
@@ -384,3 +404,18 @@ func runDebugServer(mux *http.ServeMux, addr string) {
log.Fatal(err)
}
}
func mustStartNetstack(logf logger.Logf, e wgengine.Engine, onlySubnets bool) *netstack.Impl {
tunDev, magicConn, ok := e.(wgengine.InternalsGetter).GetInternals()
if !ok {
log.Fatalf("%T is not a wgengine.InternalsGetter", e)
}
ns, err := netstack.Create(logf, tunDev, e, magicConn, onlySubnets)
if err != nil {
log.Fatalf("netstack.Create: %v", err)
}
if err := ns.Start(); err != nil {
log.Fatalf("failed to start netstack: %v", err)
}
return ns
}

View File

@@ -30,11 +30,13 @@ import (
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
"tailscale.com/ipn/ipnserver"
"tailscale.com/logpolicy"
"tailscale.com/net/dns"
"tailscale.com/net/tstun"
"tailscale.com/tempfork/wireguard-windows/firewall"
"tailscale.com/types/logger"
"tailscale.com/version"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
"tailscale.com/wgengine/router"
)
@@ -157,86 +159,120 @@ func beFirewallKillswitch() bool {
func startIPNServer(ctx context.Context, logid string) error {
var logf logger.Logf = log.Printf
var eng wgengine.Engine
var err error
getEngine := func() (wgengine.Engine, error) {
dev, err := tstun.New(logf, "Tailscale")
getEngineRaw := func() (wgengine.Engine, error) {
dev, devName, err := tstun.New(logf, "Tailscale")
if err != nil {
return nil, err
return nil, fmt.Errorf("TUN: %w", err)
}
r, err := router.New(logf, dev)
if err != nil {
dev.Close()
return nil, err
return nil, fmt.Errorf("Router: %w", err)
}
if wrapNetstack {
r = netstack.NewSubnetRouterWrapper(r)
}
d, err := dns.NewOSConfigurator(logf, devName)
if err != nil {
r.Close()
dev.Close()
return nil, fmt.Errorf("DNS: %w", err)
}
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Tun: dev,
Router: r,
DNS: d,
ListenPort: 41641,
})
if err != nil {
r.Close()
dev.Close()
return nil, err
return nil, fmt.Errorf("Engine: %w", err)
}
onlySubnets := true
if wrapNetstack {
mustStartNetstack(logf, eng, onlySubnets)
}
return wgengine.NewWatchdog(eng), nil
}
if msg := os.Getenv("TS_DEBUG_WIN_FAIL"); msg != "" {
err = fmt.Errorf("pretending to be a service failure: %v", msg)
} else {
// We have a bunch of bug reports of wgengine.NewUserspaceEngine returning a few different errors,
// all intermittently. A few times I (Brad) have also seen sporadic failures that simply
// restarting fixed. So try a few times.
for try := 1; try <= 5; try++ {
if try > 1 {
// Only sleep a bit. Don't do some massive backoff because
// the frontend GUI has a 30 second timeout on connecting to us,
// but even 5 seconds is too long for them to get any results.
// 5 tries * 1 second each seems fine.
time.Sleep(time.Second)
}
eng, err = getEngine()
type engineOrError struct {
Engine wgengine.Engine
Err error
}
engErrc := make(chan engineOrError)
t0 := time.Now()
go func() {
const ms = time.Millisecond
for try := 1; ; try++ {
logf("tailscaled: getting engine... (try %v)", try)
t1 := time.Now()
eng, err := getEngineRaw()
d, dt := time.Since(t1).Round(ms), time.Since(t1).Round(ms)
if err != nil {
logf("wgengine.NewUserspaceEngine: (try %v) %v", try, err)
continue
logf("tailscaled: engine fetch error (try %v) in %v (total %v, sysUptime %v): %v",
try, d, dt, windowsUptime().Round(time.Second), err)
} else {
if try > 1 {
logf("tailscaled: got engine on try %v in %v (total %v)", try, d, dt)
} else {
logf("tailscaled: got engine in %v", d)
}
}
if try > 1 {
logf("wgengine.NewUserspaceEngine: ended up working on try %v", try)
timer := time.NewTimer(5 * time.Second)
engErrc <- engineOrError{eng, err}
if err == nil {
timer.Stop()
return
}
break
<-timer.C
}
}
if err != nil {
// Log the error, but don't fatalf. We want to
// propagate the error message to the UI frontend. So
// we continue and tell the ipnserver to return that
// in a Notify message.
logf("wgengine.NewUserspaceEngine: %v", err)
}
}()
opts := ipnserver.Options{
Port: 41112,
SurviveDisconnects: false,
StatePath: args.statepath,
}
if err != nil {
// Return nicer errors to users, annotated with logids, which helps
// when they file bugs.
rawGetEngine := getEngine // raw == without verbose logid-containing error
getEngine = func() (wgengine.Engine, error) {
eng, err := rawGetEngine()
if err != nil {
return nil, fmt.Errorf("wgengine.NewUserspaceEngine: %v\n\nlogid: %v", err, logid)
}
return eng, nil
// getEngine is called by ipnserver to get the engine. It's
// not called concurrently and is not called again once it
// successfully returns an engine.
getEngine := func() (wgengine.Engine, error) {
if msg := os.Getenv("TS_DEBUG_WIN_FAIL"); msg != "" {
return nil, fmt.Errorf("pretending to be a service failure: %v", msg)
}
for {
res := <-engErrc
if res.Engine != nil {
return res.Engine, nil
}
if time.Since(t0) < time.Minute || windowsUptime() < 10*time.Minute {
// Ignore errors during early boot. Windows 10 auto logs in the GUI
// way sooner than the networking stack components start up.
// So the network will fail for a bit (and require a few tries) while
// the GUI is still fine.
continue
}
// Return nicer errors to users, annotated with logids, which helps
// when they file bugs.
return nil, fmt.Errorf("%w\n\nlogid: %v", res.Err, logid)
}
} else {
getEngine = ipnserver.FixedEngine(eng)
}
err = ipnserver.Run(ctx, logf, logid, getEngine, opts)
err := ipnserver.Run(ctx, logf, logid, getEngine, opts)
if err != nil {
logf("ipnserver.Run: %v", err)
}
return err
}
var (
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
getTickCount64Proc = kernel32.NewProc("GetTickCount64")
)
func windowsUptime() time.Duration {
r, _, _ := getTickCount64Proc.Call()
return time.Duration(int64(r)) * time.Millisecond
}

View File

@@ -100,11 +100,22 @@ func (s Status) String() string {
}
type LoginGoal struct {
_ structs.Incomparable
wantLoggedIn bool // true if we *want* to be logged in
token *tailcfg.Oauth2Token // oauth token to use when logging in
flags LoginFlags // flags to use when logging in
url string // auth url that needs to be visited
_ structs.Incomparable
wantLoggedIn bool // true if we *want* to be logged in
token *tailcfg.Oauth2Token // oauth token to use when logging in
flags LoginFlags // flags to use when logging in
url string // auth url that needs to be visited
loggedOutResult chan<- error
}
func (g *LoginGoal) sendLogoutError(err error) {
if g.loggedOutResult == nil {
return
}
select {
case g.loggedOutResult <- err:
default:
}
}
// Client connects to a tailcontrol server for a node.
@@ -363,6 +374,7 @@ func (c *Client) authRoutine() {
if !goal.wantLoggedIn {
err := c.direct.TryLogout(ctx)
goal.sendLogoutError(err)
if err != nil {
report(err, "TryLogout")
bo.BackOff(ctx, err)
@@ -402,7 +414,8 @@ func (c *Client) authRoutine() {
report(err, f)
bo.BackOff(ctx, err)
continue
} else if url != "" {
}
if url != "" {
if goal.url != "" {
err = fmt.Errorf("[unexpected] server required a new URL?")
report(err, "WaitLoginURL")
@@ -682,18 +695,42 @@ func (c *Client) Login(t *tailcfg.Oauth2Token, flags LoginFlags) {
c.cancelAuth()
}
func (c *Client) Logout() {
c.logf("client.Logout()")
func (c *Client) StartLogout() {
c.logf("client.StartLogout()")
c.mu.Lock()
c.loginGoal = &LoginGoal{
wantLoggedIn: false,
}
c.mu.Unlock()
c.cancelAuth()
}
func (c *Client) Logout(ctx context.Context) error {
c.logf("client.Logout()")
errc := make(chan error, 1)
c.mu.Lock()
c.loginGoal = &LoginGoal{
wantLoggedIn: false,
loggedOutResult: errc,
}
c.mu.Unlock()
c.cancelAuth()
timer := time.NewTimer(10 * time.Second)
defer timer.Stop()
select {
case err := <-errc:
return err
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return context.DeadlineExceeded
}
}
// UpdateEndpoints sets the client's discovered endpoints and sends
// them to the control server if they've changed.
//
@@ -701,7 +738,7 @@ func (c *Client) Logout() {
//
// The localPort field is unused except for integration tests in
// another repo.
func (c *Client) UpdateEndpoints(localPort uint16, endpoints []string) {
func (c *Client) UpdateEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) {
changed := c.direct.SetEndpoints(localPort, endpoints)
if changed {
c.sendNewMapRequest()

View File

@@ -23,7 +23,6 @@ import (
"path/filepath"
"reflect"
"runtime"
"sort"
"strconv"
"strings"
"sync"
@@ -46,9 +45,9 @@ import (
"tailscale.com/types/opt"
"tailscale.com/types/persist"
"tailscale.com/types/wgkey"
"tailscale.com/util/dnsname"
"tailscale.com/util/systemd"
"tailscale.com/version"
"tailscale.com/wgengine/filter"
"tailscale.com/wgengine/monitor"
)
@@ -76,7 +75,7 @@ type Direct struct {
expiry *time.Time
// hostinfo is mutated in-place while mu is held.
hostinfo *tailcfg.Hostinfo // always non-nil
endpoints []string
endpoints []tailcfg.Endpoint
everEndpoints bool // whether we've ever had non-empty endpoints
localPort uint16 // or zero to mean auto
}
@@ -144,7 +143,7 @@ func NewDirect(opts Options) (*Direct, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = tshttpproxy.ProxyFromEnvironment
tshttpproxy.SetTransportGetProxyConnectHeader(tr)
tr.TLSClientConfig = tlsdial.Config(serverURL.Host, tr.TLSClientConfig)
tr.TLSClientConfig = tlsdial.Config(serverURL.Hostname(), tr.TLSClientConfig)
tr.DialContext = dnscache.Dialer(dialer.DialContext, dnsCache)
tr.DialTLSContext = dnscache.TLSDialer(dialer.DialContext, dnsCache, tr.TLSClientConfig)
tr.ForceAttemptHTTP2 = true
@@ -179,6 +178,7 @@ var osVersion func() string // non-nil on some platforms
func NewHostinfo() *tailcfg.Hostinfo {
hostname, _ := os.Hostname()
hostname = dnsname.FirstLabel(hostname)
var osv string
if osVersion != nil {
osv = osVersion()
@@ -261,20 +261,19 @@ const (
func (c *Direct) TryLogout(ctx context.Context) error {
c.logf("direct.TryLogout()")
c.mu.Lock()
defer c.mu.Unlock()
mustRegen, newURL, err := c.doLogin(ctx, loginOpt{Logout: true})
c.logf("TryLogout control response: mustRegen=%v, newURL=%v, err=%v", mustRegen, newURL, err)
// TODO(crawshaw): Tell the server. This node key should be
// immediately invalidated.
//if !c.persist.PrivateNodeKey.IsZero() {
//}
c.mu.Lock()
c.persist = persist.Persist{}
return nil
c.mu.Unlock()
return err
}
func (c *Direct) TryLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags LoginFlags) (url string, err error) {
c.logf("direct.TryLogin(token=%v, flags=%v)", t != nil, flags)
return c.doLoginOrRegen(ctx, t, flags, false, "")
return c.doLoginOrRegen(ctx, loginOpt{Token: t, Flags: flags})
}
// WaitLoginURL sits in a long poll waiting for the user to authenticate at url.
@@ -282,22 +281,30 @@ func (c *Direct) TryLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags Log
// On success, newURL and err will both be nil.
func (c *Direct) WaitLoginURL(ctx context.Context, url string) (newURL string, err error) {
c.logf("direct.WaitLoginURL")
return c.doLoginOrRegen(ctx, nil, LoginDefault, false, url)
return c.doLoginOrRegen(ctx, loginOpt{URL: url})
}
func (c *Direct) doLoginOrRegen(ctx context.Context, t *tailcfg.Oauth2Token, flags LoginFlags, regen bool, url string) (newURL string, err error) {
mustregen, url, err := c.doLogin(ctx, t, flags, regen, url)
func (c *Direct) doLoginOrRegen(ctx context.Context, opt loginOpt) (newURL string, err error) {
mustRegen, url, err := c.doLogin(ctx, opt)
if err != nil {
return url, err
}
if mustregen {
_, url, err = c.doLogin(ctx, t, flags, true, url)
if mustRegen {
opt.Regen = true
_, url, err = c.doLogin(ctx, opt)
}
return url, err
}
func (c *Direct) doLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags LoginFlags, regen bool, url string) (mustregen bool, newurl string, err error) {
type loginOpt struct {
Token *tailcfg.Oauth2Token
Flags LoginFlags
Regen bool
URL string
Logout bool
}
func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, newURL string, err error) {
c.mu.Lock()
persist := c.persist
tryingNewKey := c.tryingNewKey
@@ -316,22 +323,27 @@ func (c *Direct) doLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags Logi
return false, "", errors.New("getMachinePrivKey returned zero key")
}
if expired {
c.logf("Old key expired -> regen=true")
systemd.Status("key expired; run 'tailscale up' to authenticate")
regen = true
}
if (flags & LoginInteractive) != 0 {
c.logf("LoginInteractive -> regen=true")
regen = true
regen := opt.Regen
if opt.Logout {
c.logf("logging out...")
} else {
if expired {
c.logf("Old key expired -> regen=true")
systemd.Status("key expired; run 'tailscale up' to authenticate")
regen = true
}
if (opt.Flags & LoginInteractive) != 0 {
c.logf("LoginInteractive -> regen=true")
regen = true
}
}
c.logf("doLogin(regen=%v, hasUrl=%v)", regen, url != "")
c.logf("doLogin(regen=%v, hasUrl=%v)", regen, opt.URL != "")
if serverKey.IsZero() {
var err error
serverKey, err = loadServerKey(ctx, c.httpc, c.serverURL)
if err != nil {
return regen, url, err
return regen, opt.URL, err
}
c.mu.Lock()
@@ -340,17 +352,21 @@ func (c *Direct) doLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags Logi
}
var oldNodeKey wgkey.Key
if url != "" {
} else if regen || persist.PrivateNodeKey.IsZero() {
switch {
case opt.Logout:
tryingNewKey = persist.PrivateNodeKey
case opt.URL != "":
// Nothing.
case regen || persist.PrivateNodeKey.IsZero():
c.logf("Generating a new nodekey.")
persist.OldPrivateNodeKey = persist.PrivateNodeKey
key, err := wgkey.NewPrivate()
if err != nil {
c.logf("login keygen: %v", err)
return regen, url, err
return regen, opt.URL, err
}
tryingNewKey = key
} else {
default:
// Try refreshing the current key first
tryingNewKey = persist.PrivateNodeKey
}
@@ -359,11 +375,14 @@ func (c *Direct) doLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags Logi
}
if tryingNewKey.IsZero() {
if opt.Logout {
return false, "", errors.New("no nodekey to log out")
}
log.Fatalf("tryingNewKey is empty, give up")
}
if backendLogID == "" {
err = errors.New("hostinfo: BackendLogID missing")
return regen, url, err
return regen, opt.URL, err
}
now := time.Now().Round(time.Second)
request := tailcfg.RegisterRequest{
@@ -371,13 +390,16 @@ func (c *Direct) doLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags Logi
OldNodeKey: tailcfg.NodeKey(oldNodeKey),
NodeKey: tailcfg.NodeKey(tryingNewKey.Public()),
Hostinfo: hostinfo,
Followup: url,
Followup: opt.URL,
Timestamp: &now,
}
if opt.Logout {
request.Expiry = time.Unix(123, 0) // far in the past
}
c.logf("RegisterReq: onode=%v node=%v fup=%v",
request.OldNodeKey.ShortString(),
request.NodeKey.ShortString(), url != "")
request.Auth.Oauth2Token = t
request.NodeKey.ShortString(), opt.URL != "")
request.Auth.Oauth2Token = opt.Token
request.Auth.Provider = persist.Provider
request.Auth.LoginName = persist.LoginName
request.Auth.AuthKey = authKey
@@ -395,34 +417,44 @@ func (c *Direct) doLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags Logi
c.logf("RegisterReq sign error: %v", err)
}
}
if debugRegister {
j, _ := json.MarshalIndent(request, "", "\t")
c.logf("RegisterRequest: %s", j)
}
bodyData, err := encode(request, &serverKey, &machinePrivKey)
if err != nil {
return regen, url, err
return regen, opt.URL, err
}
body := bytes.NewReader(bodyData)
u := fmt.Sprintf("%s/machine/%s", c.serverURL, machinePrivKey.Public().HexString())
req, err := http.NewRequest("POST", u, body)
if err != nil {
return regen, url, err
return regen, opt.URL, err
}
req = req.WithContext(ctx)
res, err := c.httpc.Do(req)
if err != nil {
return regen, url, fmt.Errorf("register request: %v", err)
return regen, opt.URL, fmt.Errorf("register request: %v", err)
}
if res.StatusCode != 200 {
msg, _ := ioutil.ReadAll(res.Body)
res.Body.Close()
return regen, url, fmt.Errorf("register request: http %d: %.200s",
return regen, opt.URL, fmt.Errorf("register request: http %d: %.200s",
res.StatusCode, strings.TrimSpace(string(msg)))
}
resp := tailcfg.RegisterResponse{}
if err := decode(res, &resp, &serverKey, &machinePrivKey); err != nil {
c.logf("error decoding RegisterResponse with server key %s and machine key %s: %v", serverKey, machinePrivKey.Public(), err)
return regen, url, fmt.Errorf("register request: %v", err)
return regen, opt.URL, fmt.Errorf("register request: %v", err)
}
if debugRegister {
j, _ := json.MarshalIndent(resp, "", "\t")
c.logf("RegisterResponse: %s", j)
}
// Log without PII:
c.logf("RegisterReq: got response; nodeKeyExpired=%v, machineAuthorized=%v; authURL=%v",
resp.NodeKeyExpired, resp.MachineAuthorized, resp.AuthURL != "")
@@ -474,7 +506,7 @@ func (c *Direct) doLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags Logi
return false, resp.AuthURL, nil
}
func sameStrings(a, b []string) bool {
func sameEndpoints(a, b []tailcfg.Endpoint) bool {
if len(a) != len(b) {
return false
}
@@ -490,15 +522,19 @@ func sameStrings(a, b []string) bool {
// whether they've changed.
//
// It does not retain the provided slice.
func (c *Direct) newEndpoints(localPort uint16, endpoints []string) (changed bool) {
func (c *Direct) newEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) (changed bool) {
c.mu.Lock()
defer c.mu.Unlock()
// Nothing new?
if c.localPort == localPort && sameStrings(c.endpoints, endpoints) {
if c.localPort == localPort && sameEndpoints(c.endpoints, endpoints) {
return false // unchanged
}
c.logf("client.newEndpoints(%v, %v)", localPort, endpoints)
var epStrs []string
for _, ep := range endpoints {
epStrs = append(epStrs, ep.Addr.String())
}
c.logf("client.newEndpoints(%v, %v)", localPort, epStrs)
c.localPort = localPort
c.endpoints = append(c.endpoints[:0], endpoints...)
if len(endpoints) > 0 {
@@ -510,7 +546,7 @@ func (c *Direct) newEndpoints(localPort uint16, endpoints []string) (changed boo
// SetEndpoints updates the list of locally advertised endpoints.
// It won't be replicated to the server until a *fresh* call to PollNetMap().
// You don't need to restart PollNetMap if we return changed==false.
func (c *Direct) SetEndpoints(localPort uint16, endpoints []string) (changed bool) {
func (c *Direct) SetEndpoints(localPort uint16, endpoints []tailcfg.Endpoint) (changed bool) {
// (no log message on function entry, because it clutters the logs
// if endpoints haven't changed. newEndpoints() will log it.)
return c.newEndpoints(localPort, endpoints)
@@ -543,7 +579,12 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
hostinfo := c.hostinfo.Clone()
backendLogID := hostinfo.BackendLogID
localPort := c.localPort
ep := append([]string(nil), c.endpoints...)
var epStrs []string
var epTypes []tailcfg.EndpointType
for _, ep := range c.endpoints {
epStrs = append(epStrs, ep.Addr.String())
epTypes = append(epTypes, ep.Type)
}
everEndpoints := c.everEndpoints
c.mu.Unlock()
@@ -563,7 +604,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
}
allowStream := maxPolls != 1
c.logf("[v1] PollNetMap: stream=%v :%v ep=%v", allowStream, localPort, ep)
c.logf("[v1] PollNetMap: stream=%v :%v ep=%v", allowStream, localPort, epStrs)
vlogf := logger.Discard
if Debug.NetMap {
@@ -573,15 +614,16 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
}
request := &tailcfg.MapRequest{
Version: tailcfg.CurrentMapRequestVersion,
KeepAlive: c.keepAlive,
NodeKey: tailcfg.NodeKey(persist.PrivateNodeKey.Public()),
DiscoKey: c.discoPubKey,
Endpoints: ep,
Stream: allowStream,
Hostinfo: hostinfo,
DebugFlags: c.debugFlags,
OmitPeers: cb == nil,
Version: tailcfg.CurrentMapRequestVersion,
KeepAlive: c.keepAlive,
NodeKey: tailcfg.NodeKey(persist.PrivateNodeKey.Public()),
DiscoKey: c.discoPubKey,
Endpoints: epStrs,
EndpointTypes: epTypes,
Stream: allowStream,
Hostinfo: hostinfo,
DebugFlags: c.debugFlags,
OmitPeers: cb == nil,
}
var extraDebugFlags []string
if hostinfo != nil && c.linkMon != nil && !c.skipIPForwardingCheck &&
@@ -609,7 +651,7 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
// TODO(bradfitz): we skip this optimization in tests, though,
// because the e2e tests are currently hyperspecific about the
// ordering of things. The e2e tests need love.
if len(ep) == 0 && !everEndpoints && !inTest() {
if len(epStrs) == 0 && !everEndpoints && !inTest() {
request.ReadOnly = true
}
@@ -685,10 +727,11 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
}
}()
var lastDERPMap *tailcfg.DERPMap
var lastUserProfile = map[tailcfg.UserID]tailcfg.UserProfile{}
var lastParsedPacketFilter []filter.Match
var collectServices bool
sess := newMapSession(persist.PrivateNodeKey)
sess.logf = c.logf
sess.vlogf = vlogf
sess.machinePubKey = machinePubKey
sess.keepSharerAndUserSplit = c.keepSharerAndUserSplit
// If allowStream, then the server will use an HTTP long poll to
// return incremental results. There is always one response right
@@ -697,7 +740,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
// the same format before just closing the connection.
// We can use this same read loop either way.
var msg []byte
var previousPeers []*tailcfg.Node // for delta-purposes
for i := 0; i < maxPolls || maxPolls < 0; i++ {
vlogf("netmap: starting size read after %v (poll %v)", time.Since(t0).Round(time.Millisecond), i)
var siz [4]byte
@@ -744,16 +786,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
continue
}
undeltaPeers(&resp, previousPeers)
previousPeers = cloneNodes(resp.Peers) // defensive/lazy clone, since this escapes to who knows where
for _, up := range resp.UserProfiles {
lastUserProfile[up.ID] = up
}
if resp.DERPMap != nil {
vlogf("netmap: new map contains DERP map")
lastDERPMap = resp.DERPMap
}
if resp.Debug != nil {
if resp.Debug.LogHeapPprof {
go logheap.LogHeap(resp.Debug.LogHeapURL)
@@ -764,17 +796,34 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
setControlAtomic(&controlUseDERPRoute, resp.Debug.DERPRoute)
setControlAtomic(&controlTrimWGConfig, resp.Debug.TrimWGConfig)
}
nm := sess.netmapForResponse(&resp)
if nm.SelfNode == nil {
c.logf("MapResponse lacked node")
return errors.New("MapResponse lacked node")
}
// Temporarily (2020-06-29) support removing all but
// discovery-supporting nodes during development, for
// less noise.
if Debug.OnlyDisco {
filtered := resp.Peers[:0]
for _, p := range resp.Peers {
if !p.DiscoKey.IsZero() {
filtered = append(filtered, p)
anyOld, numDisco := false, 0
for _, p := range nm.Peers {
if p.DiscoKey.IsZero() {
anyOld = true
} else {
numDisco++
}
}
resp.Peers = filtered
if anyOld {
filtered := make([]*tailcfg.Node, 0, numDisco)
for _, p := range nm.Peers {
if !p.DiscoKey.IsZero() {
filtered = append(filtered, p)
}
}
nm.Peers = filtered
}
}
if Debug.StripEndpoints {
for _, p := range resp.Peers {
@@ -784,13 +833,8 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
p.Endpoints = []string{"127.9.9.9:456"}
}
}
if pf := resp.PacketFilter; pf != nil {
lastParsedPacketFilter = c.parsePacketFilter(pf)
}
if v, ok := resp.CollectServices.Get(); ok {
collectServices = v
if Debug.StripCaps {
nm.SelfNode.Capabilities = nil
}
// Get latest localPort. This might've changed if
@@ -798,67 +842,9 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, cb func(*netm
// the end-to-end test.
// TODO(bradfitz): remove the NetworkMap.LocalPort field entirely.
c.mu.Lock()
localPort = c.localPort
nm.LocalPort = c.localPort
c.mu.Unlock()
nm := &netmap.NetworkMap{
SelfNode: resp.Node,
NodeKey: tailcfg.NodeKey(persist.PrivateNodeKey.Public()),
PrivateKey: persist.PrivateNodeKey,
MachineKey: machinePubKey,
Expiry: resp.Node.KeyExpiry,
Name: resp.Node.Name,
Addresses: resp.Node.Addresses,
Peers: resp.Peers,
LocalPort: localPort,
User: resp.Node.User,
UserProfiles: make(map[tailcfg.UserID]tailcfg.UserProfile),
Domain: resp.Domain,
DNS: resp.DNSConfig,
Hostinfo: resp.Node.Hostinfo,
PacketFilter: lastParsedPacketFilter,
CollectServices: collectServices,
DERPMap: lastDERPMap,
Debug: resp.Debug,
}
addUserProfile := func(userID tailcfg.UserID) {
if _, dup := nm.UserProfiles[userID]; dup {
// Already populated it from a previous peer.
return
}
if up, ok := lastUserProfile[userID]; ok {
nm.UserProfiles[userID] = up
}
}
addUserProfile(nm.User)
magicDNSSuffix := nm.MagicDNSSuffix()
nm.SelfNode.InitDisplayNames(magicDNSSuffix)
for _, peer := range resp.Peers {
peer.InitDisplayNames(magicDNSSuffix)
if !peer.Sharer.IsZero() {
if c.keepSharerAndUserSplit {
addUserProfile(peer.Sharer)
} else {
peer.User = peer.Sharer
}
}
addUserProfile(peer.User)
}
if resp.Node.MachineAuthorized {
nm.MachineStatus = tailcfg.MachineAuthorized
} else {
nm.MachineStatus = tailcfg.MachineUnauthorized
}
if len(resp.DNS) > 0 {
nm.DNS.Nameservers = resp.DNS
}
if len(resp.SearchPaths) > 0 {
nm.DNS.Domains = resp.SearchPaths
}
if Debug.ProxyDNS {
nm.DNS.Proxied = true
}
// Printing the netmap can be extremely verbose, but is very
// handy for debugging. Let's limit how often we do it.
// Code elsewhere prints netmap diffs every time, so this
@@ -894,7 +880,10 @@ func decode(res *http.Response, v interface{}, serverKey *wgkey.Key, mkey *wgkey
return decodeMsg(msg, v, serverKey, mkey)
}
var debugMap, _ = strconv.ParseBool(os.Getenv("TS_DEBUG_MAP"))
var (
debugMap, _ = strconv.ParseBool(os.Getenv("TS_DEBUG_MAP"))
debugRegister, _ = strconv.ParseBool(os.Getenv("TS_DEBUG_REGISTER"))
)
var jsonEscapedZero = []byte(`\u0000`)
@@ -1021,6 +1010,7 @@ type debug struct {
OnlyDisco bool
Disco bool
StripEndpoints bool // strip endpoints from control (only use disco messages)
StripCaps bool // strip all local node's control-provided capabilities
}
func initDebug() debug {
@@ -1029,6 +1019,7 @@ func initDebug() debug {
NetMap: envBool("TS_DEBUG_NETMAP"),
ProxyDNS: envBool("TS_DEBUG_PROXY_DNS"),
StripEndpoints: envBool("TS_DEBUG_STRIP_ENDPOINTS"),
StripCaps: envBool("TS_DEBUG_STRIP_CAPS"),
OnlyDisco: use == "only",
Disco: use == "only" || use == "" || envBool("TS_DEBUG_USE_DISCO"),
}
@@ -1046,117 +1037,7 @@ func envBool(k string) bool {
return v
}
// undeltaPeers updates mapRes.Peers to be complete based on the provided previous peer list
// and the PeersRemoved and PeersChanged fields in mapRes.
// It then also nils out the delta fields.
func undeltaPeers(mapRes *tailcfg.MapResponse, prev []*tailcfg.Node) {
if len(mapRes.Peers) > 0 {
// Not delta encoded.
if !nodesSorted(mapRes.Peers) {
log.Printf("netmap: undeltaPeers: MapResponse.Peers not sorted; sorting")
sortNodes(mapRes.Peers)
}
return
}
var removed map[tailcfg.NodeID]bool
if pr := mapRes.PeersRemoved; len(pr) > 0 {
removed = make(map[tailcfg.NodeID]bool, len(pr))
for _, id := range pr {
removed[id] = true
}
}
changed := mapRes.PeersChanged
if len(removed) == 0 && len(changed) == 0 {
// No changes fast path.
mapRes.Peers = prev
return
}
if !nodesSorted(changed) {
log.Printf("netmap: undeltaPeers: MapResponse.PeersChanged not sorted; sorting")
sortNodes(changed)
}
if !nodesSorted(prev) {
// Internal error (unrelated to the network) if we get here.
log.Printf("netmap: undeltaPeers: [unexpected] prev not sorted; sorting")
sortNodes(prev)
}
newFull := make([]*tailcfg.Node, 0, len(prev)-len(removed))
for len(prev) > 0 && len(changed) > 0 {
pID := prev[0].ID
cID := changed[0].ID
if removed[pID] {
prev = prev[1:]
continue
}
switch {
case pID < cID:
newFull = append(newFull, prev[0])
prev = prev[1:]
case pID == cID:
newFull = append(newFull, changed[0])
prev, changed = prev[1:], changed[1:]
case cID < pID:
newFull = append(newFull, changed[0])
changed = changed[1:]
}
}
newFull = append(newFull, changed...)
for _, n := range prev {
if !removed[n.ID] {
newFull = append(newFull, n)
}
}
sortNodes(newFull)
if mapRes.PeerSeenChange != nil {
peerByID := make(map[tailcfg.NodeID]*tailcfg.Node, len(newFull))
for _, n := range newFull {
peerByID[n.ID] = n
}
now := time.Now()
for nodeID, seen := range mapRes.PeerSeenChange {
if n, ok := peerByID[nodeID]; ok {
if seen {
n.LastSeen = &now
} else {
n.LastSeen = nil
}
}
}
}
mapRes.Peers = newFull
mapRes.PeersChanged = nil
mapRes.PeersRemoved = nil
}
func nodesSorted(v []*tailcfg.Node) bool {
for i, n := range v {
if i > 0 && n.ID <= v[i-1].ID {
return false
}
}
return true
}
func sortNodes(v []*tailcfg.Node) {
sort.Slice(v, func(i, j int) bool { return v[i].ID < v[j].ID })
}
func cloneNodes(v1 []*tailcfg.Node) []*tailcfg.Node {
if v1 == nil {
return nil
}
v2 := make([]*tailcfg.Node, len(v1))
for i, n := range v1 {
v2[i] = n.Clone()
}
return v2
}
var clockNow = time.Now
// opt.Bool configs from control.
var (

View File

@@ -6,94 +6,13 @@ package controlclient
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"inet.af/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/wgkey"
)
func TestUndeltaPeers(t *testing.T) {
n := func(id tailcfg.NodeID, name string) *tailcfg.Node {
return &tailcfg.Node{ID: id, Name: name}
}
peers := func(nv ...*tailcfg.Node) []*tailcfg.Node { return nv }
tests := []struct {
name string
mapRes *tailcfg.MapResponse
prev []*tailcfg.Node
want []*tailcfg.Node
}{
{
name: "full_peers",
mapRes: &tailcfg.MapResponse{
Peers: peers(n(1, "foo"), n(2, "bar")),
},
want: peers(n(1, "foo"), n(2, "bar")),
},
{
name: "full_peers_ignores_deltas",
mapRes: &tailcfg.MapResponse{
Peers: peers(n(1, "foo"), n(2, "bar")),
PeersRemoved: []tailcfg.NodeID{2},
},
want: peers(n(1, "foo"), n(2, "bar")),
},
{
name: "add_and_update",
prev: peers(n(1, "foo"), n(2, "bar")),
mapRes: &tailcfg.MapResponse{
PeersChanged: peers(n(0, "zero"), n(2, "bar2"), n(3, "three")),
},
want: peers(n(0, "zero"), n(1, "foo"), n(2, "bar2"), n(3, "three")),
},
{
name: "remove",
prev: peers(n(1, "foo"), n(2, "bar")),
mapRes: &tailcfg.MapResponse{
PeersRemoved: []tailcfg.NodeID{1},
},
want: peers(n(2, "bar")),
},
{
name: "add_and_remove",
prev: peers(n(1, "foo"), n(2, "bar")),
mapRes: &tailcfg.MapResponse{
PeersChanged: peers(n(1, "foo2")),
PeersRemoved: []tailcfg.NodeID{2},
},
want: peers(n(1, "foo2")),
},
{
name: "unchanged",
prev: peers(n(1, "foo"), n(2, "bar")),
mapRes: &tailcfg.MapResponse{},
want: peers(n(1, "foo"), n(2, "bar")),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
undeltaPeers(tt.mapRes, tt.prev)
if !reflect.DeepEqual(tt.mapRes.Peers, tt.want) {
t.Errorf("wrong results\n got: %s\nwant: %s", formatNodes(tt.mapRes.Peers), formatNodes(tt.want))
}
})
}
}
func formatNodes(nodes []*tailcfg.Node) string {
var sb strings.Builder
for i, n := range nodes {
if i > 0 {
sb.WriteString(", ")
}
fmt.Fprintf(&sb, "(%d, %q)", n.ID, n.Name)
}
return sb.String()
}
func TestNewDirect(t *testing.T) {
hi := NewHostinfo()
ni := tailcfg.NetInfo{LinkType: "wired"}
@@ -144,7 +63,7 @@ func TestNewDirect(t *testing.T) {
t.Errorf("c.SetHostinfo(hi) want true got %v", changed)
}
endpoints := []string{"1", "2", "3"}
endpoints := fakeEndpoints(1, 2, 3)
changed = c.newEndpoints(12, endpoints)
if !changed {
t.Errorf("c.newEndpoints(12) want true got %v", changed)
@@ -157,13 +76,22 @@ func TestNewDirect(t *testing.T) {
if !changed {
t.Errorf("c.newEndpoints(13) want true got %v", changed)
}
endpoints = []string{"4", "5", "6"}
endpoints = fakeEndpoints(4, 5, 6)
changed = c.newEndpoints(13, endpoints)
if !changed {
t.Errorf("c.newEndpoints(13) want true got %v", changed)
}
}
func fakeEndpoints(ports ...uint16) (ret []tailcfg.Endpoint) {
for _, port := range ports {
ret = append(ret, tailcfg.Endpoint{
Addr: netaddr.IPPort{Port: port},
})
}
return
}
func TestNewHostinfo(t *testing.T) {
hi := NewHostinfo()
if hi == nil {

View File

@@ -1,20 +0,0 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package controlclient
import (
"tailscale.com/tailcfg"
"tailscale.com/wgengine/filter"
)
// Parse a backward-compatible FilterRule used by control's wire
// format, producing the most current filter format.
func (c *Direct) parsePacketFilter(pf []tailcfg.FilterRule) []filter.Match {
mm, err := filter.MatchesFromFilterRules(pf)
if err != nil {
c.logf("parsePacketFilter: %s\n", err)
}
return mm
}

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"io/ioutil"
"os"
"strings"
"syscall"
@@ -58,6 +59,9 @@ func osVersionLinux() string {
if inContainer() {
attrBuf.WriteString("; container")
}
if inKnative() {
attrBuf.WriteString("; env=kn")
}
attr := attrBuf.String()
id := m["ID"]
@@ -99,5 +103,21 @@ func inContainer() (ret bool) {
}
return nil
})
lineread.File("/proc/mounts", func(line []byte) error {
if mem.Contains(mem.B(line), mem.S("fuse.lxcfs")) {
ret = true
return io.EOF
}
return nil
})
return
}
func inKnative() bool {
// https://cloud.google.com/run/docs/reference/container-contract#env-vars
if os.Getenv("K_REVISION") != "" && os.Getenv("K_CONFIGURATION") != "" &&
os.Getenv("K_SERVICE") != "" && os.Getenv("PORT") != "" {
return true
}
return false
}

View File

@@ -0,0 +1,282 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package controlclient
import (
"log"
"sort"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/wgkey"
"tailscale.com/wgengine/filter"
)
// mapSession holds the state over a long-polled "map" request to the
// control plane.
//
// It accepts incremental tailcfg.MapResponse values to
// netMapForResponse and returns fully inflated NetworkMaps, filling
// in the omitted data implicit from prior MapResponse values from
// within the same session (the same long-poll HTTP response to the
// one MapRequest).
type mapSession struct {
// Immutable fields.
privateNodeKey wgkey.Private
logf logger.Logf
vlogf logger.Logf
machinePubKey tailcfg.MachineKey
keepSharerAndUserSplit bool // see Options.KeepSharerAndUserSplit
// Fields storing state over the the coards of multiple MapResponses.
lastNode *tailcfg.Node
lastDNSConfig *tailcfg.DNSConfig
lastDERPMap *tailcfg.DERPMap
lastUserProfile map[tailcfg.UserID]tailcfg.UserProfile
lastParsedPacketFilter []filter.Match
collectServices bool
previousPeers []*tailcfg.Node // for delta-purposes
lastDomain string
// netMapBuilding is non-nil during a netmapForResponse call,
// containing the value to be returned, once fully populated.
netMapBuilding *netmap.NetworkMap
}
func newMapSession(privateNodeKey wgkey.Private) *mapSession {
ms := &mapSession{
privateNodeKey: privateNodeKey,
logf: logger.Discard,
vlogf: logger.Discard,
lastDNSConfig: new(tailcfg.DNSConfig),
lastUserProfile: map[tailcfg.UserID]tailcfg.UserProfile{},
}
return ms
}
func (ms *mapSession) addUserProfile(userID tailcfg.UserID) {
nm := ms.netMapBuilding
if _, dup := nm.UserProfiles[userID]; dup {
// Already populated it from a previous peer.
return
}
if up, ok := ms.lastUserProfile[userID]; ok {
nm.UserProfiles[userID] = up
}
}
// netmapForResponse returns a fully populated NetworkMap from a full
// or incremental MapResponse within the session, filling in omitted
// information from prior MapResponse values.
func (ms *mapSession) netmapForResponse(resp *tailcfg.MapResponse) *netmap.NetworkMap {
undeltaPeers(resp, ms.previousPeers)
ms.previousPeers = cloneNodes(resp.Peers) // defensive/lazy clone, since this escapes to who knows where
for _, up := range resp.UserProfiles {
ms.lastUserProfile[up.ID] = up
}
if resp.DERPMap != nil {
ms.vlogf("netmap: new map contains DERP map")
ms.lastDERPMap = resp.DERPMap
}
if pf := resp.PacketFilter; pf != nil {
var err error
ms.lastParsedPacketFilter, err = filter.MatchesFromFilterRules(pf)
if err != nil {
ms.logf("parsePacketFilter: %v", err)
}
}
if c := resp.DNSConfig; c != nil {
ms.lastDNSConfig = c
}
if v, ok := resp.CollectServices.Get(); ok {
ms.collectServices = v
}
if resp.Domain != "" {
ms.lastDomain = resp.Domain
}
nm := &netmap.NetworkMap{
NodeKey: tailcfg.NodeKey(ms.privateNodeKey.Public()),
PrivateKey: ms.privateNodeKey,
MachineKey: ms.machinePubKey,
Peers: resp.Peers,
UserProfiles: make(map[tailcfg.UserID]tailcfg.UserProfile),
Domain: ms.lastDomain,
DNS: *ms.lastDNSConfig,
PacketFilter: ms.lastParsedPacketFilter,
CollectServices: ms.collectServices,
DERPMap: ms.lastDERPMap,
Debug: resp.Debug,
}
ms.netMapBuilding = nm
if resp.Node != nil {
ms.lastNode = resp.Node
}
if node := ms.lastNode.Clone(); node != nil {
nm.SelfNode = node
nm.Expiry = node.KeyExpiry
nm.Name = node.Name
nm.Addresses = node.Addresses
nm.User = node.User
nm.Hostinfo = node.Hostinfo
if node.MachineAuthorized {
nm.MachineStatus = tailcfg.MachineAuthorized
} else {
nm.MachineStatus = tailcfg.MachineUnauthorized
}
}
ms.addUserProfile(nm.User)
magicDNSSuffix := nm.MagicDNSSuffix()
if nm.SelfNode != nil {
nm.SelfNode.InitDisplayNames(magicDNSSuffix)
}
for _, peer := range resp.Peers {
peer.InitDisplayNames(magicDNSSuffix)
if !peer.Sharer.IsZero() {
if ms.keepSharerAndUserSplit {
ms.addUserProfile(peer.Sharer)
} else {
peer.User = peer.Sharer
}
}
ms.addUserProfile(peer.User)
}
if len(resp.DNS) > 0 {
nm.DNS.Nameservers = resp.DNS
}
if len(resp.SearchPaths) > 0 {
nm.DNS.Domains = resp.SearchPaths
}
if Debug.ProxyDNS {
nm.DNS.Proxied = true
}
ms.netMapBuilding = nil
return nm
}
// undeltaPeers updates mapRes.Peers to be complete based on the
// provided previous peer list and the PeersRemoved and PeersChanged
// fields in mapRes, as well as the PeerSeenChange and OnlineChange
// maps.
//
// It then also nils out the delta fields.
func undeltaPeers(mapRes *tailcfg.MapResponse, prev []*tailcfg.Node) {
if len(mapRes.Peers) > 0 {
// Not delta encoded.
if !nodesSorted(mapRes.Peers) {
log.Printf("netmap: undeltaPeers: MapResponse.Peers not sorted; sorting")
sortNodes(mapRes.Peers)
}
return
}
var removed map[tailcfg.NodeID]bool
if pr := mapRes.PeersRemoved; len(pr) > 0 {
removed = make(map[tailcfg.NodeID]bool, len(pr))
for _, id := range pr {
removed[id] = true
}
}
changed := mapRes.PeersChanged
if !nodesSorted(changed) {
log.Printf("netmap: undeltaPeers: MapResponse.PeersChanged not sorted; sorting")
sortNodes(changed)
}
if !nodesSorted(prev) {
// Internal error (unrelated to the network) if we get here.
log.Printf("netmap: undeltaPeers: [unexpected] prev not sorted; sorting")
sortNodes(prev)
}
newFull := prev
if len(removed) > 0 || len(changed) > 0 {
newFull = make([]*tailcfg.Node, 0, len(prev)-len(removed))
for len(prev) > 0 && len(changed) > 0 {
pID := prev[0].ID
cID := changed[0].ID
if removed[pID] {
prev = prev[1:]
continue
}
switch {
case pID < cID:
newFull = append(newFull, prev[0])
prev = prev[1:]
case pID == cID:
newFull = append(newFull, changed[0])
prev, changed = prev[1:], changed[1:]
case cID < pID:
newFull = append(newFull, changed[0])
changed = changed[1:]
}
}
newFull = append(newFull, changed...)
for _, n := range prev {
if !removed[n.ID] {
newFull = append(newFull, n)
}
}
sortNodes(newFull)
}
if len(mapRes.PeerSeenChange) != 0 || len(mapRes.OnlineChange) != 0 {
peerByID := make(map[tailcfg.NodeID]*tailcfg.Node, len(newFull))
for _, n := range newFull {
peerByID[n.ID] = n
}
now := clockNow()
for nodeID, seen := range mapRes.PeerSeenChange {
if n, ok := peerByID[nodeID]; ok {
if seen {
n.LastSeen = &now
} else {
n.LastSeen = nil
}
}
}
for nodeID, online := range mapRes.OnlineChange {
if n, ok := peerByID[nodeID]; ok {
online := online
n.Online = &online
}
}
}
mapRes.Peers = newFull
mapRes.PeersChanged = nil
mapRes.PeersRemoved = nil
}
func nodesSorted(v []*tailcfg.Node) bool {
for i, n := range v {
if i > 0 && n.ID <= v[i-1].ID {
return false
}
}
return true
}
func sortNodes(v []*tailcfg.Node) {
sort.Slice(v, func(i, j int) bool { return v[i].ID < v[j].ID })
}
func cloneNodes(v1 []*tailcfg.Node) []*tailcfg.Node {
if v1 == nil {
return nil
}
v2 := make([]*tailcfg.Node, len(v1))
for i, n := range v1 {
v2[i] = n.Clone()
}
return v2
}

View File

@@ -0,0 +1,311 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package controlclient
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"time"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
"tailscale.com/types/wgkey"
)
func TestUndeltaPeers(t *testing.T) {
defer func(old func() time.Time) { clockNow = old }(clockNow)
var curTime time.Time
clockNow = func() time.Time {
return curTime
}
online := func(v bool) func(*tailcfg.Node) {
return func(n *tailcfg.Node) {
n.Online = &v
}
}
seenAt := func(t time.Time) func(*tailcfg.Node) {
return func(n *tailcfg.Node) {
n.LastSeen = &t
}
}
n := func(id tailcfg.NodeID, name string, mod ...func(*tailcfg.Node)) *tailcfg.Node {
n := &tailcfg.Node{ID: id, Name: name}
for _, f := range mod {
f(n)
}
return n
}
peers := func(nv ...*tailcfg.Node) []*tailcfg.Node { return nv }
tests := []struct {
name string
mapRes *tailcfg.MapResponse
curTime time.Time
prev []*tailcfg.Node
want []*tailcfg.Node
}{
{
name: "full_peers",
mapRes: &tailcfg.MapResponse{
Peers: peers(n(1, "foo"), n(2, "bar")),
},
want: peers(n(1, "foo"), n(2, "bar")),
},
{
name: "full_peers_ignores_deltas",
mapRes: &tailcfg.MapResponse{
Peers: peers(n(1, "foo"), n(2, "bar")),
PeersRemoved: []tailcfg.NodeID{2},
},
want: peers(n(1, "foo"), n(2, "bar")),
},
{
name: "add_and_update",
prev: peers(n(1, "foo"), n(2, "bar")),
mapRes: &tailcfg.MapResponse{
PeersChanged: peers(n(0, "zero"), n(2, "bar2"), n(3, "three")),
},
want: peers(n(0, "zero"), n(1, "foo"), n(2, "bar2"), n(3, "three")),
},
{
name: "remove",
prev: peers(n(1, "foo"), n(2, "bar")),
mapRes: &tailcfg.MapResponse{
PeersRemoved: []tailcfg.NodeID{1},
},
want: peers(n(2, "bar")),
},
{
name: "add_and_remove",
prev: peers(n(1, "foo"), n(2, "bar")),
mapRes: &tailcfg.MapResponse{
PeersChanged: peers(n(1, "foo2")),
PeersRemoved: []tailcfg.NodeID{2},
},
want: peers(n(1, "foo2")),
},
{
name: "unchanged",
prev: peers(n(1, "foo"), n(2, "bar")),
mapRes: &tailcfg.MapResponse{},
want: peers(n(1, "foo"), n(2, "bar")),
},
{
name: "online_change",
prev: peers(n(1, "foo"), n(2, "bar")),
mapRes: &tailcfg.MapResponse{
OnlineChange: map[tailcfg.NodeID]bool{
1: true,
},
},
want: peers(
n(1, "foo", online(true)),
n(2, "bar"),
),
},
{
name: "online_change_offline",
prev: peers(n(1, "foo"), n(2, "bar")),
mapRes: &tailcfg.MapResponse{
OnlineChange: map[tailcfg.NodeID]bool{
1: false,
2: true,
},
},
want: peers(
n(1, "foo", online(false)),
n(2, "bar", online(true)),
),
},
{
name: "peer_seen_at",
prev: peers(n(1, "foo", seenAt(time.Unix(111, 0))), n(2, "bar")),
curTime: time.Unix(123, 0),
mapRes: &tailcfg.MapResponse{
PeerSeenChange: map[tailcfg.NodeID]bool{
1: false,
2: true,
},
},
want: peers(
n(1, "foo"),
n(2, "bar", seenAt(time.Unix(123, 0))),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !tt.curTime.IsZero() {
curTime = tt.curTime
}
undeltaPeers(tt.mapRes, tt.prev)
if !reflect.DeepEqual(tt.mapRes.Peers, tt.want) {
t.Errorf("wrong results\n got: %s\nwant: %s", formatNodes(tt.mapRes.Peers), formatNodes(tt.want))
}
})
}
}
func formatNodes(nodes []*tailcfg.Node) string {
var sb strings.Builder
for i, n := range nodes {
if i > 0 {
sb.WriteString(", ")
}
var extra string
if n.Online != nil {
extra += fmt.Sprintf(", online=%v", *n.Online)
}
if n.LastSeen != nil {
extra += fmt.Sprintf(", lastSeen=%v", n.LastSeen.Unix())
}
fmt.Fprintf(&sb, "(%d, %q%s)", n.ID, n.Name, extra)
}
return sb.String()
}
func newTestMapSession(t *testing.T) *mapSession {
k, err := wgkey.NewPrivate()
if err != nil {
t.Fatal(err)
}
return newMapSession(k)
}
func TestNetmapForResponse(t *testing.T) {
t.Run("implicit_packetfilter", func(t *testing.T) {
somePacketFilter := []tailcfg.FilterRule{
{
SrcIPs: []string{"*"},
DstPorts: []tailcfg.NetPortRange{
{IP: "10.2.3.4", Ports: tailcfg.PortRange{First: 22, Last: 22}},
},
},
}
ms := newTestMapSession(t)
nm1 := ms.netmapForResponse(&tailcfg.MapResponse{
Node: new(tailcfg.Node),
PacketFilter: somePacketFilter,
})
if len(nm1.PacketFilter) == 0 {
t.Fatalf("zero length PacketFilter")
}
nm2 := ms.netmapForResponse(&tailcfg.MapResponse{
Node: new(tailcfg.Node),
PacketFilter: nil, // testing that the server can omit this.
})
if len(nm1.PacketFilter) == 0 {
t.Fatalf("zero length PacketFilter in 2nd netmap")
}
if !reflect.DeepEqual(nm1.PacketFilter, nm2.PacketFilter) {
t.Error("packet filters differ")
}
})
t.Run("implicit_dnsconfig", func(t *testing.T) {
someDNSConfig := &tailcfg.DNSConfig{Domains: []string{"foo", "bar"}}
ms := newTestMapSession(t)
nm1 := ms.netmapForResponse(&tailcfg.MapResponse{
Node: new(tailcfg.Node),
DNSConfig: someDNSConfig,
})
if !reflect.DeepEqual(nm1.DNS, *someDNSConfig) {
t.Fatalf("1st DNS wrong")
}
nm2 := ms.netmapForResponse(&tailcfg.MapResponse{
Node: new(tailcfg.Node),
DNSConfig: nil, // implict
})
if !reflect.DeepEqual(nm2.DNS, *someDNSConfig) {
t.Fatalf("2nd DNS wrong")
}
})
t.Run("collect_services", func(t *testing.T) {
ms := newTestMapSession(t)
var nm *netmap.NetworkMap
wantCollect := func(v bool) {
t.Helper()
if nm.CollectServices != v {
t.Errorf("netmap.CollectServices = %v; want %v", nm.CollectServices, v)
}
}
nm = ms.netmapForResponse(&tailcfg.MapResponse{
Node: new(tailcfg.Node),
})
wantCollect(false)
nm = ms.netmapForResponse(&tailcfg.MapResponse{
Node: new(tailcfg.Node),
CollectServices: "false",
})
wantCollect(false)
nm = ms.netmapForResponse(&tailcfg.MapResponse{
Node: new(tailcfg.Node),
CollectServices: "true",
})
wantCollect(true)
nm = ms.netmapForResponse(&tailcfg.MapResponse{
Node: new(tailcfg.Node),
CollectServices: "",
})
wantCollect(true)
})
t.Run("implicit_domain", func(t *testing.T) {
ms := newTestMapSession(t)
var nm *netmap.NetworkMap
want := func(v string) {
t.Helper()
if nm.Domain != v {
t.Errorf("netmap.Domain = %q; want %q", nm.Domain, v)
}
}
nm = ms.netmapForResponse(&tailcfg.MapResponse{
Node: new(tailcfg.Node),
Domain: "foo.com",
})
want("foo.com")
nm = ms.netmapForResponse(&tailcfg.MapResponse{
Node: new(tailcfg.Node),
})
want("foo.com")
})
t.Run("implicit_node", func(t *testing.T) {
someNode := &tailcfg.Node{
Name: "foo",
}
wantNode := &tailcfg.Node{
Name: "foo",
ComputedName: "foo",
ComputedNameWithHost: "foo",
}
ms := newTestMapSession(t)
nm1 := ms.netmapForResponse(&tailcfg.MapResponse{
Node: someNode,
})
if nm1.SelfNode == nil {
t.Fatal("nil Node in 1st netmap")
}
if !reflect.DeepEqual(nm1.SelfNode, wantNode) {
j, _ := json.Marshal(nm1.SelfNode)
t.Errorf("Node mismatch in 1st netmap; got: %s", j)
}
nm2 := ms.netmapForResponse(&tailcfg.MapResponse{})
if nm2.SelfNode == nil {
t.Fatal("nil Node in 1st netmap")
}
if !reflect.DeepEqual(nm2.SelfNode, wantNode) {
j, _ := json.Marshal(nm2.SelfNode)
t.Errorf("Node mismatch in 2nd netmap; got: %s", j)
}
})
}

View File

@@ -51,6 +51,46 @@ var (
errBadRequest = errors.New("malformed request")
)
func isSupportedCertificate(cert *x509.Certificate) bool {
return cert.PublicKeyAlgorithm == x509.RSA
}
func isSubjectInChain(subject string, chain []*x509.Certificate) bool {
if len(chain) == 0 || chain[0] == nil {
return false
}
for _, c := range chain {
if c == nil {
continue
}
if c.Subject.String() == subject {
return true
}
}
return false
}
func selectIdentityFromSlice(subject string, ids []certstore.Identity) (certstore.Identity, []*x509.Certificate) {
for _, id := range ids {
chain, err := id.CertificateChain()
if err != nil {
continue
}
if !isSupportedCertificate(chain[0]) {
continue
}
if isSubjectInChain(subject, chain) {
return id, chain
}
}
return nil, nil
}
// findIdentity locates an identity from the Windows or Darwin certificate
// store. It returns the first certificate with a matching Subject anywhere in
// its certificate chain, so it is possible to search for the leaf certificate,
@@ -64,26 +104,7 @@ func findIdentity(subject string, st certstore.Store) (certstore.Identity, []*x5
return nil, nil, err
}
var selected certstore.Identity
var chain []*x509.Certificate
for _, id := range ids {
chain, err = id.CertificateChain()
if err != nil {
continue
}
if chain[0].PublicKeyAlgorithm != x509.RSA {
continue
}
for _, c := range chain {
if c.Subject.String() == subject {
selected = id
break
}
}
}
selected, chain := selectIdentityFromSlice(subject, ids)
for _, id := range ids {
if id != selected {

View File

@@ -409,7 +409,7 @@ func TestSendFreeze(t *testing.T) {
for i := 0; i < cap(errCh); i++ {
err := <-errCh
if err != nil {
if errors.Is(err, io.EOF) {
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
continue
}
t.Error(err)

18
disco/disco_fuzzer.go Normal file
View File

@@ -0,0 +1,18 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build gofuzz
package disco
func Fuzz(data []byte) int {
m, _ := Parse(data)
newBytes := m.AppendMarshal(data)
parsedMarshall, _ := Parse(newBytes)
if m != parsedMarshall {
panic("Parsing error")
}
return 1
}

2
go.mod
View File

@@ -24,7 +24,7 @@ require (
github.com/peterbourgon/ff/v2 v2.0.0
github.com/pkg/errors v0.9.1 // indirect
github.com/tailscale/depaware v0.0.0-20201214215404-77d1e9757027
github.com/tailscale/wireguard-go v0.0.0-20210403171604-17614717a9b5
github.com/tailscale/wireguard-go v0.0.0-20210419202603-b32acd8f0292
github.com/tcnksm/go-httpstat v0.2.0
github.com/toqueteos/webbrowser v1.2.0
go4.org/mem v0.0.0-20201119185036-c04c5a6ff174

2
go.sum
View File

@@ -139,6 +139,8 @@ github.com/tailscale/wireguard-go v0.0.0-20210402193818-fc309421dd43 h1:SRUknVD6
github.com/tailscale/wireguard-go v0.0.0-20210402193818-fc309421dd43/go.mod h1:g3WdWX37upLnDT8STKFWhvA34Gwrt4hIpnWR3HGufpM=
github.com/tailscale/wireguard-go v0.0.0-20210403171604-17614717a9b5 h1:FegsXWjtyhCxpB8bBSL1kLzagtV+e7BaX07phMM8uQM=
github.com/tailscale/wireguard-go v0.0.0-20210403171604-17614717a9b5/go.mod h1:ys4yUmhKncXy1jWP34qUHKipRjl322VVhxoh1Rkfo7c=
github.com/tailscale/wireguard-go v0.0.0-20210419202603-b32acd8f0292 h1:rKgYi0k3TNqEz5f7sc6zNeufZcnxm1Efd6bb39cGGkY=
github.com/tailscale/wireguard-go v0.0.0-20210419202603-b32acd8f0292/go.mod h1:ys4yUmhKncXy1jWP34qUHKipRjl322VVhxoh1Rkfo7c=
github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0=
github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8=
github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ=

View File

@@ -5,7 +5,6 @@
package ipn
import (
"net/http"
"time"
"tailscale.com/ipn/ipnstate"
@@ -68,8 +67,18 @@ type Notify struct {
BackendLogID *string // public logtail id used by backend
PingResult *ipnstate.PingResult
// FilesWaiting if non-nil means that files are buffered in
// the Tailscale daemon and ready for local transfer to the
// user's preferred storage location.
FilesWaiting *empty.Message `json:",omitempty"`
// IncomingFiles, if non-nil, specifies which files are in the
// process of being received. A nil IncomingFiles means this
// Notify should not update the state of file transfers. A non-nil
// but empty IncomingFiles means that no files are in the middle
// of being transferred.
IncomingFiles []PartialFile `json:",omitempty"`
// LocalTCPPort, if non-nil, informs the UI frontend which
// (non-zero) localhost TCP port it's listening on.
// This is currently only used by Tailscale when run in the
@@ -79,6 +88,24 @@ type Notify struct {
// type is mirrored in xcode/Shared/IPN.swift
}
// PartialFile represents an in-progress file transfer.
type PartialFile struct {
Name string // e.g. "foo.jpg"
Started time.Time // time transfer started
DeclaredSize int64 // or -1 if unknown
Received int64 // bytes copied thus far
// PartialPath is set non-empty in "direct" file mode to the
// in-progress '*.partial' file's path when the peerapi isn't
// being used; see LocalBackend.SetDirectFileRoot.
PartialPath string `json:",omitempty"`
// Done is set in "direct" mode when the partial file has been
// closed and is ready for the caller to rename away the
// ".partial" suffix.
Done bool `json:",omitempty"`
}
// StateKey is an opaque identifier for a set of LocalBackend state
// (preferences, private keys, etc.).
//
@@ -114,19 +141,6 @@ type Options struct {
// AuthKey is an optional node auth key used to authorize a
// new node key without user interaction.
AuthKey string
// LegacyConfigPath optionally specifies the old-style relaynode
// relay.conf location. If both LegacyConfigPath and StateKey are
// specified and the requested state doesn't exist in the backend
// store, the backend migrates the config from LegacyConfigPath.
//
// TODO(danderson): remove some time after the transition to
// tailscaled is done.
LegacyConfigPath string
// Notify is called when backend events happen.
Notify func(Notify) `json:"-"`
// HTTPTestClient is an optional HTTP client to pass to controlclient
// (for tests only).
HTTPTestClient *http.Client
}
// Backend is the interface between Tailscale frontends
@@ -135,6 +149,9 @@ type Options struct {
// (It has nothing to do with the interface between the backends
// and the cloud control plane.)
type Backend interface {
// SetNotifyCallback sets the callback to be called on updates
// from the backend to the client.
SetNotifyCallback(func(Notify))
// Start starts or restarts the backend, typically when a
// frontend client connects.
Start(Options) error
@@ -151,8 +168,6 @@ type Backend interface {
// WantRunning. This may cause the wireguard engine to
// reconfigure or stop.
SetPrefs(*Prefs)
// EditPrefs is like SetPrefs but only sets the specified fields.
EditPrefs(*MaskedPrefs)
// RequestEngineStatus polls for an update from the wireguard
// engine. Only needed if you want to display byte
// counts. Connection events are emitted automatically without

View File

@@ -5,7 +5,6 @@
package ipn
import (
"log"
"time"
"tailscale.com/ipn/ipnstate"
@@ -20,19 +19,29 @@ type FakeBackend struct {
}
func (b *FakeBackend) Start(opts Options) error {
b.serverURL = opts.Prefs.ControlURL
if opts.Notify == nil {
log.Fatalf("FakeBackend.Start: opts.Notify is nil\n")
b.serverURL = opts.Prefs.ControlURLOrDefault()
if b.notify == nil {
panic("FakeBackend.Start: SetNotifyCallback not called")
}
b.notify = opts.Notify
b.notify(Notify{Prefs: opts.Prefs})
nl := NeedsLogin
b.notify(Notify{State: &nl})
if b.notify != nil {
b.notify(Notify{Prefs: opts.Prefs})
b.notify(Notify{State: &nl})
}
return nil
}
func (b *FakeBackend) SetNotifyCallback(notify func(Notify)) {
if notify == nil {
panic("FakeBackend.SetNotifyCallback: notify is nil")
}
b.notify = notify
}
func (b *FakeBackend) newState(s State) {
b.notify(Notify{State: &s})
if b.notify != nil {
b.notify(Notify{State: &s})
}
if s == Running {
b.live = true
} else {
@@ -42,7 +51,9 @@ func (b *FakeBackend) newState(s State) {
func (b *FakeBackend) StartLoginInteractive() {
u := b.serverURL + "/this/is/fake"
b.notify(Notify{BrowseToURL: &u})
if b.notify != nil {
b.notify(Notify{BrowseToURL: &u})
}
b.login()
}
@@ -54,10 +65,14 @@ func (b *FakeBackend) login() {
b.newState(NeedsMachineAuth)
b.newState(Stopped)
// TODO(apenwarr): Fill in a more interesting netmap here.
b.notify(Notify{NetMap: &netmap.NetworkMap{}})
if b.notify != nil {
b.notify(Notify{NetMap: &netmap.NetworkMap{}})
}
b.newState(Starting)
// TODO(apenwarr): Fill in a more interesting status.
b.notify(Notify{Engine: &EngineStatus{}})
if b.notify != nil {
b.notify(Notify{Engine: &EngineStatus{}})
}
b.newState(Running)
}
@@ -70,7 +85,9 @@ func (b *FakeBackend) SetPrefs(new *Prefs) {
panic("FakeBackend.SetPrefs got nil prefs")
}
b.notify(Notify{Prefs: new.Clone()})
if b.notify != nil {
b.notify(Notify{Prefs: new.Clone()})
}
if new.WantRunning && !b.live {
b.newState(Starting)
b.newState(Running)
@@ -79,21 +96,20 @@ func (b *FakeBackend) SetPrefs(new *Prefs) {
}
}
func (b *FakeBackend) EditPrefs(mp *MaskedPrefs) {
// This fake implementation only cares about this one pref.
if mp.WantRunningSet {
b.SetPrefs(&mp.Prefs)
func (b *FakeBackend) RequestEngineStatus() {
if b.notify != nil {
b.notify(Notify{Engine: &EngineStatus{}})
}
}
func (b *FakeBackend) RequestEngineStatus() {
b.notify(Notify{Engine: &EngineStatus{}})
}
func (b *FakeBackend) FakeExpireAfter(x time.Duration) {
b.notify(Notify{NetMap: &netmap.NetworkMap{}})
if b.notify != nil {
b.notify(Notify{NetMap: &netmap.NetworkMap{}})
}
}
func (b *FakeBackend) Ping(ip string, useTSMP bool) {
b.notify(Notify{PingResult: &ipnstate.PingResult{}})
if b.notify != nil {
b.notify(Notify{PingResult: &ipnstate.PingResult{}})
}
}

View File

@@ -15,25 +15,26 @@ import (
)
type Handle struct {
frontendLogID string
b Backend
xnotify func(Notify)
logf logger.Logf
b Backend
logf logger.Logf
// Mutex protects everything below
mu sync.Mutex
xnotify func(Notify)
frontendLogID string
netmapCache *netmap.NetworkMap
engineStatusCache EngineStatus
stateCache State
prefsCache *Prefs
}
func NewHandle(b Backend, logf logger.Logf, opts Options) (*Handle, error) {
func NewHandle(b Backend, logf logger.Logf, notify func(Notify), opts Options) (*Handle, error) {
h := &Handle{
b: b,
logf: logf,
}
h.SetNotifyCallback(notify)
err := h.Start(opts)
if err != nil {
return nil, err
@@ -42,18 +43,25 @@ func NewHandle(b Backend, logf logger.Logf, opts Options) (*Handle, error) {
return h, nil
}
func (h *Handle) SetNotifyCallback(notify func(Notify)) {
h.mu.Lock()
h.xnotify = notify
h.mu.Unlock()
h.b.SetNotifyCallback(h.notify)
}
func (h *Handle) Start(opts Options) error {
h.mu.Lock()
h.frontendLogID = opts.FrontendLogID
h.xnotify = opts.Notify
h.netmapCache = nil
h.engineStatusCache = EngineStatus{}
h.stateCache = NoState
if opts.Prefs != nil {
h.prefsCache = opts.Prefs.Clone()
}
xopts := opts
xopts.Notify = h.notify
return h.b.Start(xopts)
h.mu.Unlock()
return h.b.Start(opts)
}
func (h *Handle) Reset() {
@@ -148,7 +156,7 @@ func (h *Handle) Expiry() time.Time {
}
func (h *Handle) AdminPageURL() string {
return h.prefsCache.ControlURL + "/admin/machines"
return h.prefsCache.ControlURLOrDefault() + "/admin/machines"
}
func (h *Handle) StartLoginInteractive() {

File diff suppressed because it is too large Load Diff

View File

@@ -291,3 +291,131 @@ func TestPeerRoutes(t *testing.T) {
}
}
func TestPeerAPIBase(t *testing.T) {
tests := []struct {
name string
nm *netmap.NetworkMap
peer *tailcfg.Node
want string
}{
{
name: "nil_netmap",
peer: new(tailcfg.Node),
want: "",
},
{
name: "nil_peer",
nm: new(netmap.NetworkMap),
want: "",
},
{
name: "self_only_4_them_both",
nm: &netmap.NetworkMap{
Addresses: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("100.64.1.1/32"),
},
},
peer: &tailcfg.Node{
Addresses: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("100.64.1.2/32"),
netaddr.MustParseIPPrefix("fe70::2/128"),
},
Hostinfo: tailcfg.Hostinfo{
Services: []tailcfg.Service{
{Proto: "peerapi4", Port: 444},
{Proto: "peerapi6", Port: 666},
},
},
},
want: "http://100.64.1.2:444",
},
{
name: "self_only_6_them_both",
nm: &netmap.NetworkMap{
Addresses: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("fe70::1/128"),
},
},
peer: &tailcfg.Node{
Addresses: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("100.64.1.2/32"),
netaddr.MustParseIPPrefix("fe70::2/128"),
},
Hostinfo: tailcfg.Hostinfo{
Services: []tailcfg.Service{
{Proto: "peerapi4", Port: 444},
{Proto: "peerapi6", Port: 666},
},
},
},
want: "http://[fe70::2]:666",
},
{
name: "self_both_them_only_4",
nm: &netmap.NetworkMap{
Addresses: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("100.64.1.1/32"),
netaddr.MustParseIPPrefix("fe70::1/128"),
},
},
peer: &tailcfg.Node{
Addresses: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("100.64.1.2/32"),
netaddr.MustParseIPPrefix("fe70::2/128"),
},
Hostinfo: tailcfg.Hostinfo{
Services: []tailcfg.Service{
{Proto: "peerapi4", Port: 444},
},
},
},
want: "http://100.64.1.2:444",
},
{
name: "self_both_them_only_6",
nm: &netmap.NetworkMap{
Addresses: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("100.64.1.1/32"),
netaddr.MustParseIPPrefix("fe70::1/128"),
},
},
peer: &tailcfg.Node{
Addresses: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("100.64.1.2/32"),
netaddr.MustParseIPPrefix("fe70::2/128"),
},
Hostinfo: tailcfg.Hostinfo{
Services: []tailcfg.Service{
{Proto: "peerapi6", Port: 666},
},
},
},
want: "http://[fe70::2]:666",
},
{
name: "self_both_them_no_peerapi_service",
nm: &netmap.NetworkMap{
Addresses: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("100.64.1.1/32"),
netaddr.MustParseIPPrefix("fe70::1/128"),
},
},
peer: &tailcfg.Node{
Addresses: []netaddr.IPPrefix{
netaddr.MustParseIPPrefix("100.64.1.2/32"),
netaddr.MustParseIPPrefix("fe70::2/128"),
},
},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := peerAPIBase(tt.nm, tt.peer)
if got != tt.want {
t.Errorf("got %q; want %q", got, tt.want)
}
})
}
}

View File

@@ -82,7 +82,6 @@ func TestLocalLogLines(t *testing.T) {
LastHandshake: time.Now(),
NodeKey: tailcfg.NodeKey(key.NewPrivate()),
}},
LocalAddrs: []string{"idk an address"},
}
lb.mu.Lock()
lb.parseWgStatusLocked(status)

View File

@@ -20,8 +20,13 @@ import (
"runtime"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"inet.af/netaddr"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/net/interfaces"
"tailscale.com/syncs"
@@ -37,25 +42,62 @@ type peerAPIServer struct {
tunName string
selfNode *tailcfg.Node
knownEmpty syncs.AtomicBool
// directFileMode is whether we're writing files directly to a
// download directory (as *.partial files), rather than making
// the frontend retrieve it over localapi HTTP and write it
// somewhere itself. This is used on GUI macOS version.
directFileMode bool
}
const partialSuffix = ".tspartial"
const partialSuffix = ".partial"
func validFilenameRune(r rune) bool {
switch r {
case '/':
return false
case '\\', ':', '*', '"', '<', '>', '|':
// Invalid stuff on Windows, but we reject them everywhere
// for now.
// TODO(bradfitz): figure out a better plan. We initially just
// wrote things to disk URL path-escaped, but that's gross
// when debugging, and just moves the problem to callers.
// So now we put the UTF-8 filenames on disk directly as
// sent.
return false
}
return unicode.IsPrint(r)
}
func (s *peerAPIServer) diskPath(baseName string) (fullPath string, ok bool) {
if !utf8.ValidString(baseName) {
return "", false
}
if strings.TrimSpace(baseName) != baseName {
return "", false
}
if len(baseName) > 255 {
return "", false
}
// TODO: validate unicode normalization form too? Varies by platform.
clean := path.Clean(baseName)
if clean != baseName ||
clean == "." ||
strings.ContainsAny(clean, `/\`) ||
clean == "." || clean == ".." ||
strings.HasSuffix(clean, partialSuffix) {
return "", false
}
return filepath.Join(s.rootDir, strings.ReplaceAll(url.PathEscape(baseName), ":", "%3a")), true
for _, r := range baseName {
if !validFilenameRune(r) {
return "", false
}
}
return filepath.Join(s.rootDir, baseName), true
}
// hasFilesWaiting reports whether any files are buffered in the
// tailscaled daemon storage.
func (s *peerAPIServer) hasFilesWaiting() bool {
if s.rootDir == "" {
if s.rootDir == "" || s.directFileMode {
return false
}
if s.knownEmpty.Get() {
@@ -91,17 +133,13 @@ func (s *peerAPIServer) hasFilesWaiting() bool {
return false
}
// WaitingFile is a JSON-marshaled struct sent by the localapi to pick
// up queued files.
type WaitingFile struct {
Name string
Size int64
}
func (s *peerAPIServer) WaitingFiles() (ret []WaitingFile, err error) {
func (s *peerAPIServer) WaitingFiles() (ret []apitype.WaitingFile, err error) {
if s.rootDir == "" {
return nil, errors.New("peerapi disabled; no storage configured")
}
if s.directFileMode {
return nil, nil
}
f, err := os.Open(s.rootDir)
if err != nil {
return nil, err
@@ -119,7 +157,7 @@ func (s *peerAPIServer) WaitingFiles() (ret []WaitingFile, err error) {
if err != nil {
continue
}
ret = append(ret, WaitingFile{
ret = append(ret, apitype.WaitingFile{
Name: filepath.Base(name),
Size: fi.Size(),
})
@@ -139,6 +177,9 @@ func (s *peerAPIServer) DeleteFile(baseName string) error {
if s.rootDir == "" {
return errors.New("peerapi disabled; no storage configured")
}
if s.directFileMode {
return errors.New("deletes not allowed in direct mode")
}
path, ok := s.diskPath(baseName)
if !ok {
return errors.New("bad filename")
@@ -154,6 +195,9 @@ func (s *peerAPIServer) OpenFile(baseName string) (rc io.ReadCloser, size int64,
if s.rootDir == "" {
return nil, 0, errors.New("peerapi disabled; no storage configured")
}
if s.directFileMode {
return nil, 0, errors.New("opens not allowed in direct mode")
}
path, ok := s.diskPath(baseName)
if !ok {
return nil, 0, errors.New("bad filename")
@@ -279,7 +323,6 @@ func (pln *peerAPIListener) serve() {
remoteAddr: ipp,
peerNode: peerNode,
peerUser: peerUser,
lb: pln.lb,
}
httpServer := &http.Server{
Handler: h,
@@ -313,7 +356,6 @@ type peerAPIHandler struct {
isSelf bool // whether peerNode is owned by same user as this node
peerNode *tailcfg.Node // peerNode is who's making the request
peerUser tailcfg.UserProfile // profile of peerNode
lb *LocalBackend
}
func (h *peerAPIHandler) logf(format string, a ...interface{}) {
@@ -322,7 +364,7 @@ func (h *peerAPIHandler) logf(format string, a ...interface{}) {
func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v0/put/") {
h.put(w, r)
h.handlePeerPut(w, r)
return
}
who := h.peerUser.DisplayName
@@ -338,25 +380,108 @@ This is my Tailscale device. Your device is %v.
}
}
func (h *peerAPIHandler) put(w http.ResponseWriter, r *http.Request) {
type incomingFile struct {
name string // "foo.jpg"
started time.Time
size int64 // or -1 if unknown; never 0
w io.Writer // underlying writer
ph *peerAPIHandler
partialPath string // non-empty in direct mode
mu sync.Mutex
copied int64
done bool
lastNotify time.Time
}
func (f *incomingFile) markAndNotifyDone() {
f.mu.Lock()
f.done = true
f.mu.Unlock()
b := f.ph.ps.b
b.sendFileNotify()
}
func (f *incomingFile) Write(p []byte) (n int, err error) {
n, err = f.w.Write(p)
b := f.ph.ps.b
var needNotify bool
defer func() {
if needNotify {
b.sendFileNotify()
}
}()
if n > 0 {
f.mu.Lock()
defer f.mu.Unlock()
f.copied += int64(n)
now := time.Now()
if f.lastNotify.IsZero() || now.Sub(f.lastNotify) > time.Second {
f.lastNotify = now
needNotify = true
}
}
return n, err
}
func (f *incomingFile) PartialFile() ipn.PartialFile {
f.mu.Lock()
defer f.mu.Unlock()
return ipn.PartialFile{
Name: f.name,
Started: f.started,
DeclaredSize: f.size,
Received: f.copied,
PartialPath: f.partialPath,
Done: f.done,
}
}
func (h *peerAPIHandler) handlePeerPut(w http.ResponseWriter, r *http.Request) {
if !h.isSelf {
http.Error(w, "not owner", http.StatusForbidden)
return
}
if !h.ps.b.hasCapFileSharing() {
http.Error(w, "file sharing not enabled by Tailscale admin", http.StatusForbidden)
return
}
if r.Method != "PUT" {
http.Error(w, "not method PUT", http.StatusMethodNotAllowed)
http.Error(w, "expected method PUT", http.StatusMethodNotAllowed)
return
}
if h.ps.rootDir == "" {
http.Error(w, "no rootdir", http.StatusInternalServerError)
return
}
baseName := path.Base(r.URL.Path)
rawPath := r.URL.EscapedPath()
suffix := strings.TrimPrefix(rawPath, "/v0/put/")
if suffix == rawPath {
http.Error(w, "misconfigured internals", 500)
return
}
if suffix == "" {
http.Error(w, "empty filename", 400)
return
}
if strings.Contains(suffix, "/") {
http.Error(w, "directories not supported", 400)
return
}
baseName, err := url.PathUnescape(suffix)
if err != nil {
http.Error(w, "bad path encoding", 400)
return
}
dstFile, ok := h.ps.diskPath(baseName)
if !ok {
http.Error(w, "bad filename", 400)
return
}
if h.ps.directFileMode {
dstFile += partialSuffix
}
f, err := os.Create(dstFile)
if err != nil {
h.logf("put Create error: %v", err)
@@ -369,27 +494,49 @@ func (h *peerAPIHandler) put(w http.ResponseWriter, r *http.Request) {
os.Remove(dstFile)
}
}()
n, err := io.Copy(f, r.Body)
if err != nil {
f.Close()
h.logf("put Copy error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
var finalSize int64
var inFile *incomingFile
if r.ContentLength != 0 {
inFile = &incomingFile{
name: baseName,
started: time.Now(),
size: r.ContentLength,
w: f,
ph: h,
}
if h.ps.directFileMode {
inFile.partialPath = dstFile
}
h.ps.b.registerIncomingFile(inFile, true)
defer h.ps.b.registerIncomingFile(inFile, false)
n, err := io.Copy(inFile, r.Body)
if err != nil {
f.Close()
h.logf("put Copy error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
finalSize = n
}
if err := f.Close(); err != nil {
h.logf("put Close error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if h.ps.directFileMode {
if inFile != nil { // non-zero length; TODO: notify even for zero length
inFile.markAndNotifyDone()
}
}
h.logf("put of %s from %v/%v", baseName, approxSize(n), h.remoteAddr.IP, h.peerNode.ComputedName)
h.logf("put of %s from %v/%v", approxSize(finalSize), h.remoteAddr.IP, h.peerNode.ComputedName)
// TODO: set modtime
// TODO: some real response
success = true
io.WriteString(w, "{}\n")
h.ps.knownEmpty.Set(false)
h.ps.b.send(ipn.Notify{}) // it will set FilesWaiting
h.ps.b.sendFileNotify()
}
func approxSize(n int64) string {

View File

@@ -7,6 +7,7 @@
package ipnlocal
import (
"errors"
"fmt"
"log"
"net"
@@ -20,6 +21,7 @@ import (
func init() {
initListenConfig = initListenConfigNetworkExtension
peerDialControlFunc = peerDialControlFuncNetworkExtension
}
// initListenConfigNetworkExtension configures nc for listening on IP
@@ -33,16 +35,7 @@ func initListenConfigNetworkExtension(nc *net.ListenConfig, ip netaddr.IP, st *i
nc.Control = func(network, address string, c syscall.RawConn) error {
var sockErr error
err := c.Control(func(fd uintptr) {
v6 := strings.Contains(address, "]:") || strings.HasSuffix(network, "6") // hacky test for v6
proto := unix.IPPROTO_IP
opt := unix.IP_BOUND_IF
if v6 {
proto = unix.IPPROTO_IPV6
opt = unix.IPV6_BOUND_IF
}
sockErr = unix.SetsockoptInt(int(fd), proto, opt, tunIf.Index)
sockErr = bindIf(fd, network, address, tunIf.Index)
log.Printf("peerapi: bind(%q, %q) on index %v = %v", network, address, tunIf.Index, sockErr)
})
if err != nil {
@@ -52,3 +45,40 @@ func initListenConfigNetworkExtension(nc *net.ListenConfig, ip netaddr.IP, st *i
}
return nil
}
func bindIf(fd uintptr, network, address string, ifIndex int) error {
v6 := strings.Contains(address, "]:") || strings.HasSuffix(network, "6") // hacky test for v6
proto := unix.IPPROTO_IP
opt := unix.IP_BOUND_IF
if v6 {
proto = unix.IPPROTO_IPV6
opt = unix.IPV6_BOUND_IF
}
return unix.SetsockoptInt(int(fd), proto, opt, ifIndex)
}
func peerDialControlFuncNetworkExtension(b *LocalBackend) func(network, address string, c syscall.RawConn) error {
b.mu.Lock()
defer b.mu.Unlock()
st := b.prevIfState
pas := b.peerAPIServer
index := -1
if st != nil && pas != nil && pas.tunName != "" {
if tunIf, ok := st.Interface[pas.tunName]; ok {
index = tunIf.Index
}
}
return func(network, address string, c syscall.RawConn) error {
if index == -1 {
return errors.New("failed to find TUN interface to bind to")
}
var sockErr error
err := c.Control(func(fd uintptr) {
sockErr = bindIf(fd, network, address, index)
})
if err != nil {
return err
}
return sockErr
}
}

View File

@@ -0,0 +1,424 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package ipnlocal
import (
"bytes"
"fmt"
"io"
"io/fs"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)
type peerAPITestEnv struct {
ph *peerAPIHandler
rr *httptest.ResponseRecorder
logBuf bytes.Buffer
}
func (e *peerAPITestEnv) logf(format string, a ...interface{}) {
fmt.Fprintf(&e.logBuf, format, a...)
}
type check func(*testing.T, *peerAPITestEnv)
func checks(vv ...check) []check { return vv }
func httpStatus(wantStatus int) check {
return func(t *testing.T, e *peerAPITestEnv) {
if res := e.rr.Result(); res.StatusCode != wantStatus {
t.Errorf("HTTP response code = %v; want %v", res.Status, wantStatus)
}
}
}
func bodyContains(sub string) check {
return func(t *testing.T, e *peerAPITestEnv) {
if body := e.rr.Body.String(); !strings.Contains(body, sub) {
t.Errorf("HTTP response body does not contain %q; got: %s", sub, body)
}
}
}
func bodyNotContains(sub string) check {
return func(t *testing.T, e *peerAPITestEnv) {
if body := e.rr.Body.String(); strings.Contains(body, sub) {
t.Errorf("HTTP response body unexpectedly contains %q; got: %s", sub, body)
}
}
}
func fileHasSize(name string, size int) check {
return func(t *testing.T, e *peerAPITestEnv) {
root := e.ph.ps.rootDir
if root == "" {
t.Errorf("no rootdir; can't check whether %q has size %v", name, size)
return
}
path := filepath.Join(root, name)
if fi, err := os.Stat(path); err != nil {
t.Errorf("fileHasSize(%q, %v): %v", name, size, err)
} else if fi.Size() != int64(size) {
t.Errorf("file %q has size %v; want %v", name, fi.Size(), size)
}
}
}
func fileHasContents(name string, want string) check {
return func(t *testing.T, e *peerAPITestEnv) {
root := e.ph.ps.rootDir
if root == "" {
t.Errorf("no rootdir; can't check contents of %q", name)
return
}
path := filepath.Join(root, name)
got, err := ioutil.ReadFile(path)
if err != nil {
t.Errorf("fileHasContents: %v", err)
return
}
if string(got) != want {
t.Errorf("file contents = %q; want %q", got, want)
}
}
}
func hexAll(v string) string {
var sb strings.Builder
for i := 0; i < len(v); i++ {
fmt.Fprintf(&sb, "%%%02x", v[i])
}
return sb.String()
}
func TestHandlePeerPut(t *testing.T) {
tests := []struct {
name string
isSelf bool // the peer sending the request is owned by us
capSharing bool // self node has file sharing capabilty
omitRoot bool // don't configure
req *http.Request
checks []check
}{
{
name: "not_peer_api",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("GET", "/", nil),
checks: checks(
httpStatus(200),
bodyContains("This is my Tailscale device."),
bodyContains("You are the owner of this node."),
),
},
{
name: "not_peer_api_not_owner",
isSelf: false,
capSharing: true,
req: httptest.NewRequest("GET", "/", nil),
checks: checks(
httpStatus(200),
bodyContains("This is my Tailscale device."),
bodyNotContains("You are the owner of this node."),
),
},
{
name: "reject_non_owner_put",
isSelf: false,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/foo", nil),
checks: checks(
httpStatus(http.StatusForbidden),
bodyContains("not owner"),
),
},
{
name: "owner_without_cap",
isSelf: true,
capSharing: false,
req: httptest.NewRequest("PUT", "/v0/put/foo", nil),
checks: checks(
httpStatus(http.StatusForbidden),
bodyContains("file sharing not enabled by Tailscale admin"),
),
},
{
name: "owner_with_cap_no_rootdir",
omitRoot: true,
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/foo", nil),
checks: checks(
httpStatus(http.StatusInternalServerError),
bodyContains("no rootdir"),
),
},
{
name: "bad_method",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("POST", "/v0/put/foo", nil),
checks: checks(
httpStatus(405),
bodyContains("expected method PUT"),
),
},
{
name: "put_zero_length",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/foo", nil),
checks: checks(
httpStatus(200),
bodyContains("{}"),
fileHasSize("foo", 0),
fileHasContents("foo", ""),
),
},
{
name: "put_non_zero_length_content_length",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/foo", strings.NewReader("contents")),
checks: checks(
httpStatus(200),
bodyContains("{}"),
fileHasSize("foo", len("contents")),
fileHasContents("foo", "contents"),
),
},
{
name: "put_non_zero_length_chunked",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/foo", struct{ io.Reader }{strings.NewReader("contents")}),
checks: checks(
httpStatus(200),
bodyContains("{}"),
fileHasSize("foo", len("contents")),
fileHasContents("foo", "contents"),
),
},
{
name: "bad_filename_partial",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/foo.partial", nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_dot",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/.", nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_empty",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/", nil),
checks: checks(
httpStatus(400),
bodyContains("empty filename"),
),
},
{
name: "bad_filename_slash",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/foo/bar", nil),
checks: checks(
httpStatus(400),
bodyContains("directories not supported"),
),
},
{
name: "bad_filename_encoded_dot",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("."), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_encoded_slash",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("/"), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_encoded_backslash",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("\\"), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_encoded_dotdot",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll(".."), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "bad_filename_encoded_dotdot_out",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("foo/../../../../../etc/passwd"), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "put_spaces_and_caps",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("Foo Bar.dat"), strings.NewReader("baz")),
checks: checks(
httpStatus(200),
bodyContains("{}"),
fileHasContents("Foo Bar.dat", "baz"),
),
},
{
name: "put_unicode",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("Томас и его друзья.mp3"), strings.NewReader("главный озорник")),
checks: checks(
httpStatus(200),
bodyContains("{}"),
fileHasContents("Томас и его друзья.mp3", "главный озорник"),
),
},
{
name: "put_invalid_utf8",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+(hexAll("😜")[:3]), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "put_invalid_null",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/%00", nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "put_invalid_non_printable",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/%01", nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "put_invalid_colon",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll("nul:"), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
{
name: "put_invalid_surrounding_whitespace",
isSelf: true,
capSharing: true,
req: httptest.NewRequest("PUT", "/v0/put/"+hexAll(" foo "), nil),
checks: checks(
httpStatus(400),
bodyContains("bad filename"),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var caps []string
if tt.capSharing {
caps = append(caps, tailcfg.CapabilityFileSharing)
}
var e peerAPITestEnv
lb := &LocalBackend{
netMap: &netmap.NetworkMap{
SelfNode: &tailcfg.Node{
Capabilities: caps,
},
},
logf: e.logf,
}
e.ph = &peerAPIHandler{
isSelf: tt.isSelf,
peerNode: &tailcfg.Node{
ComputedName: "some-peer-name",
},
ps: &peerAPIServer{
b: lb,
},
}
var rootDir string
if !tt.omitRoot {
rootDir = t.TempDir()
e.ph.ps.rootDir = rootDir
}
e.rr = httptest.NewRecorder()
e.ph.ServeHTTP(e.rr, tt.req)
for _, f := range tt.checks {
f(t, &e)
}
if t.Failed() && rootDir != "" {
t.Logf("Contents of %s:", rootDir)
des, _ := fs.ReadDir(os.DirFS(rootDir), ".")
for _, de := range des {
fi, err := de.Info()
if err != nil {
t.Log(err)
} else {
t.Logf(" %v %5d %s", fi.Mode(), fi.Size(), de.Name())
}
}
}
})
}
}

View File

@@ -63,16 +63,6 @@ type Options struct {
// waits for a frontend to start it.
AutostartStateKey ipn.StateKey
// LegacyConfigPath optionally specifies the old-style relaynode
// relay.conf location. If both LegacyConfigPath and
// AutostartStateKey are specified and the requested state doesn't
// exist in the backend store, the backend migrates the config
// from LegacyConfigPath.
//
// TODO(danderson): remove some time after the transition to
// tailscaled is done.
LegacyConfigPath string
// SurviveDisconnects specifies how the server reacts to its
// frontend disconnecting. If true, the server keeps running on
// its existing state, and accepts new frontend connections. If
@@ -297,7 +287,7 @@ func (s *server) serveConn(ctx context.Context, c net.Conn, logf logger.Logf) {
defer s.removeAndCloseConn(c)
logf("[v1] incoming control connection")
if isReadonlyConn(ci, logf) {
if isReadonlyConn(ci, s.b.OperatorUserID(), logf) {
ctx = ipn.ReadonlyContextOf(ctx)
}
@@ -323,7 +313,7 @@ func (s *server) serveConn(ctx context.Context, c net.Conn, logf logger.Logf) {
}
}
func isReadonlyConn(ci connIdentity, logf logger.Logf) bool {
func isReadonlyConn(ci connIdentity, operatorUID string, logf logger.Logf) bool {
if runtime.GOOS == "windows" {
// Windows doesn't need/use this mechanism, at least yet. It
// has a different last-user-wins auth model.
@@ -352,6 +342,10 @@ func isReadonlyConn(ci connIdentity, logf logger.Logf) bool {
logf("connection from userid %v; connection from non-root user matching daemon has access", uid)
return rw
}
if operatorUID != "" && uid == operatorUID {
logf("connection from userid %v; is configured operator", uid)
return rw
}
var adminGroupID string
switch runtime.GOOS {
case "darwin":
@@ -445,7 +439,7 @@ func (s *server) localAPIPermissions(ci connIdentity) (read, write bool) {
return false, false
}
if ci.IsUnixSock {
return true, !isReadonlyConn(ci, logger.Discard)
return true, !isReadonlyConn(ci, s.b.OperatorUserID(), logger.Discard)
}
return false, false
}
@@ -482,9 +476,7 @@ func (s *server) addConn(c net.Conn, isHTTP bool) (ci connIdentity, err error) {
defer func() {
if doReset {
s.logf("identity changed; resetting server")
s.bsMu.Lock()
s.bs.Reset(context.TODO())
s.bsMu.Unlock()
s.b.ResetForClientDisconnect()
}
}()
@@ -534,9 +526,7 @@ func (s *server) removeAndCloseConn(c net.Conn) {
s.logf("client disconnected; staying alive in server mode")
} else {
s.logf("client disconnected; stopping server")
s.bsMu.Lock()
s.bs.Reset(context.TODO())
s.bsMu.Unlock()
s.b.ResetForClientDisconnect()
}
}
c.Close()
@@ -602,6 +592,7 @@ func (s *server) writeToClients(b []byte) {
// Run runs a Tailscale backend service.
// The getEngine func is called repeatedly, once per connection, until it returns an engine successfully.
func Run(ctx context.Context, logf logger.Logf, logid string, getEngine func() (wgengine.Engine, error), opts Options) error {
getEngine = getEngineUntilItWorksWrapper(getEngine)
runDone := make(chan struct{})
defer close(runDone)
@@ -662,46 +653,6 @@ func Run(ctx context.Context, logf logger.Logf, logid string, getEngine func() (
eng, err := getEngine()
if err != nil {
logf("ipnserver: initial getEngine call: %v", err)
// Issue 1187: on Windows, in unattended mode,
// sometimes we try 5 times and fail to create the
// engine before the system's ready. Hack until the
// bug if fixed properly: if we're running in
// unattended mode on Windows, keep trying forever,
// waiting for the machine to be ready (networking to
// come up?) and then dial our own safesocket TCP
// listener to wake up the usual mechanism that lets
// us surface getEngine errors to UI clients. (We
// don't want to just call getEngine in a loop without
// the listener.Accept, as we do want to handle client
// connections so we can tell them about errors)
bootRaceWaitForEngine, bootRaceWaitForEngineCancel := context.WithTimeout(context.Background(), time.Minute)
if runtime.GOOS == "windows" && opts.AutostartStateKey != "" {
logf("ipnserver: in unattended mode, waiting for engine availability")
getEngine = getEngineUntilItWorksWrapper(getEngine)
// Wait for it to be ready.
go func() {
defer bootRaceWaitForEngineCancel()
t0 := time.Now()
for {
time.Sleep(10 * time.Second)
if _, err := getEngine(); err != nil {
logf("ipnserver: unattended mode engine load: %v", err)
continue
}
c, err := net.Dial("tcp", listen.Addr().String())
logf("ipnserver: engine created after %v; waking up Accept: Dial error: %v", time.Since(t0).Round(time.Second), err)
if err == nil {
c.Close()
}
break
}
}()
} else {
bootRaceWaitForEngineCancel()
}
for i := 1; ctx.Err() == nil; i++ {
c, err := listen.Accept()
if err != nil {
@@ -709,7 +660,6 @@ func Run(ctx context.Context, logf logger.Logf, logid string, getEngine func() (
bo.BackOff(ctx, err)
continue
}
<-bootRaceWaitForEngine.Done()
logf("ipnserver: try%d: trying getEngine again...", i)
eng, err = getEngine()
if err == nil {
@@ -754,10 +704,7 @@ func Run(ctx context.Context, logf logger.Logf, logid string, getEngine func() (
server.bs.GotCommand(context.TODO(), &ipn.Command{
Version: version.Long,
Start: &ipn.StartArgs{
Opts: ipn.Options{
StateKey: opts.AutostartStateKey,
LegacyConfigPath: opts.LegacyConfigPath,
},
Opts: ipn.Options{StateKey: opts.AutostartStateKey},
},
})
}

View File

@@ -71,7 +71,8 @@ type PeerStatus struct {
OS string // HostInfo.OS
UserID tailcfg.UserID
TailAddr string // Tailscale IP
TailAddrDeprecated string `json:"TailAddr"` // Tailscale IP
TailscaleIPs []netaddr.IP // Tailscale IP(s) assigned to this node
// Endpoints:
Addrs []string
@@ -87,7 +88,8 @@ type PeerStatus struct {
KeepAlive bool
ExitNode bool // true if this is the currently selected exit node.
PeerAPIURL []string
PeerAPIURL []string
Capabilities []string `json:",omitempty"`
// ShareeNode indicates this node exists in the netmap because
// it's owned by a shared-to user and that node might connect
@@ -213,8 +215,11 @@ func (sb *StatusBuilder) AddPeer(peer key.Public, st *PeerStatus) {
if v := st.UserID; v != 0 {
e.UserID = v
}
if v := st.TailAddr; v != "" {
e.TailAddr = v
if v := st.TailAddrDeprecated; v != "" {
e.TailAddrDeprecated = v
}
if v := st.TailscaleIPs; v != nil {
e.TailscaleIPs = v
}
if v := st.OS; v != "" {
e.OS = st.OS
@@ -343,13 +348,17 @@ table tbody tr:nth-child(even) td { background-color: #f5f5f5; }
hostNameHTML = "<br>" + html.EscapeString(hostName)
}
var tailAddr string
if len(ps.TailscaleIPs) > 0 {
tailAddr = ps.TailscaleIPs[0].String()
}
f("<tr><td>%s</td><td class=acenter>%s</td>"+
"<td><b>%s</b>%s<div class=\"tailaddr\">%s</div></td><td class=\"acenter owner\">%s</td><td class=\"aright\">%v</td><td class=\"aright\">%v</td><td class=\"aright\">%v</td>",
ps.PublicKey.ShortString(),
osEmoji(ps.OS),
html.EscapeString(dnsName),
hostNameHTML,
ps.TailAddr,
tailAddr,
html.EscapeString(owner),
ps.RxBytes,
ps.TxBytes,
@@ -437,5 +446,9 @@ func sortKey(ps *PeerStatus) string {
if ps.HostName != "" {
return ps.HostName
}
return ps.TailAddr
// TODO(bradfitz): add PeerStatus.Less and avoid these allocs in a Less func.
if len(ps.TailscaleIPs) > 0 {
return ps.TailscaleIPs[0].String()
}
return string(ps.PublicKey[:])
}

View File

@@ -9,16 +9,23 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"reflect"
"runtime"
"strconv"
"strings"
"sync"
"time"
"inet.af/netaddr"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
@@ -72,6 +79,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.serveFiles(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/localapi/v0/file-put/") {
h.serveFilePut(w, r)
return
}
switch r.URL.Path {
case "/localapi/v0/whois":
h.serveWhoIs(w, r)
@@ -79,12 +90,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.serveGoroutines(w, r)
case "/localapi/v0/status":
h.serveStatus(w, r)
case "/localapi/v0/logout":
h.serveLogout(w, r)
case "/localapi/v0/prefs":
h.servePrefs(w, r)
case "/localapi/v0/check-ip-forwarding":
h.serveCheckIPForwarding(w, r)
case "/localapi/v0/bugreport":
h.serveBugReport(w, r)
default:
case "/localapi/v0/file-targets":
h.serveFileTargets(w, r)
case "/":
io.WriteString(w, "tailscaled\n")
default:
http.Error(w, "404 not found", 404)
}
}
@@ -126,7 +145,7 @@ func (h *Handler) serveWhoIs(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no match for IP:port", 404)
return
}
res := &tailcfg.WhoIsResponse{
res := &apitype.WhoIsResponse{
Node: n,
UserProfile: &u,
}
@@ -186,6 +205,58 @@ func (h *Handler) serveStatus(w http.ResponseWriter, r *http.Request) {
e.Encode(st)
}
func (h *Handler) serveLogout(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "logout access denied", http.StatusForbidden)
return
}
if r.Method != "POST" {
http.Error(w, "want POST", 400)
return
}
err := h.b.LogoutSync(r.Context())
if err == nil {
w.WriteHeader(http.StatusNoContent)
return
}
http.Error(w, err.Error(), 500)
}
func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "prefs access denied", http.StatusForbidden)
return
}
var prefs *ipn.Prefs
switch r.Method {
case "PATCH":
if !h.PermitWrite {
http.Error(w, "prefs write access denied", http.StatusForbidden)
return
}
mp := new(ipn.MaskedPrefs)
if err := json.NewDecoder(r.Body).Decode(mp); err != nil {
http.Error(w, err.Error(), 400)
return
}
var err error
prefs, err = h.b.EditPrefs(mp)
if err != nil {
http.Error(w, err.Error(), 400)
return
}
case "GET", "HEAD":
prefs = h.b.Prefs()
default:
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
e := json.NewEncoder(w)
e.SetIndent("", "\t")
e.Encode(prefs)
}
func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "file access denied", http.StatusForbidden)
@@ -229,6 +300,108 @@ func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) {
io.Copy(w, rc)
}
func writeErrorJSON(w http.ResponseWriter, err error) {
if err == nil {
err = errors.New("unexpected nil error")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
type E struct {
Error string `json:"error"`
}
json.NewEncoder(w).Encode(E{err.Error()})
}
func (h *Handler) serveFileTargets(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "access denied", http.StatusForbidden)
return
}
if r.Method != "GET" {
http.Error(w, "want GET to list targets", 400)
return
}
fts, err := h.b.FileTargets()
if err != nil {
writeErrorJSON(w, err)
return
}
makeNonNil(&fts)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(fts)
}
func (h *Handler) serveFilePut(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "file access denied", http.StatusForbidden)
return
}
if r.Method != "PUT" {
http.Error(w, "want PUT to put file", 400)
return
}
fts, err := h.b.FileTargets()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
upath := strings.TrimPrefix(r.URL.EscapedPath(), "/localapi/v0/file-put/")
slash := strings.Index(upath, "/")
if slash == -1 {
http.Error(w, "bogus URL", 400)
return
}
stableID, filenameEscaped := tailcfg.StableNodeID(upath[:slash]), upath[slash+1:]
var ft *apitype.FileTarget
for _, x := range fts {
if x.Node.StableID == stableID {
ft = x
break
}
}
if ft == nil {
http.Error(w, "node not found", 404)
return
}
dstURL, err := url.Parse(ft.PeerAPIURL)
if err != nil {
http.Error(w, "bogus peer URL", 500)
return
}
outReq, err := http.NewRequestWithContext(r.Context(), "PUT", "http://peer/v0/put/"+filenameEscaped, r.Body)
if err != nil {
http.Error(w, "bogus outreq", 500)
return
}
outReq.ContentLength = r.ContentLength
rp := httputil.NewSingleHostReverseProxy(dstURL)
rp.Transport = getDialPeerTransport(h.b)
rp.ServeHTTP(w, outReq)
}
var dialPeerTransportOnce struct {
sync.Once
v *http.Transport
}
func getDialPeerTransport(b *ipnlocal.LocalBackend) *http.Transport {
dialPeerTransportOnce.Do(func() {
t := http.DefaultTransport.(*http.Transport).Clone()
t.Dial = nil //lint:ignore SA1019 yes I know I'm setting it to nil defensively
dialer := net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
Control: b.PeerDialControlFunc(),
}
t.DialContext = dialer.DialContext
dialPeerTransportOnce.v = t
})
return dialPeerTransportOnce.v
}
func defBool(a string, def bool) bool {
if a == "" {
return def
@@ -239,3 +412,30 @@ 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 interface{}) {
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

@@ -80,7 +80,6 @@ type Command struct {
Login *tailcfg.Oauth2Token
Logout *NoArgs
SetPrefs *SetPrefsArgs
EditPrefs *MaskedPrefs
RequestEngineStatus *NoArgs
RequestStatus *NoArgs
FakeExpireAfter *FakeExpireAfterArgs
@@ -95,11 +94,13 @@ type BackendServer struct {
}
func NewBackendServer(logf logger.Logf, b Backend, sendNotifyMsg func(b []byte)) *BackendServer {
return &BackendServer{
bs := &BackendServer{
logf: logf,
b: b,
sendNotifyMsg: sendNotifyMsg,
}
b.SetNotifyCallback(bs.send)
return bs
}
func (bs *BackendServer) send(n Notify) {
@@ -142,11 +143,6 @@ func (bs *BackendServer) GotCommandMsg(ctx context.Context, b []byte) error {
return bs.GotCommand(ctx, cmd)
}
func (bs *BackendServer) GotFakeCommand(ctx context.Context, cmd *Command) error {
cmd.Version = version.Long
return bs.GotCommand(ctx, cmd)
}
// ErrMsgPermissionDenied is the Notify.ErrMessage value used an
// operation was done from a user/context that didn't have permission.
const ErrMsgPermissionDenied = "permission denied"
@@ -190,7 +186,6 @@ func (bs *BackendServer) GotCommand(ctx context.Context, cmd *Command) error {
return errors.New("Quit command received")
} else if c := cmd.Start; c != nil {
opts := c.Opts
opts.Notify = bs.send
return bs.b.Start(opts)
} else if c := cmd.StartLoginInteractive; c != nil {
bs.b.StartLoginInteractive()
@@ -204,9 +199,6 @@ func (bs *BackendServer) GotCommand(ctx context.Context, cmd *Command) error {
} else if c := cmd.SetPrefs; c != nil {
bs.b.SetPrefs(c.New)
return nil
} else if c := cmd.EditPrefs; c != nil {
bs.b.EditPrefs(c)
return nil
} else if c := cmd.FakeExpireAfter; c != nil {
bs.b.FakeExpireAfter(c.Duration)
return nil
@@ -214,12 +206,6 @@ func (bs *BackendServer) GotCommand(ctx context.Context, cmd *Command) error {
return fmt.Errorf("BackendServer.Do: no command specified")
}
func (bs *BackendServer) Reset(ctx context.Context) error {
// Tell the backend we got a Logout command, which will cause it
// to forget all its authentication information.
return bs.GotFakeCommand(ctx, &Command{Logout: &NoArgs{}})
}
type BackendClient struct {
logf logger.Logf
sendCommandMsg func(jsonb []byte)
@@ -247,7 +233,7 @@ func (bc *BackendClient) GotNotifyMsg(b []byte) {
}
n := Notify{}
if err := json.Unmarshal(b, &n); err != nil {
log.Fatalf("BackendClient.Notify: cannot decode message (length=%d)\n%#v", len(b), string(b))
log.Fatalf("BackendClient.Notify: cannot decode message (length=%d, %#q): %v", len(b), b, err)
}
if n.Version != version.Long && !bc.AllowVersionSkew {
vs := fmt.Sprintf("GotNotify: Version mismatch! frontend=%#v backend=%#v",
@@ -287,8 +273,6 @@ func (bc *BackendClient) Quit() error {
}
func (bc *BackendClient) Start(opts Options) error {
bc.notify = opts.Notify
opts.Notify = nil // server can't call our function pointer
bc.send(Command{Start: &StartArgs{Opts: opts}})
return nil // remote Start() errors must be handled remotely
}
@@ -309,10 +293,6 @@ func (bc *BackendClient) SetPrefs(new *Prefs) {
bc.send(Command{SetPrefs: &SetPrefsArgs{New: new}})
}
func (bc *BackendClient) EditPrefs(mp *MaskedPrefs) {
bc.send(Command{EditPrefs: mp})
}
func (bc *BackendClient) RequestEngineStatus() {
bc.send(Command{RequestEngineStatus: &NoArgs{}})
}

View File

@@ -90,13 +90,11 @@ func TestClientServer(t *testing.T) {
bc = NewBackendClient(clogf, clientToServer)
ch := make(chan Notify, 256)
h, err := NewHandle(bc, clogf, Options{
notify := func(n Notify) { ch <- n }
h, err := NewHandle(bc, clogf, notify, Options{
Prefs: &Prefs{
ControlURL: "http://example.com/fake",
},
Notify: func(n Notify) {
ch <- n
},
})
if err != nil {
t.Fatalf("NewHandle error: %v\n", err)

View File

@@ -25,9 +25,18 @@ import (
//go:generate go run tailscale.com/cmd/cloner -type=Prefs -output=prefs_clone.go
// DefaultControlURL returns the URL base of the control plane
// ("coordination server") for use when no explicit one is configured.
// The default control plane is the hosted version run by Tailscale.com.
const DefaultControlURL = "https://login.tailscale.com"
// Prefs are the user modifiable settings of the Tailscale node agent.
type Prefs struct {
// ControlURL is the URL of the control server to use.
//
// If empty, the default for new installs, DefaultControlURL
// is used. It's set non-empty once the daemon has been started
// for the first time.
ControlURL string
// RouteAll specifies whether to accept subnets advertised by
@@ -66,6 +75,10 @@ type Prefs struct {
ExitNodeID tailcfg.StableNodeID
ExitNodeIP netaddr.IP
// ExitNodeAllowLANAccess indicates whether locally accessible subnets should be
// routed directly or via the exit node.
ExitNodeAllowLANAccess bool
// CorpDNS specifies whether to install the Tailscale network's
// DNS configuration, if it exists.
CorpDNS bool
@@ -140,6 +153,10 @@ type Prefs struct {
// Tailscale, if at all.
NetfilterMode preftype.NetfilterMode
// OperatorUser is the local machine user name who is allowed to
// operate tailscaled without being root or using sudo.
OperatorUser string `json:",omitempty"`
// The Persist field is named 'Config' in the file for backward
// compatibility with earlier versions.
// TODO(apenwarr): We should move this out of here, it's not a pref.
@@ -152,23 +169,25 @@ type Prefs struct {
type MaskedPrefs struct {
Prefs
ControlURLSet bool `json:",omitempty"`
RouteAllSet bool `json:",omitempty"`
AllowSingleHostsSet bool `json:",omitempty"`
ExitNodeIDSet bool `json:",omitempty"`
ExitNodeIPSet bool `json:",omitempty"`
CorpDNSSet bool `json:",omitempty"`
WantRunningSet bool `json:",omitempty"`
ShieldsUpSet bool `json:",omitempty"`
AdvertiseTagsSet bool `json:",omitempty"`
HostnameSet bool `json:",omitempty"`
OSVersionSet bool `json:",omitempty"`
DeviceModelSet bool `json:",omitempty"`
NotepadURLsSet bool `json:",omitempty"`
ForceDaemonSet bool `json:",omitempty"`
AdvertiseRoutesSet bool `json:",omitempty"`
NoSNATSet bool `json:",omitempty"`
NetfilterModeSet bool `json:",omitempty"`
ControlURLSet bool `json:",omitempty"`
RouteAllSet bool `json:",omitempty"`
AllowSingleHostsSet bool `json:",omitempty"`
ExitNodeIDSet bool `json:",omitempty"`
ExitNodeIPSet bool `json:",omitempty"`
ExitNodeAllowLANAccessSet bool `json:",omitempty"`
CorpDNSSet bool `json:",omitempty"`
WantRunningSet bool `json:",omitempty"`
ShieldsUpSet bool `json:",omitempty"`
AdvertiseTagsSet bool `json:",omitempty"`
HostnameSet bool `json:",omitempty"`
OSVersionSet bool `json:",omitempty"`
DeviceModelSet bool `json:",omitempty"`
NotepadURLsSet bool `json:",omitempty"`
ForceDaemonSet bool `json:",omitempty"`
AdvertiseRoutesSet bool `json:",omitempty"`
NoSNATSet bool `json:",omitempty"`
NetfilterModeSet bool `json:",omitempty"`
OperatorUserSet bool `json:",omitempty"`
}
// ApplyEdits mutates p, assigning fields from m.Prefs for each MaskedPrefs
@@ -237,9 +256,9 @@ func (p *Prefs) pretty(goos string) string {
sb.WriteString("shields=true ")
}
if !p.ExitNodeIP.IsZero() {
fmt.Fprintf(&sb, "exit=%v ", p.ExitNodeIP)
fmt.Fprintf(&sb, "exit=%v lan=%t ", p.ExitNodeIP, p.ExitNodeAllowLANAccess)
} else if !p.ExitNodeID.IsZero() {
fmt.Fprintf(&sb, "exit=%v ", p.ExitNodeID)
fmt.Fprintf(&sb, "exit=%v lan=%t ", p.ExitNodeID, p.ExitNodeAllowLANAccess)
}
if len(p.AdvertiseRoutes) > 0 || goos == "linux" {
fmt.Fprintf(&sb, "routes=%v ", p.AdvertiseRoutes)
@@ -253,9 +272,15 @@ func (p *Prefs) pretty(goos string) string {
if goos == "linux" {
fmt.Fprintf(&sb, "nf=%v ", p.NetfilterMode)
}
if p.ControlURL != "" && p.ControlURL != "https://login.tailscale.com" {
if p.ControlURL != "" && p.ControlURL != DefaultControlURL {
fmt.Fprintf(&sb, "url=%q ", p.ControlURL)
}
if p.Hostname != "" {
fmt.Fprintf(&sb, "host=%q ", p.Hostname)
}
if p.OperatorUser != "" {
fmt.Fprintf(&sb, "op=%q ", p.OperatorUser)
}
if p.Persist != nil {
sb.WriteString(p.Persist.Pretty())
} else {
@@ -287,12 +312,14 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.AllowSingleHosts == p2.AllowSingleHosts &&
p.ExitNodeID == p2.ExitNodeID &&
p.ExitNodeIP == p2.ExitNodeIP &&
p.ExitNodeAllowLANAccess == p2.ExitNodeAllowLANAccess &&
p.CorpDNS == p2.CorpDNS &&
p.WantRunning == p2.WantRunning &&
p.NotepadURLs == p2.NotepadURLs &&
p.ShieldsUp == p2.ShieldsUp &&
p.NoSNAT == p2.NoSNAT &&
p.NetfilterMode == p2.NetfilterMode &&
p.OperatorUser == p2.OperatorUser &&
p.Hostname == p2.Hostname &&
p.OSVersion == p2.OSVersion &&
p.DeviceModel == p2.DeviceModel &&
@@ -326,12 +353,19 @@ func compareStrings(a, b []string) bool {
return true
}
// NewPrefs returns the default preferences to use.
func NewPrefs() *Prefs {
// Provide default values for options which might be missing
// from the json data for any reason. The json can still
// override them to false.
return &Prefs{
// Provide default values for options which might be missing
// from the json data for any reason. The json can still
// override them to false.
ControlURL: "https://login.tailscale.com",
// ControlURL is explicitly not set to signal that
// it's not yet configured, which relaxes the CLI "up"
// safety net features. It will get set to DefaultControlURL
// on first up. Or, if not, DefaultControlURL will be used
// later anyway.
ControlURL: "",
RouteAll: true,
AllowSingleHosts: true,
CorpDNS: true,
@@ -340,6 +374,15 @@ func NewPrefs() *Prefs {
}
}
// ControlURLOrDefault returns the coordination server's URL base.
// If not configured, DefaultControlURL is returned instead.
func (p *Prefs) ControlURLOrDefault() string {
if p.ControlURL != "" {
return p.ControlURL
}
return DefaultControlURL
}
// PrefsFromBytes deserializes Prefs from a JSON blob. If
// enforceDefaults is true, Prefs.RouteAll and Prefs.AllowSingleHosts
// are forced on.

View File

@@ -33,22 +33,24 @@ func (src *Prefs) Clone() *Prefs {
// A compilation failure here means this code must be regenerated, with command:
// tailscale.com/cmd/cloner -type Prefs
var _PrefsNeedsRegeneration = Prefs(struct {
ControlURL string
RouteAll bool
AllowSingleHosts bool
ExitNodeID tailcfg.StableNodeID
ExitNodeIP netaddr.IP
CorpDNS bool
WantRunning bool
ShieldsUp bool
AdvertiseTags []string
Hostname string
OSVersion string
DeviceModel string
NotepadURLs bool
ForceDaemon bool
AdvertiseRoutes []netaddr.IPPrefix
NoSNAT bool
NetfilterMode preftype.NetfilterMode
Persist *persist.Persist
ControlURL string
RouteAll bool
AllowSingleHosts bool
ExitNodeID tailcfg.StableNodeID
ExitNodeIP netaddr.IP
ExitNodeAllowLANAccess bool
CorpDNS bool
WantRunning bool
ShieldsUp bool
AdvertiseTags []string
Hostname string
OSVersion string
DeviceModel string
NotepadURLs bool
ForceDaemon bool
AdvertiseRoutes []netaddr.IPPrefix
NoSNAT bool
NetfilterMode preftype.NetfilterMode
OperatorUser string
Persist *persist.Persist
}{})

View File

@@ -33,7 +33,28 @@ func fieldsOf(t reflect.Type) (fields []string) {
func TestPrefsEqual(t *testing.T) {
tstest.PanicOnLog()
prefsHandles := []string{"ControlURL", "RouteAll", "AllowSingleHosts", "ExitNodeID", "ExitNodeIP", "CorpDNS", "WantRunning", "ShieldsUp", "AdvertiseTags", "Hostname", "OSVersion", "DeviceModel", "NotepadURLs", "ForceDaemon", "AdvertiseRoutes", "NoSNAT", "NetfilterMode", "Persist"}
prefsHandles := []string{
"ControlURL",
"RouteAll",
"AllowSingleHosts",
"ExitNodeID",
"ExitNodeIP",
"ExitNodeAllowLANAccess",
"CorpDNS",
"WantRunning",
"ShieldsUp",
"AdvertiseTags",
"Hostname",
"OSVersion",
"DeviceModel",
"NotepadURLs",
"ForceDaemon",
"AdvertiseRoutes",
"NoSNAT",
"NetfilterMode",
"OperatorUser",
"Persist",
}
if have := fieldsOf(reflect.TypeOf(Prefs{})); !reflect.DeepEqual(have, prefsHandles) {
t.Errorf("Prefs.Equal check might be out of sync\nfields: %q\nhandled: %q\n",
have, prefsHandles)
@@ -124,6 +145,17 @@ func TestPrefsEqual(t *testing.T) {
true,
},
{
&Prefs{},
&Prefs{ExitNodeAllowLANAccess: true},
false,
},
{
&Prefs{ExitNodeAllowLANAccess: true},
&Prefs{ExitNodeAllowLANAccess: true},
true,
},
{
&Prefs{CorpDNS: true},
&Prefs{CorpDNS: false},
@@ -384,14 +416,36 @@ func TestPrefsPretty(t *testing.T) {
ExitNodeIP: netaddr.MustParseIP("1.2.3.4"),
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=1.2.3.4 routes=[] nf=off Persist=nil}`,
`Prefs{ra=false mesh=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off Persist=nil}`,
},
{
Prefs{
ExitNodeID: tailcfg.StableNodeID("myNodeABC"),
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC routes=[] nf=off Persist=nil}`,
`Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off Persist=nil}`,
},
{
Prefs{
ExitNodeID: tailcfg.StableNodeID("myNodeABC"),
ExitNodeAllowLANAccess: true,
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off Persist=nil}`,
},
{
Prefs{
ExitNodeAllowLANAccess: true,
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist=nil}`,
},
{
Prefs{
Hostname: "foo",
},
"linux",
`Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off host="foo" Persist=nil}`,
},
}
for i, tt := range tests {

View File

@@ -121,14 +121,17 @@ func (id PublicID) MarshalText() ([]byte, error) {
}
func (id *PublicID) UnmarshalText(s []byte) error {
b, err := hex.DecodeString(string(s))
if err != nil {
return fmt.Errorf("logtail.PublicID.UnmarshalText: %v", err)
if len(s) != len(id)*2 {
return fmt.Errorf("logtail.PublicID.UnmarshalText: invalid hex length: %d", len(s))
}
if len(b) != len(id) {
return fmt.Errorf("logtail.PublicID.UnmarshalText: invalid hex length: %d", len(b))
for i := range id {
a, ok1 := fromHexChar(s[i*2+0])
b, ok2 := fromHexChar(s[i*2+1])
if !ok1 || !ok2 {
return errors.New("invalid hex character")
}
id[i] = (a << 4) | b
}
copy(id[:], b)
return nil
}

View File

@@ -303,3 +303,26 @@ func TestParseAndRemoveLogLevel(t *testing.T) {
}
}
}
func TestPublicIDUnmarshalText(t *testing.T) {
const hexStr = "6c60a9e0e7af57170bb1347b2d477e4cbc27d4571a4923b21651456f931e3d55"
x := []byte(hexStr)
var id PublicID
if err := id.UnmarshalText(x); err != nil {
t.Fatal(err)
}
if id.String() != hexStr {
t.Errorf("String = %q; want %q", id.String(), hexStr)
}
n := int(testing.AllocsPerRun(1000, func() {
var id PublicID
if err := id.UnmarshalText(x); err != nil {
t.Fatal(err)
}
}))
if n != 0 {
t.Errorf("allocs = %v; want 0", n)
}
}

View File

@@ -5,7 +5,10 @@
package dns
import (
"sort"
"inet.af/netaddr"
"tailscale.com/util/dnsname"
)
// Config is a DNS configuration.
@@ -19,27 +22,102 @@ type Config struct {
// for queries that fall within that suffix.
// If a query doesn't match any entry in Routes, the
// DefaultResolvers are used.
Routes map[string][]netaddr.IPPort
Routes map[dnsname.FQDN][]netaddr.IPPort
// SearchDomains are DNS suffixes to try when expanding
// single-label queries.
SearchDomains []string
SearchDomains []dnsname.FQDN
// Hosts maps DNS FQDNs to their IPs, which can be a mix of IPv4
// and IPv6.
// Queries matching entries in Hosts are resolved locally without
// recursing off-machine.
Hosts map[string][]netaddr.IP
Hosts map[dnsname.FQDN][]netaddr.IP
// AuthoritativeSuffixes is a list of fully-qualified DNS suffixes
// for which the in-process Tailscale resolver is authoritative.
// Queries for names within AuthoritativeSuffixes can only be
// fulfilled by entries in Hosts. Queries with no match in Hosts
// return NXDOMAIN.
AuthoritativeSuffixes []string
AuthoritativeSuffixes []dnsname.FQDN
}
// OSConfig is an OS DNS configuration.
type OSConfig struct {
// Nameservers are the IP addresses of the nameservers to use.
Nameservers []netaddr.IP
// Domains are the search domains to use.
Domains []string
// needsAnyResolvers reports whether c requires a resolver to be set
// at the OS level.
func (c Config) needsOSResolver() bool {
return c.hasDefaultResolvers() || c.hasRoutes() || c.hasHosts()
}
func (c Config) hasRoutes() bool {
return len(c.Routes) > 0
}
// hasDefaultResolversOnly reports whether the only resolvers in c are
// DefaultResolvers.
func (c Config) hasDefaultResolversOnly() bool {
return c.hasDefaultResolvers() && !c.hasRoutes() && !c.hasHosts()
}
func (c Config) hasDefaultResolvers() bool {
return len(c.DefaultResolvers) > 0
}
// singleResolverSet returns the resolvers used by c.Routes if all
// routes use the same resolvers, or nil if multiple sets of resolvers
// are specified.
func (c Config) singleResolverSet() []netaddr.IPPort {
var first []netaddr.IPPort
for _, resolvers := range c.Routes {
if first == nil {
first = resolvers
continue
}
if !sameIPPorts(first, resolvers) {
return nil
}
}
return first
}
// hasHosts reports whether c requires resolution of MagicDNS hosts or
// domains.
func (c Config) hasHosts() bool {
return len(c.Hosts) > 0 || len(c.AuthoritativeSuffixes) > 0
}
// matchDomains returns the list of match suffixes needed by Routes,
// AuthoritativeSuffixes. Hosts is not considered as we assume that
// they're covered by AuthoritativeSuffixes for now.
func (c Config) matchDomains() []dnsname.FQDN {
ret := make([]dnsname.FQDN, 0, len(c.Routes)+len(c.AuthoritativeSuffixes))
seen := map[dnsname.FQDN]bool{}
for _, suffix := range c.AuthoritativeSuffixes {
if seen[suffix] {
continue
}
ret = append(ret, suffix)
seen[suffix] = true
}
for suffix := range c.Routes {
if seen[suffix] {
continue
}
ret = append(ret, suffix)
seen[suffix] = true
}
sort.Slice(ret, func(i, j int) bool {
return ret[i].WithTrailingDot() < ret[j].WithTrailingDot()
})
return ret
}
func sameIPPorts(a, b []netaddr.IPPort) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package dns
import (
"bufio"
"bytes"
_ "embed"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"tailscale.com/atomicfile"
"tailscale.com/types/logger"
)
//go:embed resolvconf-workaround.sh
var workaroundScript []byte
// resolvconfConfigName is the name of the config submitted to
// resolvconf.
// The name starts with 'tun' in order to match the hardcoded
// interface order in debian resolvconf, which will place this
// configuration ahead of regular network links. In theory, this
// doesn't matter because we then fix things up to ensure our config
// is the only one in use, but in case that fails, this will make our
// configuration slightly preferred.
// The 'inet' suffix has no specific meaning, but conventionally
// resolvconf implementations encourage adding a suffix roughly
// indicating where the config came from, and "inet" is the "none of
// the above" value (rather than, say, "ppp" or "dhcp").
const resolvconfConfigName = "tun-tailscale.inet"
// resolvconfLibcHookPath is the directory containing libc update
// scripts, which are run by Debian resolvconf when /etc/resolv.conf
// has been updated.
const resolvconfLibcHookPath = "/etc/resolvconf/update-libc.d"
// resolvconfHookPath is the name of the libc hook script we install
// to force Tailscale's DNS config to take effect.
var resolvconfHookPath = filepath.Join(resolvconfLibcHookPath, "tailscale")
// resolvconfManager manages DNS configuration using the Debian
// implementation of the `resolvconf` program, written by Thomas Hood.
type resolvconfManager struct {
logf logger.Logf
listRecordsPath string
interfacesDir string
scriptInstalled bool // libc update script has been installed
}
func newDebianResolvconfManager(logf logger.Logf) (*resolvconfManager, error) {
ret := &resolvconfManager{
logf: logf,
listRecordsPath: "/lib/resolvconf/list-records",
interfacesDir: "/etc/resolvconf/run/interface", // panic fallback if nothing seems to work
}
if _, err := os.Stat(ret.listRecordsPath); os.IsNotExist(err) {
// This might be a Debian system from before the big /usr
// merge, try /usr instead.
ret.listRecordsPath = "/usr" + ret.listRecordsPath
}
// The runtime directory is currently (2020-04) canonically
// /etc/resolvconf/run, but the manpage is making noise about
// switching to /run/resolvconf and dropping the /etc path. So,
// let's probe the possible directories and use the first one
// that works.
for _, path := range []string{
"/etc/resolvconf/run/interface",
"/run/resolvconf/interface",
"/var/run/resolvconf/interface",
} {
if _, err := os.Stat(path); err == nil {
ret.interfacesDir = path
break
}
}
if ret.interfacesDir == "" {
// None of the paths seem to work, use the canonical location
// that the current manpage says to use.
ret.interfacesDir = "/etc/resolvconf/run/interfaces"
}
return ret, nil
}
func (m *resolvconfManager) deleteTailscaleConfig() error {
cmd := exec.Command("resolvconf", "-d", resolvconfConfigName)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("running %s: %s", cmd, out)
}
return nil
}
func (m *resolvconfManager) SetDNS(config OSConfig) error {
if !m.scriptInstalled {
m.logf("injecting resolvconf workaround script")
if err := os.MkdirAll(resolvconfLibcHookPath, 0755); err != nil {
return err
}
if err := atomicfile.WriteFile(resolvconfHookPath, workaroundScript, 0755); err != nil {
return err
}
m.scriptInstalled = true
}
if config.IsZero() {
if err := m.deleteTailscaleConfig(); err != nil {
return err
}
} else {
stdin := new(bytes.Buffer)
writeResolvConf(stdin, config.Nameservers, config.SearchDomains) // dns_direct.go
// This resolvconf implementation doesn't support exclusive
// mode or interface priorities, so it will end up blending
// our configuration with other sources. However, this will
// get fixed up by the script we injected above.
cmd := exec.Command("resolvconf", "-a", resolvconfConfigName)
cmd.Stdin = stdin
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("running %s: %s", cmd, out)
}
}
return nil
}
func (m *resolvconfManager) SupportsSplitDNS() bool {
return false
}
func (m *resolvconfManager) GetBaseConfig() (OSConfig, error) {
var bs bytes.Buffer
cmd := exec.Command(m.listRecordsPath)
// list-records assumes it's being run with CWD set to the
// interfaces runtime dir, and returns nonsense otherwise.
cmd.Dir = m.interfacesDir
cmd.Stdout = &bs
if err := cmd.Run(); err != nil {
return OSConfig{}, err
}
var conf bytes.Buffer
sc := bufio.NewScanner(&bs)
for sc.Scan() {
if sc.Text() == resolvconfConfigName {
continue
}
bs, err := ioutil.ReadFile(filepath.Join(m.interfacesDir, sc.Text()))
if err != nil {
if os.IsNotExist(err) {
// Probably raced with a deletion, that's okay.
continue
}
return OSConfig{}, err
}
conf.Write(bs)
conf.WriteByte('\n')
}
return readResolv(&conf)
}
func (m *resolvconfManager) Close() error {
if err := m.deleteTailscaleConfig(); err != nil {
return err
}
if m.scriptInstalled {
m.logf("removing resolvconf workaround script")
os.Remove(resolvconfHookPath) // Best-effort
}
return nil
}

View File

@@ -2,14 +2,11 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build linux freebsd openbsd
package dns
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"io/ioutil"
@@ -20,16 +17,16 @@ import (
"inet.af/netaddr"
"tailscale.com/atomicfile"
"tailscale.com/util/dnsname"
)
const (
tsConf = "/etc/resolv.tailscale.conf"
backupConf = "/etc/resolv.pre-tailscale-backup.conf"
resolvConf = "/etc/resolv.conf"
)
// writeResolvConf writes DNS configuration in resolv.conf format to the given writer.
func writeResolvConf(w io.Writer, servers []netaddr.IP, domains []string) {
func writeResolvConf(w io.Writer, servers []netaddr.IP, domains []dnsname.FQDN) {
io.WriteString(w, "# resolv.conf(5) file generated by tailscale\n")
io.WriteString(w, "# DO NOT EDIT THIS FILE BY HAND -- CHANGES WILL BE OVERWRITTEN\n\n")
for _, ns := range servers {
@@ -41,22 +38,14 @@ func writeResolvConf(w io.Writer, servers []netaddr.IP, domains []string) {
io.WriteString(w, "search")
for _, domain := range domains {
io.WriteString(w, " ")
io.WriteString(w, domain)
io.WriteString(w, domain.WithoutTrailingDot())
}
io.WriteString(w, "\n")
}
}
// readResolvConf reads DNS configuration from /etc/resolv.conf.
func readResolvConf() (OSConfig, error) {
var config OSConfig
f, err := os.Open("/etc/resolv.conf")
if err != nil {
return config, err
}
scanner := bufio.NewScanner(f)
func readResolv(r io.Reader) (config OSConfig, err error) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
@@ -65,7 +54,7 @@ func readResolvConf() (OSConfig, error) {
nameserver = strings.TrimSpace(nameserver)
ip, err := netaddr.ParseIP(nameserver)
if err != nil {
return config, err
return OSConfig{}, err
}
config.Nameservers = append(config.Nameservers, ip)
continue
@@ -74,7 +63,11 @@ func readResolvConf() (OSConfig, error) {
if strings.HasPrefix(line, "search") {
domain := strings.TrimPrefix(line, "search")
domain = strings.TrimSpace(domain)
config.Domains = append(config.Domains, domain)
fqdn, err := dnsname.ToFQDN(domain)
if err != nil {
return OSConfig{}, fmt.Errorf("parsing search domains %q: %w", line, err)
}
config.SearchDomains = append(config.SearchDomains, fqdn)
continue
}
}
@@ -82,6 +75,53 @@ func readResolvConf() (OSConfig, error) {
return config, nil
}
func readResolvFile(path string) (OSConfig, error) {
var config OSConfig
f, err := os.Open(path)
if err != nil {
return config, err
}
defer f.Close()
return readResolv(f)
}
// readResolvConf reads DNS configuration from /etc/resolv.conf.
func readResolvConf() (OSConfig, error) {
return readResolvFile(resolvConf)
}
// resolvOwner returns the apparent owner of the resolv.conf
// configuration in bs - one of "resolvconf", "systemd-resolved" or
// "NetworkManager", or "" if no known owner was found.
func resolvOwner(bs []byte) string {
b := bytes.NewBuffer(bs)
for {
line, err := b.ReadString('\n')
if err != nil {
return ""
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if line[0] != '#' {
// First non-empty, non-comment line. Assume the owner
// isn't hiding further down.
return ""
}
if strings.Contains(line, "systemd-resolved") {
return "systemd-resolved"
} else if strings.Contains(line, "NetworkManager") {
return "NetworkManager"
} else if strings.Contains(line, "resolvconf") {
return "resolvconf"
}
}
}
// isResolvedRunning reports whether systemd-resolved is running on the system,
// even if it is not managing the system DNS settings.
func isResolvedRunning() bool {
@@ -110,77 +150,171 @@ func isResolvedRunning() bool {
// or as cleanup if the program terminates unexpectedly.
type directManager struct{}
func newDirectManager() directManager {
return directManager{}
func newDirectManager() (directManager, error) {
return directManager{}, nil
}
func (m directManager) SetDNS(config OSConfig) error {
// Write the tsConf file.
buf := new(bytes.Buffer)
writeResolvConf(buf, config.Nameservers, config.Domains)
if err := atomicfile.WriteFile(tsConf, buf.Bytes(), 0644); err != nil {
return err
}
if linkPath, err := os.Readlink(resolvConf); err != nil {
// Remove any old backup that may exist.
os.Remove(backupConf)
// Backup the existing /etc/resolv.conf file.
contents, err := ioutil.ReadFile(resolvConf)
// If the original did not exist, still back up an empty file.
// The presence of a backup file is the way we know that Up ran.
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := atomicfile.WriteFile(backupConf, contents, 0644); err != nil {
return err
}
} else if linkPath != tsConf {
// Backup the existing symlink.
os.Remove(backupConf)
if err := os.Symlink(linkPath, backupConf); err != nil {
return err
}
} else {
// Nothing to do, resolvConf already points to tsConf.
return nil
}
os.Remove(resolvConf)
if err := os.Symlink(tsConf, resolvConf); err != nil {
return err
}
if isResolvedRunning() {
exec.Command("systemctl", "restart", "systemd-resolved.service").Run() // Best-effort.
}
return nil
}
func (m directManager) RoutingMode() RoutingMode {
return RoutingModeNone
}
func (m directManager) Close() error {
if _, err := os.Stat(backupConf); err != nil {
// If the backup file does not exist, then Up never ran successfully.
// ownedByTailscale reports whether /etc/resolv.conf seems to be a
// tailscale-managed file.
func (m directManager) ownedByTailscale() (bool, error) {
st, err := os.Stat(resolvConf)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
if !st.Mode().IsRegular() {
return false, nil
}
bs, err := ioutil.ReadFile(resolvConf)
if err != nil {
return false, err
}
if bytes.Contains(bs, []byte("generated by tailscale")) {
return true, nil
}
return false, nil
}
// backupConfig creates or updates a backup of /etc/resolv.conf, if
// resolv.conf does not currently contain a Tailscale-managed config.
func (m directManager) backupConfig() error {
if _, err := os.Stat(resolvConf); err != nil {
if os.IsNotExist(err) {
// No resolv.conf, nothing to back up. Also get rid of any
// existing backup file, to avoid restoring something old.
os.Remove(backupConf)
return nil
}
return err
}
if ln, err := os.Readlink(resolvConf); err != nil {
owned, err := m.ownedByTailscale()
if err != nil {
return err
} else if ln != tsConf {
return fmt.Errorf("resolv.conf is not a symlink to %s", tsConf)
}
if owned {
return nil
}
return os.Rename(resolvConf, backupConf)
}
func (m directManager) restoreBackup() error {
if _, err := os.Stat(backupConf); err != nil {
if os.IsNotExist(err) {
// No backup, nothing we can do.
return nil
}
return err
}
owned, err := m.ownedByTailscale()
if err != nil {
return err
}
if _, err := os.Stat(resolvConf); err != nil && !os.IsNotExist(err) {
return err
}
resolvConfExists := !os.IsNotExist(err)
if resolvConfExists && !owned {
// There's already a non-tailscale config in place, get rid of
// our backup.
os.Remove(backupConf)
return nil
}
// We own resolv.conf, and a backup exists.
if err := os.Rename(backupConf, resolvConf); err != nil {
return err
}
return nil
}
func (m directManager) SetDNS(config OSConfig) error {
if config.IsZero() {
if err := m.restoreBackup(); err != nil {
return err
}
} else {
if err := m.backupConfig(); err != nil {
return err
}
buf := new(bytes.Buffer)
writeResolvConf(buf, config.Nameservers, config.SearchDomains)
if err := atomicfile.WriteFile(resolvConf, buf.Bytes(), 0644); err != nil {
return err
}
}
// We might have taken over a configuration managed by resolved,
// in which case it will notice this on restart and gracefully
// start using our configuration. This shouldn't happen because we
// try to manage DNS through resolved when it's around, but as a
// best-effort fallback if we messed up the detection, try to
// restart resolved to make the system configuration consistent.
if isResolvedRunning() {
exec.Command("systemctl", "restart", "systemd-resolved.service").Run()
}
return nil
}
func (m directManager) SupportsSplitDNS() bool {
return false
}
func (m directManager) GetBaseConfig() (OSConfig, error) {
owned, err := m.ownedByTailscale()
if err != nil {
return OSConfig{}, err
}
fileToRead := resolvConf
if owned {
fileToRead = backupConf
}
return readResolvFile(fileToRead)
}
func (m directManager) Close() error {
// We used to keep a file for the tailscale config and symlinked
// to it, but then we stopped because /etc/resolv.conf being a
// symlink to surprising places breaks snaps and other sandboxing
// things. Clean it up if it's still there.
os.Remove("/etc/resolv.tailscale.conf")
if _, err := os.Stat(backupConf); err != nil {
if os.IsNotExist(err) {
// No backup, nothing we can do.
return nil
}
return err
}
owned, err := m.ownedByTailscale()
if err != nil {
return err
}
_, err = os.Stat(resolvConf)
if err != nil && !os.IsNotExist(err) {
return err
}
resolvConfExists := !os.IsNotExist(err)
if resolvConfExists && !owned {
// There's already a non-tailscale config in place, get rid of
// our backup.
os.Remove(backupConf)
return nil
}
// We own resolv.conf, and a backup exists.
if err := os.Rename(backupConf, resolvConf); err != nil {
return err
}
os.Remove(tsConf)
if isResolvedRunning() {
exec.Command("systemctl", "restart", "systemd-resolved.service").Run() // Best-effort.

View File

@@ -5,12 +5,14 @@
package dns
import (
"strings"
"time"
"inet.af/netaddr"
"tailscale.com/net/dns/resolver"
"tailscale.com/net/tsaddr"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
"tailscale.com/wgengine/monitor"
)
@@ -45,7 +47,6 @@ func NewManager(logf logger.Logf, oscfg OSConfigurator, linkMon *monitor.Mon) *M
resolver: resolver.New(logf, linkMon),
os: oscfg,
}
m.logf("using %T", m.os)
return m
}
@@ -53,46 +54,154 @@ func NewManager(logf logger.Logf, oscfg OSConfigurator, linkMon *monitor.Mon) *M
func (m *Manager) Set(cfg Config) error {
m.logf("Set: %+v", cfg)
if len(cfg.DefaultResolvers) == 0 {
// TODO: make other settings work even if you didn't set a
// default resolver. For now, no default resolvers == no
// managed DNS config.
cfg = Config{}
}
resolverCfg := resolver.Config{
Hosts: cfg.Hosts,
LocalDomains: cfg.AuthoritativeSuffixes,
Routes: map[string][]netaddr.IPPort{},
}
osCfg := OSConfig{
Domains: cfg.SearchDomains,
}
// We must proxy through quad-100 if MagicDNS hosts are in
// use, or there are any per-domain routes.
mustProxy := len(cfg.Hosts) > 0 || len(cfg.Routes) > 0
if mustProxy {
osCfg.Nameservers = []netaddr.IP{tsaddr.TailscaleServiceIP()}
resolverCfg.Routes["."] = cfg.DefaultResolvers
for suffix, resolvers := range cfg.Routes {
resolverCfg.Routes[suffix] = resolvers
}
} else {
for _, resolver := range cfg.DefaultResolvers {
osCfg.Nameservers = append(osCfg.Nameservers, resolver.IP)
}
}
if err := m.resolver.SetConfig(resolverCfg); err != nil {
rcfg, ocfg, err := m.compileConfig(cfg)
if err != nil {
return err
}
if err := m.os.SetDNS(osCfg); err != nil {
m.logf("Resolvercfg: %+v", rcfg)
m.logf("OScfg: %+v", ocfg)
if err := m.resolver.SetConfig(rcfg); err != nil {
return err
}
if err := m.os.SetDNS(ocfg); err != nil {
return err
}
return nil
}
// compileConfig converts cfg into a quad-100 resolver configuration
// and an OS-level configuration.
func (m *Manager) compileConfig(cfg Config) (resolver.Config, OSConfig, error) {
// Deal with trivial configs first.
switch {
case !cfg.needsOSResolver():
// Set search domains, but nothing else. This also covers the
// case where cfg is entirely zero, in which case these
// configs clear all Tailscale DNS settings.
return resolver.Config{}, OSConfig{
SearchDomains: cfg.SearchDomains,
}, nil
case cfg.hasDefaultResolversOnly():
// Trivial CorpDNS configuration, just override the OS
// resolver.
return resolver.Config{}, OSConfig{
Nameservers: toIPsOnly(cfg.DefaultResolvers),
SearchDomains: cfg.SearchDomains,
}, nil
case cfg.hasDefaultResolvers():
// Default resolvers plus other stuff always ends up proxying
// through quad-100.
rcfg := resolver.Config{
Routes: map[dnsname.FQDN][]netaddr.IPPort{
".": cfg.DefaultResolvers,
},
Hosts: cfg.Hosts,
LocalDomains: cfg.AuthoritativeSuffixes,
}
for suffix, resolvers := range cfg.Routes {
rcfg.Routes[suffix] = resolvers
}
ocfg := OSConfig{
Nameservers: []netaddr.IP{tsaddr.TailscaleServiceIP()},
SearchDomains: cfg.SearchDomains,
}
return rcfg, ocfg, nil
}
// From this point on, we're figuring out split DNS
// configurations. The possible cases don't return directly any
// more, because as a final step we have to handle the case where
// the OS can't do split DNS.
var rcfg resolver.Config
var ocfg OSConfig
if !cfg.hasHosts() && cfg.singleResolverSet() != nil && m.os.SupportsSplitDNS() {
// Split DNS configuration requested, where all split domains
// go to the same resolvers. We can let the OS do it.
return resolver.Config{}, OSConfig{
Nameservers: toIPsOnly(cfg.singleResolverSet()),
SearchDomains: cfg.SearchDomains,
MatchDomains: cfg.matchDomains(),
}, nil
}
// Split DNS configuration with either multiple upstream routes,
// or routes + MagicDNS, or just MagicDNS, or on an OS that cannot
// split-DNS. Install a split config pointing at quad-100.
rcfg = resolver.Config{
Hosts: cfg.Hosts,
LocalDomains: cfg.AuthoritativeSuffixes,
Routes: map[dnsname.FQDN][]netaddr.IPPort{},
}
for suffix, resolvers := range cfg.Routes {
rcfg.Routes[suffix] = resolvers
}
ocfg = OSConfig{
Nameservers: []netaddr.IP{tsaddr.TailscaleServiceIP()},
SearchDomains: cfg.SearchDomains,
}
// If the OS can't do native split-dns, read out the underlying
// resolver config and blend it into our config.
if m.os.SupportsSplitDNS() {
ocfg.MatchDomains = cfg.matchDomains()
} else {
bcfg, err := m.os.GetBaseConfig()
if err != nil {
// Temporary hack to make OSes where split-DNS isn't fully
// implemented yet not completely crap out, but instead
// fall back to quad-9 as a hardcoded "backup resolver".
//
// This codepath currently only triggers when opted into
// the split-DNS feature server side, and when at least
// one search domain is something within tailscale.com, so
// we don't accidentally leak unstable user DNS queries to
// quad-9 if we accidentally go down this codepath.
canUseHack := false
for _, dom := range cfg.SearchDomains {
if strings.HasSuffix(dom.WithoutTrailingDot(), ".tailscale.com") {
canUseHack = true
break
}
}
if !canUseHack {
return resolver.Config{}, OSConfig{}, err
}
bcfg = OSConfig{
Nameservers: []netaddr.IP{netaddr.IPv4(9, 9, 9, 9)},
}
}
rcfg.Routes["."] = toIPPorts(bcfg.Nameservers)
ocfg.SearchDomains = append(ocfg.SearchDomains, bcfg.SearchDomains...)
}
return rcfg, ocfg, nil
}
// toIPsOnly returns only the IP portion of ipps.
// TODO: this discards port information on the assumption that we're
// always pointing at port 53.
// https://github.com/tailscale/tailscale/issues/1666 tracks making
// that not true, if we ever want to.
func toIPsOnly(ipps []netaddr.IPPort) (ret []netaddr.IP) {
ret = make([]netaddr.IP, 0, len(ipps))
for _, ipp := range ipps {
ret = append(ret, ipp.IP)
}
return ret
}
func toIPPorts(ips []netaddr.IP) (ret []netaddr.IPPort) {
ret = make([]netaddr.IPPort, 0, len(ips))
for _, ip := range ips {
ret = append(ret, netaddr.IPPort{IP: ip, Port: 53})
}
return ret
}
func (m *Manager) EnqueueRequest(bs []byte, from netaddr.IPPort) error {
return m.resolver.EnqueueRequest(bs, from)
}
@@ -113,7 +222,11 @@ func (m *Manager) Down() error {
// in case the Tailscale daemon terminated without closing the router.
// No other state needs to be instantiated before this runs.
func Cleanup(logf logger.Logf, interfaceName string) {
oscfg := NewOSConfigurator(logf, interfaceName)
oscfg, err := NewOSConfigurator(logf, interfaceName)
if err != nil {
logf("creating dns cleanup: %v", err)
return
}
dns := NewManager(logf, oscfg, nil)
if err := dns.Down(); err != nil {
logf("dns down: %v", err)

View File

@@ -8,7 +8,7 @@ package dns
import "tailscale.com/types/logger"
func NewOSConfigurator(logger.Logf, string) OSConfigurator {
func NewOSConfigurator(logger.Logf, string) (OSConfigurator, error) {
// TODO(dmytro): on darwin, we should use a macOS-specific method such as scutil.
// This is currently not implemented. Editing /etc/resolv.conf does not work,
// as most applications use the system resolver, which disregards it.

View File

@@ -4,11 +4,25 @@
package dns
import "tailscale.com/types/logger"
import (
"fmt"
"io/ioutil"
"os"
func NewOSConfigurator(logf logger.Logf, _ string) OSConfigurator {
switch {
case isResolvconfActive():
"tailscale.com/types/logger"
)
func NewOSConfigurator(logf logger.Logf, _ string) (OSConfigurator, error) {
bs, err := ioutil.ReadFile("/etc/resolv.conf")
if os.IsNotExist(err) {
return newDirectManager()
}
if err != nil {
return nil, fmt.Errorf("reading /etc/resolv.conf: %w", err)
}
switch resolvOwner(bs) {
case "resolvconf":
return newResolvconfManager(logf)
default:
return newDirectManager()

View File

@@ -4,18 +4,170 @@
package dns
import "tailscale.com/types/logger"
import (
"bytes"
"context"
"errors"
"fmt"
"io/ioutil"
"os"
"os/exec"
"time"
func NewOSConfigurator(logf logger.Logf, interfaceName string) OSConfigurator {
switch {
// TODO: rework NetworkManager and resolved support.
// case isResolvedActive():
// return newResolvedManager()
// case isNMActive():
// return newNMManager(interfaceName)
case isResolvconfActive():
"github.com/godbus/dbus/v5"
"tailscale.com/types/logger"
)
type kv struct {
k, v string
}
func (kv kv) String() string {
return fmt.Sprintf("%s=%s", kv.k, kv.v)
}
func NewOSConfigurator(logf logger.Logf, interfaceName string) (ret OSConfigurator, err error) {
var debug []kv
dbg := func(k, v string) {
debug = append(debug, kv{k, v})
}
defer func() {
if ret != nil {
dbg("ret", fmt.Sprintf("%T", ret))
}
logf("dns: %v", debug)
}()
bs, err := ioutil.ReadFile("/etc/resolv.conf")
if os.IsNotExist(err) {
dbg("rc", "missing")
return newDirectManager()
}
if err != nil {
return nil, fmt.Errorf("reading /etc/resolv.conf: %w", err)
}
switch resolvOwner(bs) {
case "systemd-resolved":
dbg("rc", "resolved")
if err := dbusPing("org.freedesktop.resolve1", "/org/freedesktop/resolve1"); err != nil {
dbg("resolved", "no")
return newDirectManager()
}
if err := dbusPing("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/DnsManager"); err != nil {
dbg("nm", "no")
return newResolvedManager(logf)
}
dbg("nm", "yes")
if err := nmIsUsingResolved(); err != nil {
dbg("nm-resolved", "no")
return newResolvedManager(logf)
}
dbg("nm-resolved", "yes")
return newNMManager(interfaceName)
case "resolvconf":
dbg("rc", "resolvconf")
if err := resolvconfSourceIsNM(bs); err == nil {
dbg("src-is-nm", "yes")
if err := dbusPing("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/DnsManager"); err == nil {
dbg("nm", "yes")
return newNMManager(interfaceName)
}
dbg("nm", "no")
}
dbg("src-is-nm", "no")
if _, err := exec.LookPath("resolvconf"); err != nil {
dbg("resolvconf", "no")
return newDirectManager()
}
dbg("resolvconf", "yes")
return newResolvconfManager(logf)
case "NetworkManager":
dbg("rc", "nm")
if err := dbusPing("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/DnsManager"); err != nil {
dbg("nm", "no")
return newDirectManager()
}
dbg("nm", "yes")
return newNMManager(interfaceName)
default:
dbg("rc", "unknown")
return newDirectManager()
}
}
func resolvconfSourceIsNM(resolvDotConf []byte) error {
b := bytes.NewBuffer(resolvDotConf)
cfg, err := readResolv(b)
if err != nil {
return fmt.Errorf("parsing /etc/resolv.conf: %w", err)
}
var (
paths = []string{
"/etc/resolvconf/run/interface/NetworkManager",
"/run/resolvconf/interface/NetworkManager",
"/var/run/resolvconf/interface/NetworkManager",
"/run/resolvconf/interfaces/NetworkManager",
"/var/run/resolvconf/interfaces/NetworkManager",
}
nmCfg OSConfig
found bool
)
for _, path := range paths {
nmCfg, err = readResolvFile(path)
if os.IsNotExist(err) {
continue
} else if err != nil {
return err
}
found = true
break
}
if !found {
return errors.New("NetworkManager resolvconf snippet not found")
}
if !nmCfg.Equal(cfg) {
return errors.New("NetworkManager config not applied by resolvconf")
}
return nil
}
func nmIsUsingResolved() error {
conn, err := dbus.SystemBus()
if err != nil {
// DBus probably not running.
return err
}
nm := conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath("/org/freedesktop/NetworkManager/DnsManager"))
v, err := nm.GetProperty("org.freedesktop.NetworkManager.DnsManager.Mode")
if err != nil {
return fmt.Errorf("getting NM mode: %w", err)
}
mode, ok := v.Value().(string)
if !ok {
return fmt.Errorf("unexpected type %T for NM DNS mode", v.Value())
}
if mode != "systemd-resolved" {
return errors.New("NetworkManager is not using systemd-resolved for DNS")
}
return nil
}
func dbusPing(name, objectPath string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
conn, err := dbus.SystemBus()
if err != nil {
// DBus probably not running.
return err
}
obj := conn.Object(name, dbus.ObjectPath(objectPath))
call := obj.CallWithContext(ctx, "org.freedesktop.DBus.Peer.Ping", 0)
return call.Err
}

View File

@@ -6,6 +6,6 @@ package dns
import "tailscale.com/types/logger"
func NewOSConfigurator(logger.Logf, string) OSConfigurator {
func NewOSConfigurator(logger.Logf, string) (OSConfigurator, error) {
return newDirectManager()
}

440
net/dns/manager_test.go Normal file
View File

@@ -0,0 +1,440 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package dns
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"inet.af/netaddr"
"tailscale.com/net/dns/resolver"
"tailscale.com/util/dnsname"
)
type fakeOSConfigurator struct {
SplitDNS bool
BaseConfig OSConfig
OSConfig OSConfig
ResolverConfig resolver.Config
}
func (c *fakeOSConfigurator) SetDNS(cfg OSConfig) error {
if !c.SplitDNS && len(cfg.MatchDomains) > 0 {
panic("split DNS config passed to non-split OSConfigurator")
}
c.OSConfig = cfg
return nil
}
func (c *fakeOSConfigurator) SetResolver(cfg resolver.Config) {
c.ResolverConfig = cfg
}
func (c *fakeOSConfigurator) SupportsSplitDNS() bool {
return c.SplitDNS
}
func (c *fakeOSConfigurator) GetBaseConfig() (OSConfig, error) {
return c.BaseConfig, nil
}
func (c *fakeOSConfigurator) Close() error { return nil }
func TestManager(t *testing.T) {
// Note: these tests assume that it's safe to switch the
// OSConfigurator's split-dns support on and off between Set
// calls. Empirically this is currently true, because we reprobe
// the support every time we generate configs. It would be
// reasonable to make this unsupported as well, in which case
// these tests will need tweaking.
tests := []struct {
name string
in Config
split bool
bs OSConfig
os OSConfig
rs resolver.Config
}{
{
name: "empty",
},
{
name: "search-only",
in: Config{
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
os: OSConfig{
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
},
{
name: "corp",
in: Config{
DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
os: OSConfig{
Nameservers: mustIPs("1.1.1.1", "9.9.9.9"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
},
{
name: "corp-split",
in: Config{
DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: mustIPs("1.1.1.1", "9.9.9.9"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
},
{
name: "corp-magic",
in: Config{
DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
AuthoritativeSuffixes: fqdns("ts.com"),
},
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(".", "1.1.1.1:53", "9.9.9.9:53"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
},
{
name: "corp-magic-split",
in: Config{
DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
AuthoritativeSuffixes: fqdns("ts.com"),
},
split: true,
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(".", "1.1.1.1:53", "9.9.9.9:53"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
},
{
name: "corp-routes",
in: Config{
DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"),
Routes: upstreams("corp.com", "2.2.2.2:53"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(
".", "1.1.1.1:53", "9.9.9.9:53",
"corp.com.", "2.2.2.2:53"),
},
},
{
name: "corp-routes-split",
in: Config{
DefaultResolvers: mustIPPs("1.1.1.1:53", "9.9.9.9:53"),
Routes: upstreams("corp.com", "2.2.2.2:53"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
rs: resolver.Config{
Routes: upstreams(
".", "1.1.1.1:53", "9.9.9.9:53",
"corp.com.", "2.2.2.2:53"),
},
},
{
name: "routes",
in: Config{
Routes: upstreams("corp.com", "2.2.2.2:53"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"),
},
rs: resolver.Config{
Routes: upstreams(
".", "8.8.8.8:53",
"corp.com.", "2.2.2.2:53"),
},
},
{
name: "routes-split",
in: Config{
Routes: upstreams("corp.com", "2.2.2.2:53"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: mustIPs("2.2.2.2"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("corp.com"),
},
},
{
name: "routes-multi",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2:53",
"bigco.net", "3.3.3.3:53"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"),
},
rs: resolver.Config{
Routes: upstreams(
".", "8.8.8.8:53",
"corp.com.", "2.2.2.2:53",
"bigco.net.", "3.3.3.3:53"),
},
},
{
name: "routes-multi-split",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2:53",
"bigco.net", "3.3.3.3:53"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("bigco.net", "corp.com"),
},
rs: resolver.Config{
Routes: upstreams(
"corp.com.", "2.2.2.2:53",
"bigco.net.", "3.3.3.3:53"),
},
},
{
name: "magic",
in: Config{
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
AuthoritativeSuffixes: fqdns("ts.com"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"),
},
rs: resolver.Config{
Routes: upstreams(".", "8.8.8.8:53"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
},
{
name: "magic-split",
in: Config{
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
AuthoritativeSuffixes: fqdns("ts.com"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("ts.com"),
},
rs: resolver.Config{
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
},
{
name: "routes-magic",
in: Config{
Routes: upstreams("corp.com", "2.2.2.2:53"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
AuthoritativeSuffixes: fqdns("ts.com"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
bs: OSConfig{
Nameservers: mustIPs("8.8.8.8"),
SearchDomains: fqdns("coffee.shop"),
},
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf", "coffee.shop"),
},
rs: resolver.Config{
Routes: upstreams(
"corp.com.", "2.2.2.2:53",
".", "8.8.8.8:53"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
},
{
name: "routes-magic-split",
in: Config{
Routes: upstreams("corp.com", "2.2.2.2:53"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
AuthoritativeSuffixes: fqdns("ts.com"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
},
split: true,
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("corp.com", "ts.com"),
},
rs: resolver.Config{
Routes: upstreams("corp.com.", "2.2.2.2:53"),
Hosts: hosts(
"dave.ts.com.", "1.2.3.4",
"bradfitz.ts.com.", "2.3.4.5"),
LocalDomains: fqdns("ts.com."),
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
f := fakeOSConfigurator{
SplitDNS: test.split,
BaseConfig: test.bs,
}
m := NewManager(t.Logf, &f, nil)
m.resolver.TestOnlySetHook(f.SetResolver)
if err := m.Set(test.in); err != nil {
t.Fatalf("m.Set: %v", err)
}
tr := cmp.Transformer("ipStr", func(ip netaddr.IP) string { return ip.String() })
if diff := cmp.Diff(f.OSConfig, test.os, tr, cmpopts.EquateEmpty()); diff != "" {
t.Errorf("wrong OSConfig (-got+want)\n%s", diff)
}
if diff := cmp.Diff(f.ResolverConfig, test.rs, tr, cmpopts.EquateEmpty()); diff != "" {
t.Errorf("wrong resolver.Config (-got+want)\n%s", diff)
}
})
}
}
func mustIPs(strs ...string) (ret []netaddr.IP) {
for _, s := range strs {
ret = append(ret, netaddr.MustParseIP(s))
}
return ret
}
func mustIPPs(strs ...string) (ret []netaddr.IPPort) {
for _, s := range strs {
ret = append(ret, netaddr.MustParseIPPort(s))
}
return ret
}
func fqdns(strs ...string) (ret []dnsname.FQDN) {
for _, s := range strs {
fqdn, err := dnsname.ToFQDN(s)
if err != nil {
panic(err)
}
ret = append(ret, fqdn)
}
return ret
}
func hosts(strs ...string) (ret map[dnsname.FQDN][]netaddr.IP) {
var key dnsname.FQDN
ret = map[dnsname.FQDN][]netaddr.IP{}
for _, s := range strs {
if ip, err := netaddr.ParseIP(s); err == nil {
if key == "" {
panic("IP provided before name")
}
ret[key] = append(ret[key], ip)
} else {
fqdn, err := dnsname.ToFQDN(s)
if err != nil {
panic(err)
}
key = fqdn
}
}
return ret
}
func upstreams(strs ...string) (ret map[dnsname.FQDN][]netaddr.IPPort) {
var key dnsname.FQDN
ret = map[dnsname.FQDN][]netaddr.IPPort{}
for _, s := range strs {
if ipp, err := netaddr.ParseIPPort(s); err == nil {
if key == "" {
panic("IPPort provided before suffix")
}
ret[key] = append(ret[key], ipp)
} else {
fqdn, err := dnsname.ToFQDN(s)
if err != nil {
panic(err)
}
key = fqdn
}
}
return ret
}

View File

@@ -5,31 +5,58 @@
package dns
import (
"errors"
"fmt"
"os/exec"
"strings"
"syscall"
"time"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
"inet.af/netaddr"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
)
const (
ipv4RegBase = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters`
ipv6RegBase = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters`
// the GUID is randomly generated. At present, Tailscale installs
// zero or one NRPT rules, so hardcoding a single GUID everywhere
// is fine.
nrptBase = `SYSTEM\CurrentControlSet\services\Dnscache\Parameters\DnsPolicyConfig\{5abe529b-675b-4486-8459-25a634dacc23}`
nrptOverrideDNS = 0x8 // bitmask value for "use the provided override DNS resolvers"
versionKey = `SOFTWARE\Microsoft\Windows NT\CurrentVersion`
)
type windowsManager struct {
logf logger.Logf
guid string
logf logger.Logf
guid string
nrptWorks bool
}
func NewOSConfigurator(logf logger.Logf, interfaceName string) OSConfigurator {
return windowsManager{
logf: logf,
guid: interfaceName,
func NewOSConfigurator(logf logger.Logf, interfaceName string) (OSConfigurator, error) {
ret := windowsManager{
logf: logf,
guid: interfaceName,
nrptWorks: !isWindows7(),
}
// Best-effort: if our NRPT rule exists, try to delete it. Unlike
// per-interface configuration, NRPT rules survive the unclean
// termination of the Tailscale process, and depending on the
// rule, it may prevent us from reaching login.tailscale.com to
// boot up. The bootstrap resolver logic will save us, but it
// slows down start-up a bunch.
if ret.nrptWorks {
ret.delKey(nrptBase)
}
return ret, nil
}
// keyOpenTimeout is how long we wait for a registry key to
@@ -38,37 +65,86 @@ func NewOSConfigurator(logf logger.Logf, interfaceName string) OSConfigurator {
// can end up racing with that.
const keyOpenTimeout = 20 * time.Second
func setRegistryString(path, name, value string) error {
func (m windowsManager) openKey(path string) (registry.Key, error) {
key, err := openKeyWait(registry.LOCAL_MACHINE, path, registry.SET_VALUE, keyOpenTimeout)
if err != nil {
return fmt.Errorf("opening %s: %w", path, err)
return 0, fmt.Errorf("opening %s: %w", path, err)
}
defer key.Close()
return key, nil
}
err = key.SetStringValue(name, value)
if err != nil {
return fmt.Errorf("setting %s[%s]: %w", path, name, err)
func (m windowsManager) ifPath(basePath string) string {
return fmt.Sprintf(`%s\Interfaces\%s`, basePath, m.guid)
}
func (m windowsManager) delKey(path string) error {
if err := registry.DeleteKey(registry.LOCAL_MACHINE, path); err != nil && err != registry.ErrNotExist {
return err
}
return nil
}
func (m windowsManager) setNameservers(basePath string, nameservers []string) error {
path := fmt.Sprintf(`%s\Interfaces\%s`, basePath, m.guid)
value := strings.Join(nameservers, ",")
return setRegistryString(path, "NameServer", value)
func delValue(key registry.Key, name string) error {
if err := key.DeleteValue(name); err != nil && err != registry.ErrNotExist {
return err
}
return nil
}
func (m windowsManager) setDomains(basePath string, domains []string) error {
path := fmt.Sprintf(`%s\Interfaces\%s`, basePath, m.guid)
value := strings.Join(domains, ",")
return setRegistryString(path, "SearchList", value)
// setSplitDNS configures an NRPT (Name Resolution Policy Table) rule
// to resolve queries for domains using resolvers, rather than the
// system's "primary" resolver.
//
// If no resolvers are provided, the Tailscale NRPT rule is deleted.
func (m windowsManager) setSplitDNS(resolvers []netaddr.IP, domains []dnsname.FQDN) error {
if len(resolvers) == 0 {
return m.delKey(nrptBase)
}
servers := make([]string, 0, len(resolvers))
for _, resolver := range resolvers {
servers = append(servers, resolver.String())
}
doms := make([]string, 0, len(domains))
for _, domain := range domains {
// NRPT rules must have a leading dot, which is not usual for
// DNS search paths.
doms = append(doms, "."+domain.WithoutTrailingDot())
}
// CreateKey is actually open-or-create, which suits us fine.
key, _, err := registry.CreateKey(registry.LOCAL_MACHINE, nrptBase, registry.SET_VALUE)
if err != nil {
return fmt.Errorf("opening %s: %w", nrptBase, err)
}
defer key.Close()
if err := key.SetDWordValue("Version", 1); err != nil {
return err
}
if err := key.SetStringsValue("Name", doms); err != nil {
return err
}
if err := key.SetStringValue("GenericDNSServers", strings.Join(servers, "; ")); err != nil {
return err
}
if err := key.SetDWordValue("ConfigOptions", nrptOverrideDNS); err != nil {
return err
}
return nil
}
func (m windowsManager) SetDNS(config OSConfig) error {
// setPrimaryDNS sets the given resolvers and domains as the Tailscale
// interface's DNS configuration.
// If resolvers is non-empty, those resolvers become the system's
// "primary" resolvers.
// domains can be set without resolvers, which just contributes new
// paths to the global DNS search list.
func (m windowsManager) setPrimaryDNS(resolvers []netaddr.IP, domains []dnsname.FQDN) error {
var ipsv4 []string
var ipsv6 []string
for _, ip := range config.Nameservers {
for _, ip := range resolvers {
if ip.Is4() {
ipsv4 = append(ipsv4, ip.String())
} else {
@@ -76,19 +152,111 @@ func (m windowsManager) SetDNS(config OSConfig) error {
}
}
if err := m.setNameservers(ipv4RegBase, ipsv4); err != nil {
domStrs := make([]string, 0, len(domains))
for _, dom := range domains {
domStrs = append(domStrs, dom.WithoutTrailingDot())
}
key4, err := m.openKey(m.ifPath(ipv4RegBase))
if err != nil {
return err
}
if err := m.setDomains(ipv4RegBase, config.Domains); err != nil {
defer key4.Close()
if len(ipsv4) == 0 {
if err := delValue(key4, "NameServer"); err != nil {
return err
}
} else if err := key4.SetStringValue("NameServer", strings.Join(ipsv4, ",")); err != nil {
return err
}
if err := m.setNameservers(ipv6RegBase, ipsv6); err != nil {
if len(domains) == 0 {
if err := delValue(key4, "SearchList"); err != nil {
return err
}
} else if err := key4.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil {
return err
}
if err := m.setDomains(ipv6RegBase, config.Domains); err != nil {
key6, err := m.openKey(m.ifPath(ipv6RegBase))
if err != nil {
return err
}
defer key6.Close()
if len(ipsv6) == 0 {
if err := delValue(key6, "NameServer"); err != nil {
return err
}
} else if err := key6.SetStringValue("NameServer", strings.Join(ipsv6, ",")); err != nil {
return err
}
if len(domains) == 0 {
if err := delValue(key6, "SearchList"); err != nil {
return err
}
} else if err := key6.SetStringValue("SearchList", strings.Join(domStrs, ",")); err != nil {
return err
}
// Disable LLMNR on the Tailscale interface. We don't do
// multicast, and we certainly don't do LLMNR, so it's pointless
// to make Windows try it.
if err := key4.SetDWordValue("EnableMulticast", 0); err != nil {
return err
}
if err := key6.SetDWordValue("EnableMulticast", 0); err != nil {
return err
}
return nil
}
func (m windowsManager) SetDNS(cfg OSConfig) error {
// We can configure Windows DNS in one of two ways:
//
// - In primary DNS mode, we set the NameServer and SearchList
// registry keys on our interface. Because our interface metric
// is very low, this turns us into the one and only "primary"
// resolver for the OS, i.e. all queries flow to the
// resolver(s) we specify.
// - In split DNS mode, we set the Domain registry key on our
// interface (which adds that domain to the global search list,
// but does not contribute other DNS configuration from the
// interface), and configure an NRPT (Name Resolution Policy
// Table) rule to route queries for our suffixes to the
// provided resolver.
//
// When switching modes, we delete all the configuration related
// to the other mode, so these two are an XOR.
//
// Windows actually supports much more advanced configurations as
// well, with arbitrary routing of hosts and suffixes to arbitrary
// resolvers. However, we use it in a "simple" split domain
// configuration only, routing one set of things to the "split"
// resolver and the rest to the primary.
if len(cfg.MatchDomains) == 0 {
if err := m.setSplitDNS(nil, nil); err != nil {
return err
}
if err := m.setPrimaryDNS(cfg.Nameservers, cfg.SearchDomains); err != nil {
return err
}
} else if !m.nrptWorks {
return errors.New("cannot set per-domain resolvers on Windows 7")
} else {
if err := m.setSplitDNS(cfg.Nameservers, cfg.MatchDomains); err != nil {
return err
}
// Still set search domains on the interface, since NRPT only
// handles query routing and not search domain expansion.
if err := m.setPrimaryDNS(nil, cfg.SearchDomains); err != nil {
return err
}
}
// Force DNS re-registration in Active Directory. What we actually
// care about is that this command invokes the undocumented hidden
@@ -97,26 +265,134 @@ func (m windowsManager) SetDNS(config OSConfig) error {
// effect.
//
// This command can take a few seconds to run, so run it async, best effort.
//
// After re-registering DNS, also flush the DNS cache to clear out
// any cached split-horizon queries that are no longer the correct
// answer.
go func() {
t0 := time.Now()
m.logf("running ipconfig /registerdns ...")
cmd := exec.Command("ipconfig", "/registerdns")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
err := cmd.Run()
d := time.Since(t0).Round(time.Millisecond)
if err := cmd.Run(); err != nil {
if err != nil {
m.logf("error running ipconfig /registerdns after %v: %v", d, err)
} else {
m.logf("ran ipconfig /registerdns in %v", d)
}
t0 = time.Now()
m.logf("running ipconfig /registerdns ...")
cmd = exec.Command("ipconfig", "/flushdns")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
err = cmd.Run()
d = time.Since(t0).Round(time.Millisecond)
if err != nil {
m.logf("error running ipconfig /flushdns after %v: %v", d, err)
} else {
m.logf("ran ipconfig /flushdns in %v", d)
}
}()
return nil
}
func (m windowsManager) RoutingMode() RoutingMode {
return RoutingModeNone
func (m windowsManager) SupportsSplitDNS() bool {
return m.nrptWorks
}
func (m windowsManager) Close() error {
return m.SetDNS(OSConfig{})
}
func (m windowsManager) GetBaseConfig() (OSConfig, error) {
resolvers, err := m.getBasePrimaryResolver()
if err != nil {
return OSConfig{}, err
}
return OSConfig{
Nameservers: resolvers,
// Don't return any search domains here, because even Windows
// 7 correctly handles blending search domains from multiple
// sources, and any search domains we add here will get tacked
// onto the Tailscale config unnecessarily.
}, nil
}
// getBasePrimaryResolver returns a guess of the non-Tailscale primary
// resolver on the system.
// It's used on Windows 7 to emulate split DNS by trying to figure out
// what the "previous" primary resolver was. It might be wrong, or
// incomplete.
func (m windowsManager) getBasePrimaryResolver() (resolvers []netaddr.IP, err error) {
tsGUID, err := windows.GUIDFromString(m.guid)
if err != nil {
return nil, err
}
tsLUID, err := winipcfg.LUIDFromGUID(&tsGUID)
if err != nil {
return nil, err
}
ifrows, err := winipcfg.GetIPInterfaceTable(windows.AF_INET)
if err == windows.ERROR_NOT_FOUND {
// IPv4 seems disabled, try to get interface metrics from IPv6 instead.
ifrows, err = winipcfg.GetIPInterfaceTable(windows.AF_INET6)
}
if err != nil {
return nil, err
}
var (
primary winipcfg.LUID
best = ^uint32(0)
)
for _, row := range ifrows {
if !row.Connected {
continue
}
if row.InterfaceLUID == tsLUID {
continue
}
if row.Metric < best {
primary = row.InterfaceLUID
best = row.Metric
}
}
if primary == 0 {
// No resolvers set outside of Tailscale.
return nil, nil
}
ips, err := primary.DNS()
if err != nil {
return nil, err
}
for _, stdip := range ips {
if ip, ok := netaddr.FromStdIP(stdip); ok {
resolvers = append(resolvers, ip)
}
}
return resolvers, nil
}
func isWindows7() bool {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, versionKey, registry.READ)
if err != nil {
// Fail safe, assume Windows 7.
return true
}
ver, _, err := key.GetStringValue("CurrentVersion")
if err != nil {
return true
}
// Careful to not assume anything about version numbers beyond
// 6.3, Microsoft deprecated this registry key and locked its
// value to what it was in Windows 8.1. We can only use this to
// probe for versions before that. Good thing we only need Windows
// 7 (so far).
//
// And yes, Windows 7 is version 6.1. Don't ask.
return ver == "6.1"
}

View File

@@ -9,66 +9,67 @@
package dns
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"os/exec"
"sort"
"time"
"github.com/godbus/dbus/v5"
"inet.af/netaddr"
"tailscale.com/util/dnsname"
"tailscale.com/util/endian"
)
// isNMActive determines if NetworkManager is currently managing system DNS settings.
func isNMActive() bool {
// This is somewhat tricky because NetworkManager supports a number
// of DNS configuration modes. In all cases, we expect it to be installed
// and /etc/resolv.conf to contain a mention of NetworkManager in the comments.
_, err := exec.LookPath("NetworkManager")
if err != nil {
return false
}
f, err := os.Open("/etc/resolv.conf")
if err != nil {
return false
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Bytes()
// Look for the word "NetworkManager" until comments end.
if len(line) > 0 && line[0] != '#' {
return false
}
if bytes.Contains(line, []byte("NetworkManager")) {
return true
}
}
return false
}
const (
highestPriority = int32(-1 << 31)
mediumPriority = int32(1) // Highest priority that doesn't hard-override
lowerPriority = int32(200) // lower than all builtin auto priorities
)
// nmManager uses the NetworkManager DBus API.
type nmManager struct {
interfaceName string
manager dbus.BusObject
dnsManager dbus.BusObject
}
func newNMManager(interfaceName string) nmManager {
return nmManager{
interfaceName: interfaceName,
func newNMManager(interfaceName string) (*nmManager, error) {
conn, err := dbus.SystemBus()
if err != nil {
return nil, err
}
return &nmManager{
interfaceName: interfaceName,
manager: conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath("/org/freedesktop/NetworkManager")),
dnsManager: conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath("/org/freedesktop/NetworkManager/DnsManager")),
}, nil
}
type nmConnectionSettings map[string]map[string]dbus.Variant
func (m nmManager) SetDNS(config OSConfig) error {
func (m *nmManager) SetDNS(config OSConfig) error {
ctx, cancel := context.WithTimeout(context.Background(), reconfigTimeout)
defer cancel()
// conn is a shared connection whose lifecycle is managed by the dbus package.
// We should not interfere with that by closing it.
// NetworkManager only lets you set DNS settings on "active"
// connections, which requires an assigned IP address. This got
// configured before the DNS manager was invoked, but it might
// take a little time for the netlink notifications to propagate
// up. So, keep retrying for the duration of the reconfigTimeout.
var err error
for ctx.Err() == nil {
err = m.trySet(ctx, config)
if err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
return err
}
func (m *nmManager) trySet(ctx context.Context, config OSConfig) error {
conn, err := dbus.SystemBus()
if err != nil {
return fmt.Errorf("connecting to system bus: %w", err)
@@ -136,43 +137,79 @@ func (m nmManager) SetDNS(config OSConfig) error {
}
}
seen := map[dnsname.FQDN]bool{}
var search []string
for _, dom := range config.SearchDomains {
if seen[dom] {
continue
}
seen[dom] = true
search = append(search, dom.WithTrailingDot())
}
for _, dom := range config.MatchDomains {
if seen[dom] {
continue
}
seen[dom] = true
search = append(search, "~"+dom.WithTrailingDot())
}
if len(config.MatchDomains) == 0 {
// Non-split routing requested, add an all-domains match.
search = append(search, "~.")
}
general := settings["connection"]
general["llmnr"] = dbus.MakeVariant(0)
general["mdns"] = dbus.MakeVariant(0)
ipv4Map := settings["ipv4"]
ipv4Map["dns"] = dbus.MakeVariant(dnsv4)
ipv4Map["dns-search"] = dbus.MakeVariant(config.Domains)
ipv4Map["dns-search"] = dbus.MakeVariant(search)
// We should only request priority if we have nameservers to set.
if len(dnsv4) == 0 {
ipv4Map["dns-priority"] = dbus.MakeVariant(100)
ipv4Map["dns-priority"] = dbus.MakeVariant(lowerPriority)
} else if len(config.MatchDomains) > 0 {
// Set a fairly high priority, but don't override all other
// configs when in split-DNS mode.
ipv4Map["dns-priority"] = dbus.MakeVariant(mediumPriority)
} else {
// dns-priority = -1 ensures that we have priority
// over other interfaces, except those exploiting this same trick.
// Ref: https://bugs.launchpad.net/ubuntu/+source/network-manager/+bug/1211110/comments/92.
ipv4Map["dns-priority"] = dbus.MakeVariant(-1)
// Negative priority means only the settings from the most
// negative connection get used. The way this mixes with
// per-domain routing is unclear, but it _seems_ that the
// priority applies after routing has found possible
// candidates for a resolution.
ipv4Map["dns-priority"] = dbus.MakeVariant(highestPriority)
}
// In principle, we should not need set this to true,
// as our interface does not configure any automatic DNS settings (presumably via DHCP).
// All the same, better to be safe.
ipv4Map["ignore-auto-dns"] = dbus.MakeVariant(true)
ipv6Map := settings["ipv6"]
// This is a hack.
// Methods "disabled", "ignore", "link-local" (IPv6 default) prevent us from setting DNS.
// It seems that our only recourse is "manual" or "auto".
// "manual" requires addresses, so we use "auto", which will assign us a random IPv6 /64.
// In IPv6 settings, you're only allowed to provide additional
// static DNS settings in "auto" (SLAAC) or "manual" mode. In
// "manual" mode you also have to specify IP addresses, so we use
// "auto".
//
// NM actually documents that to set just DNS servers, you should
// use "auto" mode and then set ignore auto routes and DNS, which
// basically means "autoconfigure but ignore any autoconfiguration
// results you might get". As a safety, we also say that
// NetworkManager should never try to make us the default route
// (none of its business anyway, we handle our own default
// routing).
ipv6Map["method"] = dbus.MakeVariant("auto")
// Our IPv6 config is a fake, so it should never become the default route.
ipv6Map["never-default"] = dbus.MakeVariant(true)
// Moreover, we should ignore all autoconfigured routes (hopefully none), as they are bogus.
ipv6Map["ignore-auto-routes"] = dbus.MakeVariant(true)
// Finally, set the actual DNS config.
ipv6Map["dns"] = dbus.MakeVariant(dnsv6)
ipv6Map["dns-search"] = dbus.MakeVariant(config.Domains)
if len(dnsv6) == 0 {
ipv6Map["dns-priority"] = dbus.MakeVariant(100)
} else {
ipv6Map["dns-priority"] = dbus.MakeVariant(-1)
}
ipv6Map["ignore-auto-dns"] = dbus.MakeVariant(true)
ipv6Map["never-default"] = dbus.MakeVariant(true)
ipv6Map["dns"] = dbus.MakeVariant(dnsv6)
ipv6Map["dns-search"] = dbus.MakeVariant(search)
if len(dnsv6) == 0 {
ipv6Map["dns-priority"] = dbus.MakeVariant(lowerPriority)
} else if len(config.MatchDomains) > 0 {
// Set a fairly high priority, but don't override all other
// configs when in split-DNS mode.
ipv6Map["dns-priority"] = dbus.MakeVariant(mediumPriority)
} else {
ipv6Map["dns-priority"] = dbus.MakeVariant(highestPriority)
}
// deprecatedProperties are the properties in interface settings
// that are deprecated by NetworkManager.
@@ -189,19 +226,134 @@ func (m nmManager) SetDNS(config OSConfig) error {
delete(ipv6Map, property)
}
err = device.CallWithContext(
ctx, "org.freedesktop.NetworkManager.Device.Reapply", 0,
settings, version, uint32(0),
).Store()
if err != nil {
if call := device.CallWithContext(ctx, "org.freedesktop.NetworkManager.Device.Reapply", 0, settings, version, uint32(0)); call.Err != nil {
return fmt.Errorf("reapply: %w", err)
}
return nil
}
func (m nmManager) RoutingMode() RoutingMode { return RoutingModeNone }
func (m *nmManager) SupportsSplitDNS() bool {
var mode string
v, err := m.dnsManager.GetProperty("org.freedesktop.NetworkManager.DnsManager.Mode")
if err != nil {
return false
}
mode, ok := v.Value().(string)
if !ok {
return false
}
func (m nmManager) Close() error {
return m.SetDNS(OSConfig{})
// Per NM's documentation, it only does split-DNS when it's
// programming dnsmasq or systemd-resolved. All other modes are
// primary-only.
return mode == "dnsmasq" || mode == "systemd-resolved"
}
func (m *nmManager) GetBaseConfig() (OSConfig, error) {
conn, err := dbus.SystemBus()
if err != nil {
return OSConfig{}, err
}
nm := conn.Object("org.freedesktop.NetworkManager", dbus.ObjectPath("/org/freedesktop/NetworkManager/DnsManager"))
v, err := nm.GetProperty("org.freedesktop.NetworkManager.DnsManager.Configuration")
if err != nil {
return OSConfig{}, err
}
cfgs, ok := v.Value().([]map[string]dbus.Variant)
if !ok {
return OSConfig{}, fmt.Errorf("unexpected NM config type %T", v.Value())
}
if len(cfgs) == 0 {
return OSConfig{}, nil
}
type dnsPrio struct {
resolvers []netaddr.IP
domains []string
priority int32
}
order := make([]dnsPrio, 0, len(cfgs)-1)
for _, cfg := range cfgs {
if name, ok := cfg["interface"]; ok {
if s, ok := name.Value().(string); ok && s == m.interfaceName {
// Config for the taislcale interface, skip.
continue
}
}
var p dnsPrio
if v, ok := cfg["nameservers"]; ok {
if ips, ok := v.Value().([]string); ok {
for _, s := range ips {
ip, err := netaddr.ParseIP(s)
if err != nil {
// hmm, what do? Shouldn't really happen.
continue
}
p.resolvers = append(p.resolvers, ip)
}
}
}
if v, ok := cfg["domains"]; ok {
if domains, ok := v.Value().([]string); ok {
p.domains = domains
}
}
if v, ok := cfg["priority"]; ok {
if prio, ok := v.Value().(int32); ok {
p.priority = prio
}
}
order = append(order, p)
}
sort.Slice(order, func(i, j int) bool {
return order[i].priority < order[j].priority
})
var (
ret OSConfig
seenResolvers = map[netaddr.IP]bool{}
seenSearch = map[string]bool{}
)
for _, cfg := range order {
for _, resolver := range cfg.resolvers {
if seenResolvers[resolver] {
continue
}
ret.Nameservers = append(ret.Nameservers, resolver)
seenResolvers[resolver] = true
}
for _, dom := range cfg.domains {
if seenSearch[dom] {
continue
}
fqdn, err := dnsname.ToFQDN(dom)
if err != nil {
continue
}
ret.SearchDomains = append(ret.SearchDomains, fqdn)
seenSearch[dom] = true
}
if cfg.priority < 0 {
// exclusive configurations preempt all other
// configurations, so we're done.
break
}
}
return ret, nil
}
func (m *nmManager) Close() error {
// No need to do anything on close, NetworkManager will delete our
// settings when the tailscale interface goes away.
return nil
}

View File

@@ -6,10 +6,13 @@ package dns
type noopManager struct{}
func (m noopManager) SetDNS(OSConfig) error { return nil }
func (m noopManager) RoutingMode() RoutingMode { return RoutingModeNone }
func (m noopManager) Close() error { return nil }
func NewNoopManager() noopManager {
return noopManager{}
func (m noopManager) SetDNS(OSConfig) error { return nil }
func (m noopManager) SupportsSplitDNS() bool { return false }
func (m noopManager) Close() error { return nil }
func (m noopManager) GetBaseConfig() (OSConfig, error) {
return OSConfig{}, ErrGetBaseConfigNotSupported
}
func NewNoopManager() (noopManager, error) {
return noopManager{}, nil
}

92
net/dns/openresolv.go Normal file
View File

@@ -0,0 +1,92 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package dns
import (
"bytes"
"fmt"
"os/exec"
"strings"
)
// openresolvManager manages DNS configuration using the openresolv
// implementation of the `resolvconf` program.
type openresolvManager struct{}
func newOpenresolvManager() (openresolvManager, error) {
return openresolvManager{}, nil
}
func (m openresolvManager) deleteTailscaleConfig() error {
cmd := exec.Command("resolvconf", "-f", "-d", "tailscale")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("running %s: %s", cmd, out)
}
return nil
}
func (m openresolvManager) SetDNS(config OSConfig) error {
if config.IsZero() {
return m.deleteTailscaleConfig()
}
var stdin bytes.Buffer
writeResolvConf(&stdin, config.Nameservers, config.SearchDomains)
cmd := exec.Command("resolvconf", "-m", "0", "-x", "-a", "tailscale")
cmd.Stdin = &stdin
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("running %s: %s", cmd, out)
}
return nil
}
func (m openresolvManager) SupportsSplitDNS() bool {
return false
}
func (m openresolvManager) GetBaseConfig() (OSConfig, error) {
// List the names of all config snippets openresolv is aware
// of. Snippets get listed in priority order (most to least),
// which we'll exploit later.
bs, err := exec.Command("resolvconf", "-i").CombinedOutput()
if err != nil {
return OSConfig{}, err
}
// Remove the "tailscale" snippet from the list.
args := []string{"-l"}
for _, f := range strings.Split(strings.TrimSpace(string(bs)), " ") {
if f == "tailscale" {
continue
}
args = append(args, f)
}
// List all resolvconf snippets except our own, and parse that as
// a resolv.conf. This effectively generates a blended config of
// "everyone except tailscale", which is what would be in use if
// tailscale hadn't set exclusive mode.
//
// Note that this is not _entirely_ true. To be perfectly correct,
// we should be looking for other interfaces marked exclusive that
// predated tailscale, and stick to only those. However, in
// practice, openresolv uses are generally quite limited, and boil
// down to 1-2 DHCP leases, for which the correct outcome is a
// blended config like the one we produce here.
var buf bytes.Buffer
cmd := exec.Command("resolvconf", args...)
cmd.Stdout = &buf
if err := cmd.Run(); err != nil {
return OSConfig{}, err
}
return readResolv(&buf)
}
func (m openresolvManager) Close() error {
return m.deleteTailscaleConfig()
}

View File

@@ -4,21 +4,11 @@
package dns
// DNSRoutingMode describes the type of per-domain DNS routing that
// the OS is capable of.
type RoutingMode int
import (
"errors"
const (
// RoutingModeNone means the OS only supports setting a single
// primary set of DNS resolvers.
RoutingModeNone RoutingMode = iota
// RoutingModeSingle means the OS supports a set of
// primary resolvers, as well as one set of additional per-suffix
// resolvers per network interface.
RoutingModeSingle
// RoutingModeMulti means the OS supports a set of primary
// resolvers, as well as an arbitrary overlay of DNS routes.
RoutingModeMulti
"inet.af/netaddr"
"tailscale.com/util/dnsname"
)
// An OSConfigurator applies DNS settings to the operating system.
@@ -28,9 +18,75 @@ type OSConfigurator interface {
// configuration is removed.
// SetDNS must not be called after Close.
SetDNS(cfg OSConfig) error
// DNSRoutingMode reports the DNS routing capabilities of this OS
// configurator.
RoutingMode() RoutingMode
// SupportsSplitDNS reports whether the configurator is capable of
// installing a resolver only for specific DNS suffixes. If false,
// the configurator can only set a global resolver.
SupportsSplitDNS() bool
// GetBaseConfig returns the OS's "base" configuration, i.e. the
// resolver settings the OS would use without Tailscale
// contributing any configuration.
// GetBaseConfig must return the tailscale-free base config even
// after SetDNS has been called to set a Tailscale configuration.
// Only works when SupportsSplitDNS=false.
// Implementations that don't support getting the base config must
// return ErrGetBaseConfigNotSupported.
GetBaseConfig() (OSConfig, error)
// Close removes Tailscale-related DNS configuration from the OS.
Close() error
}
// OSConfig is an OS DNS configuration.
type OSConfig struct {
// Nameservers are the IP addresses of the nameservers to use.
Nameservers []netaddr.IP
// SearchDomains are the domain suffixes to use when expanding
// single-label name queries. SearchDomains is additive to
// whatever non-Tailscale search domains the OS has.
SearchDomains []dnsname.FQDN
// MatchDomains are the DNS suffixes for which Nameservers should
// be used. If empty, Nameservers is installed as the "primary" resolver.
// A non-empty MatchDomains requests a "split DNS" configuration
// from the OS, which will only work with OSConfigurators that
// report SupportsSplitDNS()=true.
MatchDomains []dnsname.FQDN
}
func (o OSConfig) IsZero() bool {
return len(o.Nameservers) == 0 && len(o.SearchDomains) == 0 && len(o.MatchDomains) == 0
}
func (a OSConfig) Equal(b OSConfig) bool {
if len(a.Nameservers) != len(b.Nameservers) {
return false
}
if len(a.SearchDomains) != len(b.SearchDomains) {
return false
}
if len(a.MatchDomains) != len(b.MatchDomains) {
return false
}
for i := range a.Nameservers {
if a.Nameservers[i] != b.Nameservers[i] {
return false
}
}
for i := range a.SearchDomains {
if a.SearchDomains[i] != b.SearchDomains[i] {
return false
}
}
for i := range a.MatchDomains {
if a.MatchDomains[i] != b.MatchDomains[i] {
return false
}
}
return true
}
// ErrGetBaseConfigNotSupported is the error
// OSConfigurator.GetBaseConfig returns when the OSConfigurator
// doesn't support reading the underlying configuration out of the OS.
var ErrGetBaseConfigNotSupported = errors.New("getting OS base config is not supported")

View File

@@ -0,0 +1,63 @@
#!/bin/sh
# Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.
#
# This script is a workaround for a vpn-unfriendly behavior of the
# original resolvconf by Thomas Hood. Unlike the `openresolv`
# implementation (whose binary is also called resolvconf,
# confusingly), the original resolvconf lacks a way to specify
# "exclusive mode" for a provider configuration. In practice, this
# means that if Tailscale wants to install a DNS configuration, that
# config will get "blended" with the configs from other sources,
# rather than override those other sources.
#
# This script gets installed at /etc/resolvconf/update-libc.d, which
# is a directory of hook scripts that get run after resolvconf's libc
# helper has finished rewriting /etc/resolv.conf. It's meant to notify
# consumers of resolv.conf of a new configuration.
#
# Instead, we use that hook mechanism to reach into resolvconf's
# stuff, and rewrite the libc-generated resolv.conf to exclusively
# contain Tailscale's configuration - effectively implementing
# exclusive mode ourselves in post-production.
set -e
if [ -n "$TAILSCALE_RESOLVCONF_HOOK_LOOP" ]; then
# Hook script being invoked by itself, skip.
exit 0
fi
if [ ! -f tun-tailscale.inet ]; then
# Tailscale isn't trying to manage DNS, do nothing.
exit 0
fi
if ! grep resolvconf /etc/resolv.conf >/dev/null; then
# resolvconf isn't managing /etc/resolv.conf, do nothing.
exit 0
fi
# Write out a modified /etc/resolv.conf containing just our config.
(
if [ -f /etc/resolvconf/resolv.conf.d/head ]; then
cat /etc/resolvconf/resolv.conf.d/head
fi
echo "# Tailscale workaround applied to set exclusive DNS configuration."
cat tun-tailscale.inet
if [ -f /etc/resolvconf/resolv.conf.d/base ]; then
# Keep options and sortlist, discard other base things since
# they're the things we're trying to override.
grep -e 'sortlist ' -e 'options ' /etc/resolvconf/resolv.conf.d/base || true
fi
if [ -f /etc/resolvconf/resolv.conf.d/tail ]; then
cat /etc/resolvconf/resolv.conf.d/tail
fi
) >/etc/resolv.conf
if [ -d /etc/resolvconf/update-libc.d ] ; then
# Re-notify libc watchers that we've changed resolv.conf again.
export TAILSCALE_RESOLVCONF_HOOK_LOOP=1
exec run-parts /etc/resolvconf/update-libc.d
fi

View File

@@ -1,161 +1,27 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build linux freebsd
package dns
import (
"bufio"
"bytes"
"fmt"
"os"
"os/exec"
"tailscale.com/types/logger"
)
// isResolvconfActive indicates whether the system appears to be using resolvconf.
// If this is true, then directManager should be avoided:
// resolvconf has exclusive ownership of /etc/resolv.conf.
func isResolvconfActive() bool {
// Sanity-check first: if there is no resolvconf binary, then this is fruitless.
//
// However, this binary may be a shim like the one systemd-resolved provides.
// Such a shim may not behave as expected: in particular, systemd-resolved
// does not seem to respect the exclusive mode -x, saying:
// -x Send DNS traffic preferably over this interface
// whereas e.g. openresolv sends DNS traffix _exclusively_ over that interface,
// or not at all (in case of another exclusive-mode request later in time).
//
// Moreover, resolvconf may be installed but unused, in which case we should
// not use it either, lest we clobber existing configuration.
//
// To handle all the above correctly, we scan the comments in /etc/resolv.conf
// to ensure that it was generated by a resolvconf implementation.
_, err := exec.LookPath("resolvconf")
func newResolvconfManager(logf logger.Logf) (OSConfigurator, error) {
_, err := exec.Command("resolvconf", "--version").CombinedOutput()
if err != nil {
return false
}
f, err := os.Open("/etc/resolv.conf")
if err != nil {
return false
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Bytes()
// Look for the word "resolvconf" until comments end.
if len(line) > 0 && line[0] != '#' {
return false
}
if bytes.Contains(line, []byte("resolvconf")) {
return true
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 99 {
// Debian resolvconf doesn't understand --version, and
// exits with a specific error code.
return newDebianResolvconfManager(logf)
}
}
return false
}
// resolvconfImpl enumerates supported implementations of the resolvconf CLI.
type resolvconfImpl uint8
const (
// resolvconfOpenresolv is the implementation packaged as "openresolv" on Ubuntu.
// It supports exclusive mode and interface metrics.
resolvconfOpenresolv resolvconfImpl = iota
// resolvconfLegacy is the implementation by Thomas Hood packaged as "resolvconf" on Ubuntu.
// It does not support exclusive mode or interface metrics.
resolvconfLegacy
)
func (impl resolvconfImpl) String() string {
switch impl {
case resolvconfOpenresolv:
return "openresolv"
case resolvconfLegacy:
return "legacy"
default:
return "unknown"
}
}
// getResolvconfImpl returns the implementation of resolvconf that appears to be in use.
func getResolvconfImpl() resolvconfImpl {
err := exec.Command("resolvconf", "-v").Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
// Thomas Hood's resolvconf has a minimal flag set
// and exits with code 99 when passed an unknown flag.
if exitErr.ExitCode() == 99 {
return resolvconfLegacy
}
}
}
return resolvconfOpenresolv
}
type resolvconfManager struct {
impl resolvconfImpl
}
func newResolvconfManager(logf logger.Logf) resolvconfManager {
impl := getResolvconfImpl()
logf("resolvconf implementation is %s", impl)
return resolvconfManager{
impl: impl,
}
}
// resolvconfConfigName is the name of the config submitted to resolvconf.
// It has this form to match the "tun*" rule in interface-order
// when running resolvconfLegacy, hopefully placing our config first.
const resolvconfConfigName = "tun-tailscale.inet"
func (m resolvconfManager) SetDNS(config OSConfig) error {
stdin := new(bytes.Buffer)
writeResolvConf(stdin, config.Nameservers, config.Domains) // dns_direct.go
var cmd *exec.Cmd
switch m.impl {
case resolvconfOpenresolv:
// Request maximal priority (metric 0) and exclusive mode.
cmd = exec.Command("resolvconf", "-m", "0", "-x", "-a", resolvconfConfigName)
case resolvconfLegacy:
// This does not quite give us the desired behavior (queries leak),
// but there is nothing else we can do without messing with other interfaces' settings.
cmd = exec.Command("resolvconf", "-a", resolvconfConfigName)
}
cmd.Stdin = stdin
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("running %s: %s", cmd, out)
}
return nil
}
func (m resolvconfManager) RoutingMode() RoutingMode {
return RoutingModeNone
}
func (m resolvconfManager) Close() error {
var cmd *exec.Cmd
switch m.impl {
case resolvconfOpenresolv:
cmd = exec.Command("resolvconf", "-f", "-d", resolvconfConfigName)
case resolvconfLegacy:
// resolvconfLegacy lacks the -f flag.
// Instead, it succeeds even when the config does not exist.
cmd = exec.Command("resolvconf", "-d", resolvconfConfigName)
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("running %s: %s", cmd, out)
}
return nil
// If --version works, or we got some surprising error while
// probing, use openresolv. It's the more common implementation,
// so in cases where we can't figure things out, it's the least
// likely to misbehave.
return newOpenresolvManager()
}

View File

@@ -12,12 +12,13 @@ import (
"context"
"errors"
"fmt"
"os/exec"
"github.com/godbus/dbus/v5"
"golang.org/x/sys/unix"
"inet.af/netaddr"
"tailscale.com/net/interfaces"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
)
// resolvedListenAddr is the listen address of the resolved stub resolver.
@@ -51,15 +52,20 @@ type resolvedLinkDomain struct {
// isResolvedActive determines if resolved is currently managing system DNS settings.
func isResolvedActive() bool {
// systemd-resolved is never installed without systemd.
_, err := exec.LookPath("systemctl")
ctx, cancel := context.WithTimeout(context.Background(), reconfigTimeout)
defer cancel()
conn, err := dbus.SystemBus()
if err != nil {
// Probably no DBus on the system, or we're not allowed to use
// it. Cannot control resolved.
return false
}
// is-active exits with code 3 if the service is not active.
err = exec.Command("systemctl", "is-active", "systemd-resolved").Run()
if err != nil {
rd := conn.Object("org.freedesktop.resolve1", dbus.ObjectPath("/org/freedesktop/resolve1"))
call := rd.CallWithContext(ctx, "org.freedesktop.DBus.Peer.Ping", 0)
if call.Err != nil {
// Can't talk to resolved.
return false
}
@@ -77,29 +83,28 @@ func isResolvedActive() bool {
}
// resolvedManager uses the systemd-resolved DBus API.
type resolvedManager struct{}
type resolvedManager struct {
logf logger.Logf
resolved dbus.BusObject
}
func newResolvedManager() resolvedManager {
return resolvedManager{}
func newResolvedManager(logf logger.Logf) (*resolvedManager, error) {
conn, err := dbus.SystemBus()
if err != nil {
return nil, err
}
return &resolvedManager{
logf: logf,
resolved: conn.Object("org.freedesktop.resolve1", dbus.ObjectPath("/org/freedesktop/resolve1")),
}, nil
}
// Up implements managerImpl.
func (m resolvedManager) SetDNS(config OSConfig) error {
func (m *resolvedManager) SetDNS(config OSConfig) error {
ctx, cancel := context.WithTimeout(context.Background(), reconfigTimeout)
defer cancel()
// conn is a shared connection whose lifecycle is managed by the dbus package.
// We should not interfere with that by closing it.
conn, err := dbus.SystemBus()
if err != nil {
return fmt.Errorf("connecting to system bus: %w", err)
}
resolved := conn.Object(
"org.freedesktop.resolve1",
dbus.ObjectPath("/org/freedesktop/resolve1"),
)
// In principle, we could persist this in the manager struct
// if we knew that interface indices are persistent. This does not seem to be the case.
_, iface, err := interfaces.Tailscale()
@@ -126,7 +131,7 @@ func (m resolvedManager) SetDNS(config OSConfig) error {
}
}
err = resolved.CallWithContext(
err = m.resolved.CallWithContext(
ctx, "org.freedesktop.resolve1.Manager.SetLinkDNS", 0,
iface.Index, linkNameservers,
).Store()
@@ -134,15 +139,40 @@ func (m resolvedManager) SetDNS(config OSConfig) error {
return fmt.Errorf("setLinkDNS: %w", err)
}
var linkDomains = make([]resolvedLinkDomain, len(config.Domains))
for i, domain := range config.Domains {
linkDomains[i] = resolvedLinkDomain{
Domain: domain,
RoutingOnly: false,
linkDomains := make([]resolvedLinkDomain, 0, len(config.SearchDomains)+len(config.MatchDomains))
seenDomains := map[dnsname.FQDN]bool{}
for _, domain := range config.SearchDomains {
if seenDomains[domain] {
continue
}
seenDomains[domain] = true
linkDomains = append(linkDomains, resolvedLinkDomain{
Domain: domain.WithTrailingDot(),
RoutingOnly: false,
})
}
for _, domain := range config.MatchDomains {
if seenDomains[domain] {
// Search domains act as both search and match in
// resolved, so it's correct to skip.
continue
}
seenDomains[domain] = true
linkDomains = append(linkDomains, resolvedLinkDomain{
Domain: domain.WithTrailingDot(),
RoutingOnly: true,
})
}
if len(config.MatchDomains) == 0 && len(config.Nameservers) > 0 {
// Caller requested full DNS interception, install a
// routing-only root domain.
linkDomains = append(linkDomains, resolvedLinkDomain{
Domain: ".",
RoutingOnly: true,
})
}
err = resolved.CallWithContext(
err = m.resolved.CallWithContext(
ctx, "org.freedesktop.resolve1.Manager.SetLinkDomains", 0,
iface.Index, linkDomains,
).Store()
@@ -150,29 +180,53 @@ func (m resolvedManager) SetDNS(config OSConfig) error {
return fmt.Errorf("setLinkDomains: %w", err)
}
if call := m.resolved.CallWithContext(ctx, "org.freedesktop.resolve1.Manager.SetLinkDefaultRoute", 0, iface.Index, len(config.MatchDomains) == 0); call.Err != nil {
return fmt.Errorf("setLinkDefaultRoute: %w", err)
}
// Some best-effort setting of things, but resolved should do the
// right thing if these fail (e.g. a really old resolved version
// or something).
// Disable LLMNR, we don't do multicast.
if call := m.resolved.CallWithContext(ctx, "org.freedesktop.resolve1.Manager.SetLinkLLMNR", 0, iface.Index, "no"); call.Err != nil {
m.logf("[v1] failed to disable LLMNR: %v", call.Err)
}
// Disable mdns.
if call := m.resolved.CallWithContext(ctx, "org.freedesktop.resolve1.Manager.SetLinkMulticastDNS", 0, iface.Index, "no"); call.Err != nil {
m.logf("[v1] failed to disable mdns: %v", call.Err)
}
// We don't support dnssec consistently right now, force it off to
// avoid partial failures when we split DNS internally.
if call := m.resolved.CallWithContext(ctx, "org.freedesktop.resolve1.Manager.SetLinkDNSSEC", 0, iface.Index, "no"); call.Err != nil {
m.logf("[v1] failed to disable DNSSEC: %v", call.Err)
}
if call := m.resolved.CallWithContext(ctx, "org.freedesktop.resolve1.Manager.SetLinkDNSOverTLS", 0, iface.Index, "no"); call.Err != nil {
m.logf("[v1] failed to disable DoT: %v", call.Err)
}
if call := m.resolved.CallWithContext(ctx, "org.freedesktop.resolve1.Manager.FlushCaches", 0); call.Err != nil {
m.logf("failed to flush resolved DNS cache: %v", call.Err)
}
return nil
}
func (m resolvedManager) RoutingMode() RoutingMode {
return RoutingModeNone
func (m *resolvedManager) SupportsSplitDNS() bool {
return true
}
func (m resolvedManager) Close() error {
func (m *resolvedManager) GetBaseConfig() (OSConfig, error) {
return OSConfig{}, ErrGetBaseConfigNotSupported
}
func (m *resolvedManager) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), reconfigTimeout)
defer cancel()
// conn is a shared connection whose lifecycle is managed by the dbus package.
// We should not interfere with that by closing it.
conn, err := dbus.SystemBus()
if err != nil {
return fmt.Errorf("connecting to system bus: %w", err)
}
resolved := conn.Object(
"org.freedesktop.resolve1",
dbus.ObjectPath("/org/freedesktop/resolve1"),
)
_, iface, err := interfaces.Tailscale()
if err != nil {
return fmt.Errorf("getting interface index: %w", err)
@@ -181,12 +235,8 @@ func (m resolvedManager) Close() error {
return errNotReady
}
err = resolved.CallWithContext(
ctx, "org.freedesktop.resolve1.Manager.RevertLink", 0,
iface.Index,
).Store()
if err != nil {
return fmt.Errorf("RevertLink: %w", err)
if call := m.resolved.CallWithContext(ctx, "org.freedesktop.resolve1.Manager.RevertLink", 0, iface.Index); call.Err != nil {
return fmt.Errorf("RevertLink: %w", call.Err)
}
return nil

View File

@@ -20,7 +20,6 @@ import (
dns "golang.org/x/net/dns/dnsmessage"
"inet.af/netaddr"
"tailscale.com/logtail/backoff"
"tailscale.com/net/netns"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
)
@@ -103,7 +102,7 @@ func getTxID(packet []byte) txid {
}
type route struct {
suffix string
suffix dnsname.FQDN
resolvers []netaddr.IPPort
}
@@ -273,7 +272,7 @@ func (f *forwarder) forward(query packet) error {
var resolvers []netaddr.IPPort
for _, route := range routes {
if route.suffix != "." && !dnsname.HasSuffix(domain, route.suffix) {
if route.suffix != "." && !route.suffix.Contains(domain) {
continue
}
resolvers = route.resolvers
@@ -453,7 +452,7 @@ func (c *fwdConn) read(out []byte) int {
func (c *fwdConn) reconnectLocked() {
c.closeConnLocked()
// Make a new connection.
conn, err := netns.Listener().ListenPacket(context.Background(), "udp", "")
conn, err := net.ListenPacket("udp", "")
if err != nil {
c.logf("ListenPacket failed: %v", err)
} else {
@@ -490,7 +489,7 @@ func (c *fwdConn) close() {
}
// nameFromQuery extracts the normalized query name from bs.
func nameFromQuery(bs []byte) (string, error) {
func nameFromQuery(bs []byte) (dnsname.FQDN, error) {
var parser dns.Parser
hdr, err := parser.Start(bs)
@@ -507,5 +506,5 @@ func nameFromQuery(bs []byte) (string, error) {
}
n := q.Name.Data[:q.Name.Length]
return rawNameToLower(n), nil
return dnsname.ToFQDN(rawNameToLower(n))
}

View File

@@ -9,7 +9,6 @@ package resolver
import (
"encoding/hex"
"errors"
"fmt"
"sort"
"strings"
"sync"
@@ -59,12 +58,12 @@ type Config struct {
// queries within that suffix.
// Queries only match the most specific suffix.
// To register a "default route", add an entry for ".".
Routes map[string][]netaddr.IPPort
Routes map[dnsname.FQDN][]netaddr.IPPort
// LocalHosts is a map of FQDNs to corresponding IPs.
Hosts map[string][]netaddr.IP
Hosts map[dnsname.FQDN][]netaddr.IP
// LocalDomains is a list of DNS name suffixes that should not be
// routed to upstream resolvers.
LocalDomains []string
LocalDomains []dnsname.FQDN
}
// Resolver is a DNS resolver for nodes on the Tailscale network,
@@ -72,9 +71,10 @@ type Config struct {
// If it is asked to resolve a domain that is not of that form,
// it delegates to upstream nameservers if any are set.
type Resolver struct {
logf logger.Logf
linkMon *monitor.Mon // or nil
unregLinkMon func() // or nil
logf logger.Logf
linkMon *monitor.Mon // or nil
unregLinkMon func() // or nil
saveConfigForTests func(cfg Config) // used in tests to capture resolver config
// forwarder forwards requests to upstream nameservers.
forwarder *forwarder
@@ -91,9 +91,9 @@ type Resolver struct {
// mu guards the following fields from being updated while used.
mu sync.Mutex
localDomains []string
hostToIP map[string][]netaddr.IP
ipToHost map[netaddr.IP]string
localDomains []dnsname.FQDN
hostToIP map[dnsname.FQDN][]netaddr.IP
ipToHost map[netaddr.IP]dnsname.FQDN
}
// New returns a new resolver.
@@ -106,8 +106,8 @@ func New(logf logger.Logf, linkMon *monitor.Mon) *Resolver {
responses: make(chan packet),
errors: make(chan error),
closed: make(chan struct{}),
hostToIP: map[string][]netaddr.IP{},
ipToHost: map[netaddr.IP]string{},
hostToIP: map[dnsname.FQDN][]netaddr.IP{},
ipToHost: map[netaddr.IP]dnsname.FQDN{},
}
r.forwarder = newForwarder(r.logf, r.responses)
if r.linkMon != nil {
@@ -120,32 +120,23 @@ func New(logf logger.Logf, linkMon *monitor.Mon) *Resolver {
return r
}
func isFQDN(s string) bool {
return strings.HasSuffix(s, ".")
}
func (r *Resolver) TestOnlySetHook(hook func(Config)) { r.saveConfigForTests = hook }
func (r *Resolver) SetConfig(cfg Config) error {
if r.saveConfigForTests != nil {
r.saveConfigForTests(cfg)
}
routes := make([]route, 0, len(cfg.Routes))
reverse := make(map[netaddr.IP]string, len(cfg.Hosts))
reverse := make(map[netaddr.IP]dnsname.FQDN, len(cfg.Hosts))
for host, ips := range cfg.Hosts {
if !isFQDN(host) {
return fmt.Errorf("host entry %q is not a FQDN", host)
}
for _, ip := range ips {
reverse[ip] = host
}
}
for _, domain := range cfg.LocalDomains {
if !isFQDN(domain) {
return fmt.Errorf("local domain %q is not a FQDN", domain)
}
}
for suffix, ips := range cfg.Routes {
if !strings.HasSuffix(suffix, ".") {
return fmt.Errorf("route suffix %q is not a FQDN", suffix)
}
routes = append(routes, route{
suffix: suffix,
resolvers: ips,
@@ -153,7 +144,7 @@ func (r *Resolver) SetConfig(cfg Config) error {
}
// Sort from longest prefix to shortest.
sort.Slice(routes, func(i, j int) bool {
return dnsname.NumLabels(routes[i].suffix) > dnsname.NumLabels(routes[j].suffix)
return routes[i].suffix.NumLabels() > routes[j].suffix.NumLabels()
})
r.forwarder.setRoutes(routes)
@@ -222,12 +213,11 @@ func (r *Resolver) NextResponse() (packet []byte, to netaddr.IPPort, err error)
// resolveLocal returns an IP for the given domain, if domain is in
// the local hosts map and has an IP corresponding to the requested
// typ (A, AAAA, ALL).
// The domain name must be in canonical form (with a trailing period).
// Returns dns.RCodeRefused to indicate that the local map is not
// authoritative for domain.
func (r *Resolver) resolveLocal(domain string, typ dns.Type) (netaddr.IP, dns.RCode) {
func (r *Resolver) resolveLocal(domain dnsname.FQDN, typ dns.Type) (netaddr.IP, dns.RCode) {
// Reject .onion domains per RFC 7686.
if dnsname.HasSuffix(domain, ".onion") {
if dnsname.HasSuffix(domain.WithoutTrailingDot(), ".onion") {
return netaddr.IP{}, dns.RCodeNameError
}
@@ -239,7 +229,7 @@ func (r *Resolver) resolveLocal(domain string, typ dns.Type) (netaddr.IP, dns.RC
addrs, found := hosts[domain]
if !found {
for _, suffix := range localDomains {
if dnsname.HasSuffix(domain, suffix) {
if suffix.Contains(domain) {
// We are authoritative for the queried domain.
return netaddr.IP{}, dns.RCodeNameError
}
@@ -297,8 +287,7 @@ func (r *Resolver) resolveLocal(domain string, typ dns.Type) (netaddr.IP, dns.RC
}
// resolveReverse returns the unique domain name that maps to the given address.
// The returned domain name is in canonical form (with a trailing period).
func (r *Resolver) resolveLocalReverse(ip netaddr.IP) (string, dns.RCode) {
func (r *Resolver) resolveLocalReverse(ip netaddr.IP) (dnsname.FQDN, dns.RCode) {
r.mu.Lock()
ips := r.ipToHost
r.mu.Unlock()
@@ -355,7 +344,7 @@ type response struct {
Header dns.Header
Question dns.Question
// Name is the response to a PTR query.
Name string
Name dnsname.FQDN
// IP is the response to an A, AAAA, or ALL query.
IP netaddr.IP
}
@@ -418,7 +407,7 @@ func marshalAAAARecord(name dns.Name, ip netaddr.IP, builder *dns.Builder) error
// marshalPTRRecord serializes a PTR record into an active builder.
// The caller may continue using the builder following the call.
func marshalPTRRecord(queryName dns.Name, name string, builder *dns.Builder) error {
func marshalPTRRecord(queryName dns.Name, name dnsname.FQDN, builder *dns.Builder) error {
var answer dns.PTRResource
var err error
@@ -428,7 +417,7 @@ func marshalPTRRecord(queryName dns.Name, name string, builder *dns.Builder) err
Class: dns.ClassINET,
TTL: uint32(defaultTTL / time.Second),
}
answer.PTR, err = dns.NewName(name)
answer.PTR, err = dns.NewName(name.WithTrailingDot())
if err != nil {
return err
}
@@ -501,12 +490,13 @@ const (
// r._dns-sd._udp.<domain>.
// dr._dns-sd._udp.<domain>.
// lb._dns-sd._udp.<domain>.
func hasRDNSBonjourPrefix(s string) bool {
func hasRDNSBonjourPrefix(name dnsname.FQDN) bool {
// Even the shortest name containing a Bonjour prefix is long,
// so check length (cheap) and bail early if possible.
if len(s) < len("*._dns-sd._udp.0.0.0.0.in-addr.arpa.") {
if len(name) < len("*._dns-sd._udp.0.0.0.0.in-addr.arpa.") {
return false
}
s := name.WithTrailingDot()
dot := strings.IndexByte(s, '.')
if dot == -1 {
return false // shouldn't happen
@@ -541,9 +531,9 @@ func rawNameToLower(name []byte) string {
// 4.3.2.1.in-addr.arpa
// is transformed to
// 1.2.3.4
func rdnsNameToIPv4(name string) (ip netaddr.IP, ok bool) {
name = strings.TrimSuffix(name, rdnsv4Suffix)
ip, err := netaddr.ParseIP(string(name))
func rdnsNameToIPv4(name dnsname.FQDN) (ip netaddr.IP, ok bool) {
s := strings.TrimSuffix(name.WithTrailingDot(), rdnsv4Suffix)
ip, err := netaddr.ParseIP(s)
if err != nil {
return netaddr.IP{}, false
}
@@ -560,21 +550,21 @@ func rdnsNameToIPv4(name string) (ip netaddr.IP, ok bool) {
// b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.
// is transformed to
// 2001:db8::567:89ab
func rdnsNameToIPv6(name string) (ip netaddr.IP, ok bool) {
func rdnsNameToIPv6(name dnsname.FQDN) (ip netaddr.IP, ok bool) {
var b [32]byte
var ipb [16]byte
name = strings.TrimSuffix(name, rdnsv6Suffix)
s := strings.TrimSuffix(name.WithTrailingDot(), rdnsv6Suffix)
// 32 nibbles and 31 dots between them.
if len(name) != 63 {
if len(s) != 63 {
return netaddr.IP{}, false
}
// Dots and hex digits alternate.
prevDot := true
// i ranges over name backward; j ranges over b forward.
for i, j := len(name)-1, 0; i >= 0; i-- {
thisDot := (name[i] == '.')
for i, j := len(s)-1, 0; i >= 0; i-- {
thisDot := (s[i] == '.')
if prevDot == thisDot {
return netaddr.IP{}, false
}
@@ -583,7 +573,7 @@ func rdnsNameToIPv6(name string) (ip netaddr.IP, ok bool) {
if !thisDot {
// This is safe assuming alternation.
// We do not check that non-dots are hex digits: hex.Decode below will do that.
b[j] = name[i]
b[j] = s[i]
j++
}
}
@@ -598,7 +588,7 @@ func rdnsNameToIPv6(name string) (ip netaddr.IP, ok bool) {
// respondReverse returns a DNS response to a PTR query.
// It is assumed that resp.Question is populated by respond before this is called.
func (r *Resolver) respondReverse(query []byte, name string, resp *response) ([]byte, error) {
func (r *Resolver) respondReverse(query []byte, name dnsname.FQDN, resp *response) ([]byte, error) {
if hasRDNSBonjourPrefix(name) {
return nil, errNotOurName
}
@@ -606,9 +596,9 @@ func (r *Resolver) respondReverse(query []byte, name string, resp *response) ([]
var ip netaddr.IP
var ok bool
switch {
case strings.HasSuffix(name, rdnsv4Suffix):
case strings.HasSuffix(name.WithTrailingDot(), rdnsv4Suffix):
ip, ok = rdnsNameToIPv4(name)
case strings.HasSuffix(name, rdnsv6Suffix):
case strings.HasSuffix(name.WithTrailingDot(), rdnsv6Suffix):
ip, ok = rdnsNameToIPv6(name)
default:
return nil, errNotOurName
@@ -649,7 +639,12 @@ func (r *Resolver) respond(query []byte) ([]byte, error) {
return marshalResponse(resp)
}
rawName := resp.Question.Name.Data[:resp.Question.Name.Length]
name := rawNameToLower(rawName)
name, err := dnsname.ToFQDN(rawNameToLower(rawName))
if err != nil {
// DNS packet unexpectedly contains an invalid FQDN.
resp.Header.RCode = dns.RCodeFormatError
return marshalResponse(resp)
}
// Always try to handle reverse lookups; delegate inside when not found.
// This way, queries for existent nodes do not leak,

View File

@@ -13,23 +13,24 @@ import (
dns "golang.org/x/net/dns/dnsmessage"
"inet.af/netaddr"
"tailscale.com/tstest"
"tailscale.com/util/dnsname"
)
var testipv4 = netaddr.MustParseIP("1.2.3.4")
var testipv6 = netaddr.MustParseIP("0001:0203:0405:0607:0809:0a0b:0c0d:0e0f")
var dnsCfg = Config{
Hosts: map[string][]netaddr.IP{
Hosts: map[dnsname.FQDN][]netaddr.IP{
"test1.ipn.dev.": []netaddr.IP{testipv4},
"test2.ipn.dev.": []netaddr.IP{testipv6},
},
LocalDomains: []string{"ipn.dev."},
LocalDomains: []dnsname.FQDN{"ipn.dev."},
}
func dnspacket(domain string, tp dns.Type) []byte {
func dnspacket(domain dnsname.FQDN, tp dns.Type) []byte {
var dnsHeader dns.Header
question := dns.Question{
Name: dns.MustNewName(domain),
Name: dns.MustNewName(domain.WithTrailingDot()),
Type: tp,
Class: dns.ClassINET,
}
@@ -44,7 +45,7 @@ func dnspacket(domain string, tp dns.Type) []byte {
type dnsResponse struct {
ip netaddr.IP
name string
name dnsname.FQDN
rcode dns.RCode
}
@@ -94,7 +95,10 @@ func unpackResponse(payload []byte) (dnsResponse, error) {
if err != nil {
return response, err
}
response.name = res.NS.String()
response.name, err = dnsname.ToFQDN(res.NS.String())
if err != nil {
return response, err
}
default:
return response, errors.New("type not in {A, AAAA, NS}")
}
@@ -119,7 +123,7 @@ func mustIP(str string) netaddr.IP {
func TestRDNSNameToIPv4(t *testing.T) {
tests := []struct {
name string
input string
input dnsname.FQDN
wantIP netaddr.IP
wantOK bool
}{
@@ -144,7 +148,7 @@ func TestRDNSNameToIPv4(t *testing.T) {
func TestRDNSNameToIPv6(t *testing.T) {
tests := []struct {
name string
input string
input dnsname.FQDN
wantIP netaddr.IP
wantOK bool
}{
@@ -194,7 +198,7 @@ func TestResolveLocal(t *testing.T) {
tests := []struct {
name string
qname string
qname dnsname.FQDN
qtype dns.Type
ip netaddr.IP
code dns.RCode
@@ -235,7 +239,7 @@ func TestResolveLocalReverse(t *testing.T) {
tests := []struct {
name string
ip netaddr.IP
want string
want dnsname.FQDN
code dns.RCode
}{
{"ipv4", testipv4, "test1.ipn.dev.", dns.RCodeSuccess},
@@ -285,7 +289,7 @@ func TestDelegate(t *testing.T) {
defer r.Close()
cfg := dnsCfg
cfg.Routes = map[string][]netaddr.IPPort{
cfg.Routes = map[dnsname.FQDN][]netaddr.IPPort{
".": {
netaddr.MustParseIPPort(v4server.PacketConn.LocalAddr().String()),
netaddr.MustParseIPPort(v6server.PacketConn.LocalAddr().String()),
@@ -360,7 +364,7 @@ func TestDelegateSplitRoute(t *testing.T) {
defer r.Close()
cfg := dnsCfg
cfg.Routes = map[string][]netaddr.IPPort{
cfg.Routes = map[dnsname.FQDN][]netaddr.IPPort{
".": {netaddr.MustParseIPPort(server1.PacketConn.LocalAddr().String())},
"other.": {netaddr.MustParseIPPort(server2.PacketConn.LocalAddr().String())},
}
@@ -417,7 +421,7 @@ func TestDelegateCollision(t *testing.T) {
defer r.Close()
cfg := dnsCfg
cfg.Routes = map[string][]netaddr.IPPort{
cfg.Routes = map[dnsname.FQDN][]netaddr.IPPort{
".": {
netaddr.MustParseIPPort(server.PacketConn.LocalAddr().String()),
},
@@ -425,7 +429,7 @@ func TestDelegateCollision(t *testing.T) {
r.SetConfig(cfg)
packets := []struct {
qname string
qname dnsname.FQDN
qtype dns.Type
addr netaddr.IPPort
}{
@@ -692,7 +696,7 @@ func TestAllocs(t *testing.T) {
func TestTrimRDNSBonjourPrefix(t *testing.T) {
tests := []struct {
in string
in dnsname.FQDN
want bool
}{
{"b._dns-sd._udp.0.10.20.172.in-addr.arpa.", true},
@@ -702,7 +706,6 @@ func TestTrimRDNSBonjourPrefix(t *testing.T) {
{"lb._dns-sd._udp.0.10.20.172.in-addr.arpa.", true},
{"qq._dns-sd._udp.0.10.20.172.in-addr.arpa.", false},
{"0.10.20.172.in-addr.arpa.", false},
{"i-have-no-dot", false},
}
for _, test := range tests {
@@ -722,7 +725,7 @@ func BenchmarkFull(b *testing.B) {
defer r.Close()
cfg := dnsCfg
cfg.Routes = map[string][]netaddr.IPPort{
cfg.Routes = map[dnsname.FQDN][]netaddr.IPPort{
".": {
netaddr.MustParseIPPort(server.PacketConn.LocalAddr().String()),
},

View File

@@ -5,21 +5,13 @@
package nettest
import (
"io"
"net"
"time"
)
// Conn is a bi-directional in-memory stream that looks like a TCP net.Conn.
// Conn is a net.Conn that can additionally have its reads and writes blocked and unblocked.
type Conn interface {
io.Reader
io.Writer
io.Closer
// The *Deadline methods follow the semantics of net.Conn.
SetDeadline(t time.Time) error
SetReadDeadline(t time.Time) error
SetWriteDeadline(t time.Time) error
net.Conn
// SetReadBlock blocks or unblocks the Read method of this Conn.
// It reports an error if the existing value matches the new value,
@@ -40,24 +32,37 @@ func NewConn(name string, maxBuf int) (Conn, Conn) {
return &connHalf{r: r, w: w}, &connHalf{r: w, w: r}
}
type connAddr string
func (a connAddr) Network() string { return "mem" }
func (a connAddr) String() string { return string(a) }
type connHalf struct {
r, w *Pipe
}
func (c *connHalf) LocalAddr() net.Addr {
return connAddr(c.r.name)
}
func (c *connHalf) RemoteAddr() net.Addr {
return connAddr(c.w.name)
}
func (c *connHalf) Read(b []byte) (n int, err error) {
return c.r.Read(b)
}
func (c *connHalf) Write(b []byte) (n int, err error) {
return c.w.Write(b)
}
func (c *connHalf) Close() error {
err1 := c.r.Close()
err2 := c.w.Close()
if err1 != nil {
return err1
if err := c.w.Close(); err != nil {
return err
}
return err2
return c.r.Close()
}
func (c *connHalf) SetDeadline(t time.Time) error {
err1 := c.SetReadDeadline(t)
err2 := c.SetWriteDeadline(t)
@@ -72,6 +77,7 @@ func (c *connHalf) SetReadDeadline(t time.Time) error {
func (c *connHalf) SetWriteDeadline(t time.Time) error {
return c.w.SetWriteDeadline(t)
}
func (c *connHalf) SetReadBlock(b bool) error {
if b {
return c.r.Block()

22
net/nettest/conn_test.go Normal file
View File

@@ -0,0 +1,22 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package nettest
import (
"net"
"testing"
"golang.org/x/net/nettest"
)
func TestConn(t *testing.T) {
nettest.TestConn(t, func() (c1 net.Conn, c2 net.Conn, stop func(), err error) {
c1, c2 = NewConn("test", bufferSize)
return c1, c2, func() {
c1.Close()
c2.Close()
}, nil
})
}

83
net/nettest/listener.go Normal file
View File

@@ -0,0 +1,83 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package nettest
import (
"context"
"net"
"strings"
"sync"
)
const (
bufferSize = 256 * 1024
)
// Listener is a net.Listener using using NewConn to create pairs of network
// connections connected in memory using a buffered pipe. It also provides a
// Dial method to establish new connections.
type Listener struct {
addr connAddr
ch chan Conn
closeOnce sync.Once
closed chan struct{}
}
// Listen returns a new Listener for the provided address.
func Listen(addr string) *Listener {
return &Listener{
addr: connAddr(addr),
ch: make(chan Conn),
closed: make(chan struct{}),
}
}
// Addr implements net.Listener.Addr.
func (l *Listener) Addr() net.Addr {
return l.addr
}
// Close closes the pipe listener.
func (l *Listener) Close() error {
l.closeOnce.Do(func() {
close(l.closed)
})
return nil
}
// Accept blocks until a new connection is available or the listener is closed.
func (l *Listener) Accept() (net.Conn, error) {
select {
case c := <-l.ch:
return c, nil
case <-l.closed:
return nil, net.ErrClosed
}
}
// Dial connects to the listener using the provided context.
// The provided Context must be non-nil. If the context expires before the
// connection is complete, an error is returned. Once successfully connected
// any expiration of the context will not affect the connection.
func (l *Listener) Dial(ctx context.Context, network, addr string) (net.Conn, error) {
if !strings.HasSuffix(network, "tcp") {
return nil, net.UnknownNetworkError(network)
}
if connAddr(addr) != l.addr {
return nil, &net.AddrError{
Err: "invalid address",
Addr: addr,
}
}
c, s := NewConn(addr, bufferSize)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-l.closed:
return nil, net.ErrClosed
case l.ch <- s:
return c, nil
}
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package nettest
import (
"context"
"testing"
)
func TestListener(t *testing.T) {
l := Listen("srv.local")
defer l.Close()
go func() {
c, err := l.Accept()
if err != nil {
t.Error(err)
return
}
defer c.Close()
}()
if c, err := l.Dial(context.Background(), "tcp", "invalid"); err == nil {
c.Close()
t.Fatalf("dial to invalid address succeeded")
}
c, err := l.Dial(context.Background(), "tcp", "srv.local")
if err != nil {
t.Fatalf("dial failed: %v", err)
return
}
c.Close()
}

View File

@@ -5,11 +5,13 @@
package nettest
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net"
"os"
"sync"
"time"
)
@@ -20,13 +22,12 @@ const debugPipe = false
type Pipe struct {
name string
maxBuf int
rCh chan struct{}
wCh chan struct{}
mu sync.Mutex
cnd *sync.Cond
mu sync.Mutex
closed bool
blocked bool
buf []byte
closed bool
buf bytes.Buffer
readTimeout time.Time
writeTimeout time.Time
cancelReadTimer func()
@@ -35,21 +36,42 @@ type Pipe struct {
// NewPipe creates a Pipe with a buffer size fixed at maxBuf.
func NewPipe(name string, maxBuf int) *Pipe {
return &Pipe{
p := &Pipe{
name: name,
maxBuf: maxBuf,
rCh: make(chan struct{}, 1),
wCh: make(chan struct{}, 1),
}
p.cnd = sync.NewCond(&p.mu)
return p
}
var (
ErrTimeout = errors.New("timeout")
ErrReadTimeout = fmt.Errorf("read %w", ErrTimeout)
ErrWriteTimeout = fmt.Errorf("write %w", ErrTimeout)
)
// readOrBlock attempts to read from the buffer, if the buffer is empty and
// the connection hasn't been closed it will block until there is a change.
func (p *Pipe) readOrBlock(b []byte) (int, error) {
p.mu.Lock()
defer p.mu.Unlock()
if !p.readTimeout.IsZero() && !time.Now().Before(p.readTimeout) {
return 0, os.ErrDeadlineExceeded
}
if p.blocked {
p.cnd.Wait()
return 0, nil
}
n, err := p.buf.Read(b)
// err will either be nil or io.EOF.
if err == io.EOF {
if p.closed {
return n, err
}
// Wait for something to change.
p.cnd.Wait()
}
return n, nil
}
// Read implements io.Reader.
// Once the buffer is drained (i.e. after Close), subsequent calls will
// return io.EOF.
func (p *Pipe) Read(b []byte) (n int, err error) {
if debugPipe {
orig := b
@@ -57,35 +79,48 @@ func (p *Pipe) Read(b []byte) (n int, err error) {
log.Printf("Pipe(%q).Read( %q) n=%d, err=%v", p.name, string(orig[:n]), n, err)
}()
}
for {
p.mu.Lock()
closed := p.closed
timedout := !p.readTimeout.IsZero() && !time.Now().Before(p.readTimeout)
blocked := p.blocked
if !closed && !timedout && len(p.buf) > 0 {
n2 := copy(b, p.buf)
p.buf = p.buf[n2:]
b = b[n2:]
n += n2
for n == 0 {
n2, err := p.readOrBlock(b)
if err != nil {
return n2, err
}
p.mu.Unlock()
if closed {
return 0, fmt.Errorf("nettest.Pipe(%q): closed: %w", p.name, io.EOF)
}
if timedout {
return 0, fmt.Errorf("nettest.Pipe(%q): %w", p.name, ErrReadTimeout)
}
if blocked {
<-p.rCh
continue
}
if n > 0 {
p.signalWrite()
return n, nil
}
<-p.rCh
n += n2
}
p.cnd.Signal()
return n, nil
}
// writeOrBlock attempts to write to the buffer, if the buffer is full it will
// block until there is a change.
func (p *Pipe) writeOrBlock(b []byte) (int, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return 0, net.ErrClosed
}
if !p.writeTimeout.IsZero() && !time.Now().Before(p.writeTimeout) {
return 0, os.ErrDeadlineExceeded
}
if p.blocked {
p.cnd.Wait()
return 0, nil
}
// Optimistically we want to write the entire slice.
n := len(b)
if limit := p.maxBuf - p.buf.Len(); limit < n {
// However, we don't have enough capacity to write everything.
n = limit
}
if n == 0 {
// Wait for something to change.
p.cnd.Wait()
return 0, nil
}
p.buf.Write(b[:n])
p.cnd.Signal()
return n, nil
}
// Write implements io.Writer.
@@ -96,47 +131,23 @@ func (p *Pipe) Write(b []byte) (n int, err error) {
log.Printf("Pipe(%q).Write(%q) n=%d, err=%v", p.name, string(orig), n, err)
}()
}
for {
p.mu.Lock()
closed := p.closed
timedout := !p.writeTimeout.IsZero() && !time.Now().Before(p.writeTimeout)
blocked := p.blocked
if !closed && !timedout {
n2 := len(b)
if limit := p.maxBuf - len(p.buf); limit < n2 {
n2 = limit
}
p.buf = append(p.buf, b[:n2]...)
b = b[n2:]
n += n2
for len(b) > 0 {
n2, err := p.writeOrBlock(b)
if err != nil {
return n + n2, err
}
p.mu.Unlock()
if closed {
return n, fmt.Errorf("nettest.Pipe(%q): closed: %w", p.name, io.EOF)
}
if timedout {
return n, fmt.Errorf("nettest.Pipe(%q): %w", p.name, ErrWriteTimeout)
}
if blocked {
<-p.wCh
continue
}
if n > 0 {
p.signalRead()
}
if len(b) == 0 {
return n, nil
}
<-p.wCh
n += n2
b = b[n2:]
}
return n, nil
}
// Close implements io.Closer.
// Close closes the pipe.
func (p *Pipe) Close() error {
p.mu.Lock()
closed := p.closed
defer p.mu.Unlock()
p.closed = true
p.blocked = false
if p.cancelWriteTimer != nil {
p.cancelWriteTimer()
p.cancelWriteTimer = nil
@@ -145,77 +156,65 @@ func (p *Pipe) Close() error {
p.cancelReadTimer()
p.cancelReadTimer = nil
}
p.mu.Unlock()
p.cnd.Broadcast()
if closed {
return fmt.Errorf("nettest.Pipe(%q).Close: already closed", p.name)
}
p.signalRead()
p.signalWrite()
return nil
}
func (p *Pipe) deadlineTimer(t time.Time) func() {
if t.IsZero() {
return nil
}
if t.Before(time.Now()) {
p.cnd.Broadcast()
return nil
}
ctx, cancel := context.WithDeadline(context.Background(), t)
go func() {
<-ctx.Done()
if ctx.Err() == context.DeadlineExceeded {
p.cnd.Broadcast()
}
}()
return cancel
}
// SetReadDeadline sets the deadline for future Read calls.
func (p *Pipe) SetReadDeadline(t time.Time) error {
p.mu.Lock()
defer p.mu.Unlock()
p.readTimeout = t
// If we already have a deadline, cancel it and create a new one.
if p.cancelReadTimer != nil {
p.cancelReadTimer()
p.cancelReadTimer = nil
}
if d := time.Until(t); !t.IsZero() && d > 0 {
ctx, cancel := context.WithCancel(context.Background())
p.cancelReadTimer = cancel
go func() {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
p.signalRead()
case <-ctx.Done():
}
}()
}
p.mu.Unlock()
p.signalRead()
p.cancelReadTimer = p.deadlineTimer(t)
return nil
}
// SetWriteDeadline sets the deadline for future Write calls.
func (p *Pipe) SetWriteDeadline(t time.Time) error {
p.mu.Lock()
defer p.mu.Unlock()
p.writeTimeout = t
// If we already have a deadline, cancel it and create a new one.
if p.cancelWriteTimer != nil {
p.cancelWriteTimer()
p.cancelWriteTimer = nil
}
if d := time.Until(t); !t.IsZero() && d > 0 {
ctx, cancel := context.WithCancel(context.Background())
p.cancelWriteTimer = cancel
go func() {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
p.signalWrite()
case <-ctx.Done():
}
}()
}
p.mu.Unlock()
p.signalWrite()
p.cancelWriteTimer = p.deadlineTimer(t)
return nil
}
// Block will cause all calls to Read and Write to block until they either
// timeout, are unblocked or the pipe is closed.
func (p *Pipe) Block() error {
p.mu.Lock()
defer p.mu.Unlock()
closed := p.closed
blocked := p.blocked
p.blocked = true
p.mu.Unlock()
if closed {
return fmt.Errorf("nettest.Pipe(%q).Block: closed", p.name)
@@ -223,17 +222,17 @@ func (p *Pipe) Block() error {
if blocked {
return fmt.Errorf("nettest.Pipe(%q).Block: already blocked", p.name)
}
p.signalRead()
p.signalWrite()
p.cnd.Broadcast()
return nil
}
// Unblock will cause all blocked Read/Write calls to continue execution.
func (p *Pipe) Unblock() error {
p.mu.Lock()
defer p.mu.Unlock()
closed := p.closed
blocked := p.blocked
p.blocked = false
p.mu.Unlock()
if closed {
return fmt.Errorf("nettest.Pipe(%q).Block: closed", p.name)
@@ -241,21 +240,6 @@ func (p *Pipe) Unblock() error {
if !blocked {
return fmt.Errorf("nettest.Pipe(%q).Block: already unblocked", p.name)
}
p.signalRead()
p.signalWrite()
p.cnd.Broadcast()
return nil
}
func (p *Pipe) signalRead() {
select {
case p.rCh <- struct{}{}:
default:
}
}
func (p *Pipe) signalWrite() {
select {
case p.wCh <- struct{}{}:
default:
}
}

View File

@@ -7,6 +7,7 @@ package nettest
import (
"errors"
"fmt"
"os"
"testing"
"time"
)
@@ -35,7 +36,7 @@ func TestPipeTimeout(t *testing.T) {
p := NewPipe("p1", 1<<16)
p.SetWriteDeadline(time.Now().Add(-1 * time.Second))
n, err := p.Write([]byte{'h'})
if !errors.Is(err, ErrWriteTimeout) || !errors.Is(err, ErrTimeout) {
if !errors.Is(err, os.ErrDeadlineExceeded) {
t.Errorf("missing write timeout got err: %v", err)
}
if n != 0 {
@@ -49,7 +50,7 @@ func TestPipeTimeout(t *testing.T) {
p.SetReadDeadline(time.Now().Add(-1 * time.Second))
b := make([]byte, 1)
n, err := p.Read(b)
if !errors.Is(err, ErrReadTimeout) || !errors.Is(err, ErrTimeout) {
if !errors.Is(err, os.ErrDeadlineExceeded) {
t.Errorf("missing read timeout got err: %v", err)
}
if n != 0 {
@@ -65,7 +66,7 @@ func TestPipeTimeout(t *testing.T) {
if err := p.Block(); err != nil {
t.Fatal(err)
}
if _, err := p.Write([]byte{'h'}); !errors.Is(err, ErrWriteTimeout) {
if _, err := p.Write([]byte{'h'}); !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("want write timeout got: %v", err)
}
})
@@ -80,7 +81,7 @@ func TestPipeTimeout(t *testing.T) {
if err := p.Block(); err != nil {
t.Fatal(err)
}
if _, err := p.Read(b); !errors.Is(err, ErrReadTimeout) {
if _, err := p.Read(b); !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("want read timeout got: %v", err)
}
})

13
net/stun/stun_fuzzer.go Normal file
View File

@@ -0,0 +1,13 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build gofuzz
package stun
func FuzzStunParser(data []byte) int {
_, _, _, _ = ParseResponse(data)
_, _ = ParseBindingRequest(data)
return 1
}

View File

@@ -35,6 +35,7 @@ var (
ulaRange oncePrefix
tsUlaRange oncePrefix
ula4To6Range oncePrefix
ulaEph6Range oncePrefix
)
// TailscaleServiceIP returns the listen address of services
@@ -72,6 +73,17 @@ func Tailscale4To6Range() netaddr.IPPrefix {
return ula4To6Range.v
}
// TailscaleEphemeral6Range returns the subset of TailscaleULARange
// used for ephemeral IPv6-only Tailscale nodes.
func TailscaleEphemeral6Range() netaddr.IPPrefix {
// This IP range has no significance, beyond being a subset of
// TailscaleULARange. The bits from /48 to /64 were picked at
// random, with the only criterion being to not be the conflict
// with the Tailscale4To6Range above.
ulaEph6Range.Do(func() { mustPrefix(&ulaEph6Range.v, "fd7a:115c:a1e0:efe3::/64") })
return ulaEph6Range.v
}
// Tailscale4To6Placeholder returns an IP address that can be used as
// a source IP when one is required, but a netmap didn't provide
// any. This address never gets allocated by the 4-to-6 algorithm in

View File

@@ -28,16 +28,23 @@ import (
// discovery.
const minimalMTU = 1280
// New returns a tun.Device for the requested device name.
func New(logf logger.Logf, tunName string) (tun.Device, error) {
// New returns a tun.Device for the requested device name, along with
// the OS-dependent name that was allocated to the device.
func New(logf logger.Logf, tunName string) (tun.Device, string, error) {
dev, err := tun.CreateTUN(tunName, minimalMTU)
if err != nil {
return nil, err
return nil, "", err
}
if err := waitInterfaceUp(dev, 90*time.Second, logf); err != nil {
return nil, err
dev.Close()
return nil, "", err
}
return dev, nil
name, err := interfaceName(dev)
if err != nil {
dev.Close()
return nil, "", err
}
return dev, name, nil
}
// Diagnose tries to explain a tuntap device creation failure.

View File

@@ -0,0 +1,13 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build !windows
package tstun
import "github.com/tailscale/wireguard-go/tun"
func interfaceName(dev tun.Device) (string, error) {
return dev.Name()
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/tailscale/wireguard-go/tun"
"github.com/tailscale/wireguard-go/tun/wintun"
"golang.org/x/sys/windows"
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
)
func init() {
@@ -22,3 +23,11 @@ func init() {
}
tun.WintunStaticRequestedGUID = &guid
}
func interfaceName(dev tun.Device) (string, error) {
guid, err := winipcfg.LUID(dev.(*tun.NativeTun).LUID()).GUID()
if err != nil {
return "", err
}
return guid.String(), nil
}

View File

@@ -115,6 +115,9 @@ type Wrapper struct {
// disableFilter disables all filtering when set. This should only be used in tests.
disableFilter bool
// disableTSMPRejected disables TSMP rejected responses. For tests.
disableTSMPRejected bool
}
func Wrap(logf logger.Logf, tdev tun.Device) *Wrapper {
@@ -351,13 +354,26 @@ func (t *Wrapper) filterIn(buf []byte) filter.Response {
return filter.Drop
}
if filt.RunIn(p, t.filterFlags) != filter.Accept {
outcome := filt.RunIn(p, t.filterFlags)
// Let peerapi through the filter; its ACLs are handled at L7,
// not at the packet level.
if outcome != filter.Accept &&
p.IPProto == ipproto.TCP &&
p.TCPFlags&packet.TCPSyn != 0 &&
t.PeerAPIPort != nil {
if port, ok := t.PeerAPIPort(p.Dst.IP); ok && port == p.Dst.Port {
outcome = filter.Accept
}
}
if outcome != filter.Accept {
// Tell them, via TSMP, we're dropping them due to the ACL.
// Their host networking stack can translate this into ICMP
// or whatnot as required. But notably, their GUI or tailscale CLI
// can show them a rejection history with reasons.
if p.IPVersion == 4 && p.IPProto == ipproto.TCP && p.TCPFlags&packet.TCPSyn != 0 {
if p.IPVersion == 4 && p.IPProto == ipproto.TCP && p.TCPFlags&packet.TCPSyn != 0 && !t.disableTSMPRejected {
rj := packet.TailscaleRejectedHeader{
IPSrc: p.Dst.IP,
IPDst: p.Src.IP,

View File

@@ -6,6 +6,7 @@ package tstun
import (
"bytes"
"encoding/binary"
"fmt"
"strconv"
"strings"
@@ -42,6 +43,34 @@ func udp4(src, dst string, sport, dport uint16) []byte {
return packet.Generate(header, []byte("udp_payload"))
}
func tcp4syn(src, dst string, sport, dport uint16) []byte {
sip, err := netaddr.ParseIP(src)
if err != nil {
panic(err)
}
dip, err := netaddr.ParseIP(dst)
if err != nil {
panic(err)
}
ipHeader := packet.IP4Header{
IPProto: ipproto.TCP,
Src: sip,
Dst: dip,
IPID: 0,
}
tcpHeader := make([]byte, 20)
binary.BigEndian.PutUint16(tcpHeader[0:], sport)
binary.BigEndian.PutUint16(tcpHeader[2:], dport)
tcpHeader[13] |= 2 // SYN
both := packet.Generate(ipHeader, tcpHeader)
// 20 byte IP4 + 20 byte TCP
binary.BigEndian.PutUint16(both[2:4], 40)
return both
}
func nets(nets ...string) (ret []netaddr.IPPrefix) {
for _, s := range nets {
if i := strings.IndexByte(s, '/'); i == -1 {
@@ -385,3 +414,70 @@ func TestAtomic64Alignment(t *testing.T) {
c := new(Wrapper)
atomic.StoreInt64(&c.lastActivityAtomic, 123)
}
func TestPeerAPIBypass(t *testing.T) {
wrapperWithPeerAPI := &Wrapper{
PeerAPIPort: func(ip netaddr.IP) (port uint16, ok bool) {
if ip == netaddr.MustParseIP("100.64.1.2") {
return 60000, true
}
return
},
}
tests := []struct {
name string
w *Wrapper
filter *filter.Filter
pkt []byte
want filter.Response
}{
{
name: "reject_nil_filter",
w: &Wrapper{
PeerAPIPort: func(netaddr.IP) (port uint16, ok bool) {
return 60000, true
},
},
pkt: tcp4syn("1.2.3.4", "100.64.1.2", 1234, 60000),
want: filter.Drop,
},
{
name: "reject_with_filter",
w: &Wrapper{},
filter: filter.NewAllowNone(logger.Discard, new(netaddr.IPSet)),
pkt: tcp4syn("1.2.3.4", "100.64.1.2", 1234, 60000),
want: filter.Drop,
},
{
name: "peerapi_bypass_filter",
w: wrapperWithPeerAPI,
filter: filter.NewAllowNone(logger.Discard, new(netaddr.IPSet)),
pkt: tcp4syn("1.2.3.4", "100.64.1.2", 1234, 60000),
want: filter.Accept,
},
{
name: "peerapi_dont_bypass_filter_wrong_port",
w: wrapperWithPeerAPI,
filter: filter.NewAllowNone(logger.Discard, new(netaddr.IPSet)),
pkt: tcp4syn("1.2.3.4", "100.64.1.2", 1234, 60001),
want: filter.Drop,
},
{
name: "peerapi_dont_bypass_filter_wrong_dst_ip",
w: wrapperWithPeerAPI,
filter: filter.NewAllowNone(logger.Discard, new(netaddr.IPSet)),
pkt: tcp4syn("1.2.3.4", "100.64.1.3", 1234, 60000),
want: filter.Drop,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.w.SetFilter(tt.filter)
tt.w.disableTSMPRejected = true
if got := tt.w.filterIn(tt.pkt); got != tt.want {
t.Errorf("got = %v; want %v", got, tt.want)
}
})
}
}

View File

@@ -17,16 +17,6 @@ import (
// containing a directory we can read/write in.
var IOSSharedDir atomic.Value
// LegacyConfigPath returns the path used by the pre-tailscaled
// "relaynode" daemon's config file. It returns the empty string for
// platforms where relaynode never ran.
func LegacyConfigPath() string {
if runtime.GOOS == "windows" {
return ""
}
return "/var/lib/tailscale/relay.conf"
}
// DefaultTailscaledSocket returns the path to the tailscaled Unix socket
// or the empty string if there's no reasonable default.
func DefaultTailscaledSocket() string {

View File

@@ -42,7 +42,9 @@ func parsePort(s string) int {
}
func isLoopbackAddr(s string) bool {
return strings.HasPrefix(s, "127.0.0.1:") || strings.HasPrefix(s, "127.0.0.1.")
return strings.HasPrefix(s, "127.") ||
strings.HasPrefix(s, "[::1]:") ||
strings.HasPrefix(s, "::1.")
}
type nothing struct{}

View File

@@ -5,6 +5,7 @@
package portlist
import (
"encoding/json"
"testing"
)
@@ -40,7 +41,12 @@ udp6 0 0 :::5354 :::*
// macOS
tcp4 0 0 *.23 *.* LISTEN
tcp6 0 0 *.24 *.* LISTEN
tcp6 0 0 *.24 *.* LISTEN
tcp4 0 0 *.8185 *.* LISTEN
tcp4 0 0 127.0.0.1.8186 *.* LISTEN
tcp6 0 0 ::1.8187 *.* LISTEN
tcp4 0 0 127.1.2.3.8188 *.* LISTEN
udp6 0 0 *.5453 *.*
udp4 0 0 *.5553 *.*
@@ -73,14 +79,21 @@ func TestParsePortsNetstat(t *testing.T) {
Port{"udp", 5354, "", ""},
Port{"udp", 5453, "", ""},
Port{"udp", 5553, "", ""},
Port{"tcp", 8185, "", ""}, // but not 8186, 8187, 8188 on localhost
Port{"udp", 9353, "iTunes", ""},
}
pl := parsePortsNetstat(netstatOutput)
jgot, _ := json.MarshalIndent(pl, "", "\t")
jwant, _ := json.MarshalIndent(want, "", "\t")
if len(pl) != len(want) {
t.Fatalf("Got:\n%s\n\nWant:\n%s\n", jgot, jwant)
}
for i := range pl {
if pl[i] != want[i] {
t.Errorf("row#%d\n got: %#v\n\nwant: %#v\n",
i, pl[i], want[i])
t.Fatalf("Got:\n%s\n\nWant:\n%s\n", jgot, jwant)
}
}
}

View File

@@ -81,7 +81,7 @@ func (p *Poller) Run(ctx context.Context) error {
p.Err = err
return err
}
if pl.SameInodes(p.prev) {
if pl.sameInodes(p.prev) {
continue
}
p.prev = pl

View File

@@ -6,6 +6,7 @@ package portlist
import (
"fmt"
"sort"
"strings"
)
@@ -48,7 +49,7 @@ func (a *Port) lessThan(b *Port) bool {
return false
}
func (a List) SameInodes(b List) bool {
func (a List) sameInodes(b List) bool {
if a == nil || b == nil || len(a) != len(b) {
return false
}
@@ -76,7 +77,8 @@ func GetList(prev List) (List, error) {
if err != nil {
return nil, fmt.Errorf("listPorts: %s", err)
}
if pl.SameInodes(prev) {
pl = sortAndDedup(pl)
if pl.sameInodes(prev) {
// Nothing changed, skip inode lookup
return prev, nil
}
@@ -86,3 +88,22 @@ func GetList(prev List) (List, error) {
}
return pl, nil
}
// sortAndDedup sorts ps in place (by Port.lessThan) and then returns
// a subset of it with duplicate (Proto, Port) removed.
func sortAndDedup(ps List) List {
sort.Slice(ps, func(i, j int) bool {
return (&ps[i]).lessThan(&ps[j])
})
out := ps[:0]
var last Port
for _, p := range ps {
protoPort := Port{Proto: p.Proto, Port: p.Port}
if last == protoPort {
continue
}
out = append(out, p)
last = protoPort
}
return out
}

View File

@@ -12,7 +12,6 @@ import (
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"syscall"
@@ -25,8 +24,6 @@ import (
// Reading the sockfiles on Linux is very fast, so we can do it often.
const pollInterval = 1 * time.Second
// TODO(apenwarr): Include IPv6 ports eventually.
// Right now we don't route IPv6 anyway so it's better to exclude them.
var sockfiles = []string{"/proc/net/tcp", "/proc/net/tcp6", "/proc/net/udp", "/proc/net/udp6"}
var sawProcNetPermissionErr syncs.AtomicBool
@@ -73,11 +70,6 @@ func listPorts() (List, error) {
l = append(l, ports...)
}
sort.Slice(l, func(i, j int) bool {
return (&l[i]).lessThan(&l[j])
})
return l, nil
}

View File

@@ -33,8 +33,9 @@ var lsofFailed int64 // atomic bool
// However, "netstat -na" runs ~100x faster than lsof on my machine, so
// we should do it only if the list of open ports has actually changed.
//
// TODO(apenwarr): this fails in a macOS sandbox (ie. our usual case).
// We might as well just delete this code if we can't find a solution.
// This fails in a macOS sandbox (i.e. in the Mac App Store or System
// Extension GUI build), but does at least work in the
// tailscaled-on-macos mode.
func addProcesses(pl []Port) ([]Port, error) {
if atomic.LoadInt64(&lsofFailed) != 0 {
// This previously failed in the macOS sandbox, so don't try again.

View File

@@ -185,7 +185,7 @@ func TestSameInodes(t *testing.T) {
},
}
for _, tt := range tests {
got := tt.a.SameInodes(tt.b)
got := tt.a.sameInodes(tt.b)
if got != tt.want {
t.Errorf("%s: Equal = %v; want %v", tt.name, got, tt.want)
}

View File

@@ -16,11 +16,21 @@ import (
const pollInterval = 5 * time.Second
func listPorts() (List, error) {
// TODO(bradfitz): stop shelling out to netstat and use the
// net/netstat package instead. When doing so, be sure to filter
// out all of 127.0.0.0/8 and not just 127.0.0.1.
return listPortsNetstat("-na")
}
func addProcesses(pl []Port) ([]Port, error) {
if t := windows.GetCurrentProcessToken(); !t.IsElevated() {
// OpenCurrentProcessToken instead of GetCurrentProcessToken,
// as GetCurrentProcessToken only works on Windows 8+.
tok, err := windows.OpenCurrentProcessToken()
if err != nil {
return nil, err
}
defer tok.Close()
if !tok.IsElevated() {
return listPortsNetstat("-na")
}
return listPortsNetstat("-nab")

View File

@@ -15,7 +15,6 @@ func path(vendor, name string, port uint16) string {
return fmt.Sprintf("127.0.0.1:%v", port)
}
// TODO(apenwarr): handle magic cookie auth
func connect(path string, port uint16) (net.Conn, error) {
pipe, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
@@ -36,7 +35,7 @@ func setFlags(network, address string, c syscall.RawConn) error {
// built on top of an API that's a disaster. So for now we'll hack it by
// just always using a TCP session on a fixed port on localhost. As a
// result, on Windows we ignore the vendor and name strings.
// TODO(apenwarr): handle magic cookie auth
// NOTE(bradfitz): Jason did a new pipe package: https://go-review.googlesource.com/c/sys/+/299009
func listen(path string, port uint16) (_ net.Listener, gotPort uint16, _ error) {
lc := net.ListenConfig{
Control: setFlags,

View File

@@ -4,7 +4,7 @@
package tailcfg
//go:generate go run tailscale.com/cmd/cloner --type=User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse --clonefunc=true --output=tailcfg_clone.go
//go:generate go run tailscale.com/cmd/cloner --type=User,Node,Hostinfo,NetInfo,Login,DNSConfig,DNSResolver,RegisterResponse --clonefunc=true --output=tailcfg_clone.go
import (
"bytes"
@@ -36,7 +36,12 @@ import (
// 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
const CurrentMapRequestVersion = 13
// 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)
const CurrentMapRequestVersion = 18
type StableID string
@@ -154,7 +159,17 @@ type Node struct {
DERP string `json:",omitempty"` // DERP-in-IP:port ("127.3.3.40:N") endpoint
Hostinfo Hostinfo
Created time.Time
LastSeen *time.Time `json:",omitempty"`
// LastSeen is when the node was last online. It is not
// updated when Online is true. It is nil if the current
// node doesn't have permission to know, or the node
// has never been online.
LastSeen *time.Time `json:",omitempty"`
// Online is whether the node is currently connected to the
// coordination server. A value of nil means unknown, or the
// current node doesn't have permission to know.
Online *bool `json:",omitempty"`
KeepAlive bool `json:",omitempty"` // open and keep open a connection to this peer
@@ -164,7 +179,7 @@ type Node struct {
// They're free-form strings, but should be in the form of URLs/URIs
// such as:
// "https://tailscale.com/cap/is-admin"
// "https://tailscale.com/cap/recv-file"
// "https://tailscale.com/cap/file-sharing"
Capabilities []string `json:",omitempty"`
// The following three computed fields hold the various names that can
@@ -590,8 +605,12 @@ type RegisterRequest struct {
Oauth2Token *Oauth2Token
AuthKey string
}
Expiry time.Time // requested key expiry, server policy may override
Followup string // response waits until AuthURL is visited
// Expiry optionally specifies the requested key expiry.
// The server policy may override.
// As a special case, if Expiry is in the past and NodeKey is
// the node's current key, the key is expired.
Expiry time.Time
Followup string // response waits until AuthURL is visited
Hostinfo *Hostinfo
// The following fields are not used for SignatureNone and are required for
@@ -630,6 +649,42 @@ type RegisterResponse struct {
AuthURL string // if set, authorization pending
}
// EndpointType distinguishes different sources of MapRequest.Endpoint values.
type EndpointType int
const (
EndpointUnknownType = EndpointType(0)
EndpointLocal = EndpointType(1)
EndpointSTUN = EndpointType(2)
EndpointPortmapped = EndpointType(3)
EndpointSTUN4LocalPort = EndpointType(4) // hard NAT: STUN'ed IPv4 address + local fixed port
)
func (et EndpointType) String() string {
switch et {
case EndpointUnknownType:
return "?"
case EndpointLocal:
return "local"
case EndpointSTUN:
return "stun"
case EndpointPortmapped:
return "portmap"
case EndpointSTUN4LocalPort:
return "stun4localport"
}
return "other"
}
// Endpoint is an endpoint IPPort and an associated type.
// It doesn't currently go over the wire as is but is instead
// broken up into two parallel slices in MapReqeust, for compatibility
// reasons. But this type is used in the codebase.
type Endpoint struct {
Addr netaddr.IPPort
Type EndpointType
}
// MapRequest is sent by a client to start a long-poll network map updates.
// The request includes a copy of the client's current set of WireGuard
// endpoints and general host information.
@@ -649,11 +704,15 @@ type MapRequest struct {
KeepAlive bool // whether server should send keep-alives back to us
NodeKey NodeKey
DiscoKey DiscoKey
Endpoints []string // caller's endpoints (IPv4 or IPv6)
IncludeIPv6 bool `json:",omitempty"` // include IPv6 endpoints in returned Node Endpoints (for Version 4 clients)
Stream bool // if true, multiple MapResponse objects are returned
IncludeIPv6 bool `json:",omitempty"` // include IPv6 endpoints in returned Node Endpoints (for Version 4 clients)
Stream bool // if true, multiple MapResponse objects are returned
Hostinfo *Hostinfo
// 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.
EndpointTypes []EndpointType `json:",omitempty"`
// ReadOnly is whether the client just wants to fetch the
// MapResponse, without updating their Endpoints. The
// Endpoints field will be ignored and LastSeen will not be
@@ -763,19 +822,55 @@ var FilterAllowAll = []FilterRule{
},
}
// DNSResolver is the configuration for one DNS resolver.
type DNSResolver struct {
// Addr is the address of the DNS resolver, one of:
// - A plain IP address for a "classic" UDP+TCP DNS resolver
// - [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
// DoT/DoH resolver, if the resolver URL does not reference an IP
// address directly.
// BootstrapResolution may be empty, in which case clients should
// look up the DoT/DoH server using their local "classic" DNS
// resolver.
BootstrapResolution []netaddr.IP `json:",omitempty"`
}
// DNSConfig is the DNS configuration.
type DNSConfig struct {
// Resolvers are the DNS resolvers to use, in order of preference.
Resolvers []DNSResolver `json:",omitempty"`
// Routes maps DNS name suffixes to a set of DNS resolvers to
// use. It is used to implement "split DNS" and other advanced DNS
// routing overlays.
// Map keys must be fully-qualified DNS name suffixes, with a
// trailing dot but no leading dot.
Routes map[string][]DNSResolver `json:",omitempty"`
// FallbackResolvers is like Resolvers, but is only used if a
// split DNS configuration is requested in a configuration that
// doesn't work yet without explicit default resolvers.
// https://github.com/tailscale/tailscale/issues/1743
FallbackResolvers []DNSResolver `json:",omitempty"`
// Domains are the search domains to use.
// Search domains must be FQDNs, but *without* the trailing dot.
Domains []string `json:",omitempty"`
// Proxied turns on automatic resolution of hostnames for devices
// in the network map, aka MagicDNS.
// Despite the (legacy) name, does not necessarily cause request
// proxying to be enabled.
Proxied bool `json:",omitempty"`
// The following fields are only set and used by
// MapRequest.Version >=9 and <14.
// Nameservers are the IP addresses of the nameservers to use.
Nameservers []netaddr.IP `json:",omitempty"`
// Domains are the search domains to use.
Domains []string `json:",omitempty"`
// PerDomain is not set by the control server, and does nothing.
// TODO(danderson): revise DNS configuration to make this useful
// again.
PerDomain bool
// Proxied indicates whether DNS requests are proxied through a dns.Resolver.
// This enables MagicDNS.
Proxied bool
PerDomain bool `json:",omitempty"`
}
// PingRequest is a request to send an HTTP request to prove the
@@ -804,8 +899,14 @@ type MapResponse struct {
PingRequest *PingRequest `json:",omitempty"`
// Networking
Node *Node
DERPMap *DERPMap `json:",omitempty"` // if non-empty, a change in the DERP map.
// Node describes the node making the map request.
// Starting with MapRequest.Version 18, nil means unchanged.
Node *Node `json:",omitempty"`
// DERPMap describe the set of DERP servers available.
// A nil value means unchanged.
DERPMap *DERPMap `json:",omitempty"`
// Peers, if non-empty, is the complete list of peers.
// It will be set in the first MapResponse for a long-polled request/response.
@@ -828,24 +929,20 @@ type MapResponse struct {
// the LastSeen time is now. Absent means unchanged.
PeerSeenChange map[NodeID]bool `json:",omitempty"`
// OnlineChange changes the value of a Peer Node.Online value.
OnlineChange map[NodeID]bool `json:",omitempty"`
// DNS is the same as DNSConfig.Nameservers.
//
// TODO(dmytro): should be sent in DNSConfig.Nameservers once clients have updated.
// Only populated if MapRequest.Version < 9.
DNS []netaddr.IP `json:",omitempty"`
// SearchPaths is the old way to specify DNS search
// domains. Clients should use these values if set, but the
// server will omit this field for clients with
// MapRequest.Version >= 9. Clients should prefer to use
// DNSConfig.Domains instead.
// SearchPaths is the old way to specify DNS search domains.
// Only populated if MapRequest.Version < 9.
SearchPaths []string `json:",omitempty"`
// DNSConfig contains the DNS settings for the client to use.
//
// TODO(bradfitz): make this a pointer and conditionally sent
// only if changed, like DERPMap, PacketFilter, etc. It's
// small, though.
DNSConfig DNSConfig `json:",omitempty"`
// A nil value means no change from an earlier non-nil value.
DNSConfig *DNSConfig `json:",omitempty"`
// Domain is the name of the network that this node is
// in. It's either of the form "example.com" (for user
@@ -853,7 +950,8 @@ type MapResponse struct {
// "foo@gmail.com" (for siloed users on shared email
// providers). Its exact form should not be depended on; new
// forms are coming later.
Domain string
// If empty, the value is unchanged.
Domain string `json:",omitempty"`
// CollectServices reports whether this node's Tailnet has
// requested that info about services be included in HostInfo.
@@ -867,9 +965,12 @@ type MapResponse struct {
// previously streamed non-nil MapResponse.PacketFilter within
// the same HTTP response. A non-nil but empty list always means
// no PacketFilter (that is, to block everything).
PacketFilter []FilterRule
PacketFilter []FilterRule `json:",omitempty"`
UserProfiles []UserProfile // as of 1.1.541 (mapver 5): may be new or updated user profiles only
// UserProfiles are the user profiles of nodes in the network.
// As as of 1.1.541 (mapver 5), this contains new or updated
// user profiles only.
UserProfiles []UserProfile `json:",omitempty"`
// Debug is normally nil, except for when the control server
// is setting debug settings on a node.
@@ -976,6 +1077,7 @@ func (n *Node) Equal(n2 *Node) bool {
n.KeyExpiry.Equal(n2.KeyExpiry) &&
n.Machine == n2.Machine &&
n.DiscoKey == n2.DiscoKey &&
eqBoolPtr(n.Online, n2.Online) &&
eqCIDRs(n.Addresses, n2.Addresses) &&
eqCIDRs(n.AllowedIPs, n2.AllowedIPs) &&
eqStrings(n.Endpoints, n2.Endpoints) &&
@@ -990,6 +1092,17 @@ func (n *Node) Equal(n2 *Node) bool {
n.ComputedNameWithHost == n2.ComputedNameWithHost
}
func eqBoolPtr(a, b *bool) bool {
if a == b { // covers nil
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func eqStrings(a, b []string) bool {
if len(a) != len(b) || ((a == nil) != (b == nil)) {
return false
@@ -1018,12 +1131,6 @@ func eqTimePtr(a, b *time.Time) bool {
return ((a == nil) == (b == nil)) && (a == nil || a.Equal(*b))
}
// WhoIsResponse is the JSON type returned by tailscaled debug server's /whois?ip=$IP handler.
type WhoIsResponse struct {
Node *Node
UserProfile *UserProfile
}
// Oauth2Token is a copy of golang.org/x/oauth2.Token, to avoid the
// go.mod dependency on App Engine and grpc, which was causing problems.
// All we actually needed was this struct on the client side.
@@ -1048,3 +1155,8 @@ type Oauth2Token struct {
// mechanisms for that TokenSource will not be used.
Expiry time.Time `json:"expiry,omitempty"`
}
const (
CapabilityFileSharing = "https://tailscale.com/cap/file-sharing"
CapabilityAdmin = "https://tailscale.com/cap/is-admin"
)

View File

@@ -2,7 +2,7 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Code generated by tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse; DO NOT EDIT.
// Code generated by tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,DNSResolver,RegisterResponse; DO NOT EDIT.
package tailcfg
@@ -26,7 +26,7 @@ func (src *User) Clone() *User {
}
// A compilation failure here means this code must be regenerated, with command:
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,DNSResolver,RegisterResponse
var _UserNeedsRegeneration = User(struct {
ID UserID
LoginName string
@@ -53,12 +53,16 @@ func (src *Node) Clone() *Node {
dst.LastSeen = new(time.Time)
*dst.LastSeen = *src.LastSeen
}
if dst.Online != nil {
dst.Online = new(bool)
*dst.Online = *src.Online
}
dst.Capabilities = append(src.Capabilities[:0:0], src.Capabilities...)
return dst
}
// A compilation failure here means this code must be regenerated, with command:
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,DNSResolver,RegisterResponse
var _NodeNeedsRegeneration = Node(struct {
ID NodeID
StableID StableNodeID
@@ -76,6 +80,7 @@ var _NodeNeedsRegeneration = Node(struct {
Hostinfo Hostinfo
Created time.Time
LastSeen *time.Time
Online *bool
KeepAlive bool
MachineAuthorized bool
Capabilities []string
@@ -100,7 +105,7 @@ func (src *Hostinfo) Clone() *Hostinfo {
}
// A compilation failure here means this code must be regenerated, with command:
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,DNSResolver,RegisterResponse
var _HostinfoNeedsRegeneration = Hostinfo(struct {
IPNVersion string
FrontendLogID string
@@ -137,7 +142,7 @@ func (src *NetInfo) Clone() *NetInfo {
}
// A compilation failure here means this code must be regenerated, with command:
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,DNSResolver,RegisterResponse
var _NetInfoNeedsRegeneration = NetInfo(struct {
MappingVariesByDestIP opt.Bool
HairPinning opt.Bool
@@ -164,7 +169,7 @@ func (src *Login) Clone() *Login {
}
// A compilation failure here means this code must be regenerated, with command:
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,DNSResolver,RegisterResponse
var _LoginNeedsRegeneration = Login(struct {
_ structs.Incomparable
ID LoginID
@@ -183,18 +188,54 @@ func (src *DNSConfig) Clone() *DNSConfig {
}
dst := new(DNSConfig)
*dst = *src
dst.Nameservers = append(src.Nameservers[:0:0], src.Nameservers...)
dst.Resolvers = make([]DNSResolver, len(src.Resolvers))
for i := range dst.Resolvers {
dst.Resolvers[i] = *src.Resolvers[i].Clone()
}
if dst.Routes != nil {
dst.Routes = map[string][]DNSResolver{}
for k := range src.Routes {
dst.Routes[k] = append([]DNSResolver{}, src.Routes[k]...)
}
}
dst.FallbackResolvers = make([]DNSResolver, len(src.FallbackResolvers))
for i := range dst.FallbackResolvers {
dst.FallbackResolvers[i] = *src.FallbackResolvers[i].Clone()
}
dst.Domains = append(src.Domains[:0:0], src.Domains...)
dst.Nameservers = append(src.Nameservers[:0:0], src.Nameservers...)
return dst
}
// A compilation failure here means this code must be regenerated, with command:
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,DNSResolver,RegisterResponse
var _DNSConfigNeedsRegeneration = DNSConfig(struct {
Nameservers []netaddr.IP
Domains []string
PerDomain bool
Proxied bool
Resolvers []DNSResolver
Routes map[string][]DNSResolver
FallbackResolvers []DNSResolver
Domains []string
Proxied bool
Nameservers []netaddr.IP
PerDomain bool
}{})
// Clone makes a deep copy of DNSResolver.
// The result aliases no memory with the original.
func (src *DNSResolver) Clone() *DNSResolver {
if src == nil {
return nil
}
dst := new(DNSResolver)
*dst = *src
dst.BootstrapResolution = append(src.BootstrapResolution[:0:0], src.BootstrapResolution...)
return dst
}
// A compilation failure here means this code must be regenerated, with command:
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,DNSResolver,RegisterResponse
var _DNSResolverNeedsRegeneration = DNSResolver(struct {
Addr string
BootstrapResolution []netaddr.IP
}{})
// Clone makes a deep copy of RegisterResponse.
@@ -210,7 +251,7 @@ func (src *RegisterResponse) Clone() *RegisterResponse {
}
// A compilation failure here means this code must be regenerated, with command:
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse
// tailscale.com/cmd/cloner -type User,Node,Hostinfo,NetInfo,Login,DNSConfig,DNSResolver,RegisterResponse
var _RegisterResponseNeedsRegeneration = RegisterResponse(struct {
User User
Login Login
@@ -221,7 +262,7 @@ var _RegisterResponseNeedsRegeneration = RegisterResponse(struct {
// Clone duplicates src into dst and reports whether it succeeded.
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
// where T is one of User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse.
// where T is one of User,Node,Hostinfo,NetInfo,Login,DNSConfig,DNSResolver,RegisterResponse.
func Clone(dst, src interface{}) bool {
switch src := src.(type) {
case *User:
@@ -278,6 +319,15 @@ func Clone(dst, src interface{}) bool {
*dst = src.Clone()
return true
}
case *DNSResolver:
switch dst := dst.(type) {
case *DNSResolver:
*dst = *src.Clone()
return true
case **DNSResolver:
*dst = src.Clone()
return true
}
case *RegisterResponse:
switch dst := dst.(type) {
case *RegisterResponse:

View File

@@ -193,7 +193,7 @@ func TestNodeEqual(t *testing.T) {
"ID", "StableID", "Name", "User", "Sharer",
"Key", "KeyExpiry", "Machine", "DiscoKey",
"Addresses", "AllowedIPs", "Endpoints", "DERP", "Hostinfo",
"Created", "LastSeen", "KeepAlive", "MachineAuthorized",
"Created", "LastSeen", "Online", "KeepAlive", "MachineAuthorized",
"Capabilities",
"ComputedName", "computedHostIfDifferent", "ComputedNameWithHost",
}
@@ -500,3 +500,21 @@ func TestUserProfileJSONMarshalForMac(t *testing.T) {
t.Fatalf("Unmarshal: %v", err)
}
}
func TestEndpointTypeMarshal(t *testing.T) {
eps := []EndpointType{
EndpointUnknownType,
EndpointLocal,
EndpointSTUN,
EndpointPortmapped,
EndpointSTUN4LocalPort,
}
got, err := json.Marshal(eps)
if err != nil {
t.Fatal(err)
}
const want = `[0,1,2,3,4]`
if string(got) != want {
t.Errorf("got %s; want %s", got, want)
}
}

View File

@@ -18,6 +18,10 @@ import (
"tailscale.com/wgengine/filter"
)
// NetworkMap is the current state of the world.
//
// The fields should all be considered read-only. They might
// alias parts of previous NetworkMap values.
type NetworkMap struct {
// Core networking
@@ -53,11 +57,8 @@ type NetworkMap struct {
User tailcfg.UserID
Domain string
// TODO(crawshaw): reduce UserProfiles to []tailcfg.UserProfile?
// There are lots of ways to slice this data, leave it up to users.
UserProfiles map[tailcfg.UserID]tailcfg.UserProfile
// TODO(crawshaw): Groups []tailcfg.Group
// TODO(crawshaw): Capabilities []tailcfg.Capability
}
// MagicDNSSuffix returns the domain's MagicDNS suffix (even if

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